#!/usr/bin/env python3
"""Bandingkan jumlah argumen pemanggilan dengan tanda tangan konstruktornya.

Kesalahan yang lolos dari pemeriksa lama: kelas dikenal, medan API cocok,
tetapi widget dipanggil dengan dua argumen posisional sementara
konstruktornya hanya menerima satu. Compiler menolaknya, dan itu baru
ketahuan saat build gagal di mesin orang lain.
"""
import re, os, sys

AKAR = sys.argv[1] if len(sys.argv) > 1 else '.'
# Contoh: python3 tools/cek-argumen.py mobile_siswa

DEKLARASI = {}


def urai_named(arg):
    """Ambil parameter bernama beserta status wajibnya dari blok {...}."""
    m = re.search(r'\{([^}]*)\}', arg)
    if not m:
        return {}
    hasil = {}
    for bagian in re.split(r',(?![^<]*>)', m.group(1)):
        bagian = bagian.strip()
        if not bagian:
            continue
        wajib = bagian.startswith('required ')
        # super.key dan this.x menyisakan nama setelah titik
        nama = re.sub(r'=.*$', '', bagian).strip().split()[-1]
        nama = nama.split('.')[-1].strip()
        if nama:
            hasil[nama] = wajib
    return hasil


def urai_tanda_tangan(isi):
    """Ambil jumlah argumen posisional yang diterima tiap kelas."""
    hasil = {}
    for m in re.finditer(r'class\s+(\w+)\s+extends\s+\w+', isi):
        kelas = m.group(1)
        blok = isi[m.end():m.end() + 3000]
        k = re.search(r'const\s+' + kelas + r'\(([^;]*?)\)\s*[:;{]', blok) \
            or re.search(r'\b' + kelas + r'\(([^;]*?)\)\s*[:;{]', blok)
        if not k:
            continue
        arg = k.group(1)
        # Catat letak deklarasinya supaya tidak ikut diperiksa sebagai pemanggilan
        DEKLARASI.setdefault(kelas, set()).add(m.end() + k.start())
        # Buang bagian bernama dan opsional agar sisanya posisional wajib
        tanpa_named = re.sub(r'\{[^}]*\}', '', arg)
        opsional = re.findall(r'\[([^\]]*)\]', tanpa_named)
        tanpa_opsional = re.sub(r'\[[^\]]*\]', '', tanpa_named)

        wajib = [x for x in tanpa_opsional.split(',') if x.strip()]
        tambahan = [x for x in ','.join(opsional).split(',') if x.strip()]
        hasil[kelas] = (len(wajib), len(wajib) + len(tambahan), urai_named(arg))
    return hasil

def hitung_argumen(isi, mulai):
    """Hitung argumen posisional pada satu pemanggilan.

    Tiga hal yang mudah salah dan sudah ditangani di sini:
      - koma di dalam generic seperti Map<String, dynamic> bukan pemisah
      - titik dua pada ternary a ? b : c bukan penanda argumen bernama
      - teks di dalam tanda kutip diabaikan seluruhnya
    """
    i, dalam, sudut, pos = mulai, 1, 0, 0
    ada_isi = bernama = tanya = False
    kutip = None
    dipakai = []          # nama parameter bernama yang diberikan
    kata = ''             # calon nama, dikumpulkan sampai bertemu ':'

    while i < len(isi) and dalam:
        c = isi[i]

        if kutip:
            if c == kutip and isi[i - 1] != '\\':
                kutip = None
            i += 1
            continue

        if c in '"\'':
            kutip = c; ada_isi = True; kata = ''; i += 1; continue

        if c in '([{':
            dalam += 1
        elif c in ')]}':
            dalam -= 1
            if dalam == 0 and ada_isi and not bernama:
                pos += 1
        elif dalam == 1 and c == '<' and isi[i - 1].isalnum():
            sudut += 1
        elif dalam == 1 and c == '>' and sudut:
            sudut -= 1
        elif dalam == 1 and sudut == 0 and c == ',':
            if ada_isi and not bernama:
                pos += 1
            ada_isi = bernama = tanya = False
            kata = ''
        elif dalam == 1 and sudut == 0 and c == '?':
            tanya = True
        elif dalam == 1 and sudut == 0 and c == ':':
            # Titik dua milik ternary bila sebelumnya ada tanda tanya pada
            # argumen yang sama. Selain itu ia menandai argumen bernama —
            # tetapi hanya bila yang mendahuluinya memang sebuah nama, bukan
            # ekspresi yang sudah berisi tanda kurung atau operator.
            if tanya:
                tanya = False
            elif kata and re.fullmatch(r'[A-Za-z_]\w*', kata):
                bernama = True
                dipakai.append(kata)
            kata = ''
        elif dalam == 1 and not c.isspace():
            ada_isi = True
            kata = kata + c if (c.isalnum() or c == '_') else ''
        i += 1
    return pos, dipakai

berkas = []
for root, _, fs in os.walk(os.path.join(AKAR, 'lib')):
    for f in fs:
        if f.endswith('.dart'):
            berkas.append(os.path.join(root, f))

# Kelas publik dikumpulkan lintas berkas; kelas privat (berawalan garis
# bawah) hanya berlaku di berkasnya sendiri. Tanpa pemisahan ini, dua layar
# yang sama-sama punya kelas _Baris akan saling menimpa tanda tangan dan
# memunculkan temuan palsu.
tanda = {}
lokal = {}
for p in berkas:
    isi = open(p).read()
    for kelas, info in urai_tanda_tangan(isi).items():
        if kelas.startswith('_'):
            lokal.setdefault(p, {})[kelas] = info
        else:
            tanda[kelas] = info

total = 0
for p in sorted(berkas):
    isi = open(p).read()
    temuan = []
    berlaku = {**tanda, **lokal.get(p, {})}
    for kelas, (minimal, maksimal, named) in berlaku.items():
        for m in re.finditer(r'(?<![\w.])' + kelas + r'\(', isi):
            # Lewati deklarasi konstruktornya sendiri. Letaknya dicatat saat
            # tanda tangan diurai, jadi pemanggilan biasa yang kebetulan ada
            # di awal baris tidak ikut terlewat — kekeliruan yang sempat
            # membuat pemeriksa ini diam terhadap kesalahan sungguhan.
            if any(abs(m.start() - d) < 12 for d in DEKLARASI.get(kelas, ())):
                continue
            n, dipakai = hitung_argumen(isi, m.end())
            baris = isi[:m.start()].count('\n') + 1

            if n < minimal or n > maksimal:
                batas = f"{minimal}" if minimal == maksimal else f"{minimal}-{maksimal}"
                temuan.append((baris,
                    f"{kelas} dipanggil dengan {n} argumen posisional, "
                    f"konstruktornya menerima {batas}"))

            # Nama parameter yang tidak dikenal — biasanya salah ketik
            for nama in dipakai:
                if named and nama not in named:
                    temuan.append((baris,
                        f"{kelas} diberi parameter '{nama}:' yang tidak ada "
                        f"pada konstruktornya"))

            # Parameter wajib yang tidak diisi
            for nama, wajib in named.items():
                if wajib and nama not in dipakai:
                    temuan.append((baris,
                        f"{kelas} kehilangan parameter wajib '{nama}:'"))
    if temuan:
        print(f"### {os.path.relpath(p, AKAR)}")
        for baris, pesan in sorted(set(temuan)):
            print(f"    baris {baris}: {pesan}")
            total += 1
        print()

print(f"Kelas diperiksa: {len(tanda)} | Ketidakcocokan argumen: {total}")
