#!/usr/bin/env python3
"""Kalnavi Code: a coding agent for your terminal. (c) 2026 Muhammad Abrar Rayhan. All rights reserved.

    python kalnavi.py                     interactive session in the current folder
    python kalnavi.py "perbaiki tes yang gagal"
    python kalnavi.py --yes "..."         approve edits and safe commands automatically (dangerous ones still ask)
    python kalnavi.py --auto "..."        mode auto: no questions; an AI judge clears each command, dangerous ones still ask
    python kalnavi.py --serve             bridge: Kalnavi Code on the website may run commands and ship in this folder
    python kalnavi.py --headless "..."    no human at all (CI): anything that would need a person is refused, and a report is
                                          written to .kalnavi/laporan.md; exit code 0 only when the task is done and verified

It reads, searches and edits your project and runs commands on YOUR machine, one step at a time, and asks before it
changes anything. Kalnavi's servers only decide the next step; your code never runs on them.

What makes it Kalnavi:
  - Peta repo: every file with its classes and functions goes to the model first, so it finds the right place in big projects.
  - Turnamen: when a fix is hard, several different AI models each write a patch; each one is tested in your project and
    the one that passes your tests wins (your files are always restored between rounds).
  - Gerbang verifikasi: it may not say "done" until a run after its last edit has passed.
  - Rencana: bigger tasks become a checklist that is ticked off step by step.
  - Turnamen otomatis: when the same test still fails twice after edits, the tournament starts by itself.
  - Pemadatan konteks: long sessions are summarised so big projects fit.
  - Mode auto and headless: work without questions, guarded by fixed rules plus an AI judge that fails closed.
  - KALNAVI.md: the project's memory (conventions, commands). It reads it every task and can add to it.
  - Titik pulih: every file is copied before it is changed. /undo puts the last task's files back exactly.
  - Majelis review: /review sends your diff to several different AI models; one merges what they found.
  - Penjaga: commands that delete, format, force-push or touch the system need you to type "ya" in full, even with --yes.
  - Free models with full key rotation behind it; answers in Indonesian or English, following you.

Commands: /undo  /review [fokus]  /diff  /ship [pesan]  /baru  /bantuan  /keluar
Only the Python standard library is needed (3.9+).
"""
from __future__ import annotations

import argparse
import difflib
import fnmatch
import json
import os
import platform
import re
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path

VERSION = "4.0.0"
TEST_CMD = re.compile(r"pytest|unittest|npm\s+(run\s+)?test|yarn\s+test|pnpm\s+test|jest|vitest|mocha|go\s+test|cargo\s+test|mvn\s+test|gradle\w*\s+test|phpunit|rspec", re.I)
COMPACT_AT = 70_000                                   # characters of history before older steps are summarised
CODE_EXT = {".py", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".go", ".java", ".kt", ".rs", ".rb", ".php", ".cs", ".c", ".h", ".cpp",
            ".hpp", ".swift", ".dart", ".vue", ".svelte", ".sql", ".sh", ".ps1"}
SIGNATURE = re.compile(r"^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:def|class|function|func|fn|interface|struct|enum|impl)\s+\w+"
                       r"|^\s*(?:export\s+)?(?:const|let|var)\s+\w+\s*=\s*(?:async\s*)?(?:\(|function)"
                       r"|^\s*(?:public|private|protected)\s+[\w<>\[\], ]+\s+\w+\s*\(")


def repo_map(root: Path, limit: int = 14000) -> str:
    """Every code file with its classes and functions, so the model can go straight to the right place."""
    out, size = [], 0
    for dirpath, dirs, files in os.walk(root):
        dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS and not d.startswith("."))
        for f in sorted(files):
            p = Path(dirpath) / f
            if p.suffix.lower() not in CODE_EXT or p.stat().st_size > 400_000:
                continue
            rel = str(p.relative_to(root)).replace("\\", "/")
            sigs = []
            try:
                for line in p.read_text(encoding="utf-8", errors="ignore").splitlines():
                    if SIGNATURE.match(line) and len(sigs) < 25:
                        sigs.append("  " + line.strip()[:110].rstrip("{:").rstrip())
            except OSError:
                continue
            block = rel + ("\n" + "\n".join(sigs) if sigs else "")
            size += len(block)
            if size > limit:
                out.append("…(peta dipotong; pakai list/search untuk sisanya)")
                return "\n".join(out)
            out.append(block)
    return "\n".join(out)


def loose_find(text: str, old: str) -> tuple[int, int] | None:
    """The one place where `old` appears if spaces, tabs and line breaks may differ. None if absent or ambiguous."""
    chars = "".join(old.split())
    if len(chars) < 8:                                  # too short to place safely without exact spacing
        return None
    hits = list(re.finditer(r"\s*".join(re.escape(ch) for ch in chars), text))
    return (hits[0].start(), hits[0].end()) if len(hits) == 1 else None
SERVER = os.environ.get("KALNAVI_SERVER", "https://www.kalnavi.com").rstrip("/")
MAX_STEPS = 60
SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__", "dist", "build", ".next", ".kalnavi", ".mypy_cache", ".pytest_cache", ".idea"}
SECRET = re.compile(r"(^|[\\/])(\.env(\..*)?|id_rsa.*|.*\.pem|.*\.key|credentials(\.json)?|\.npmrc|\.pypirc|secrets?\.(json|ya?ml|toml))$", re.I)
DANGER = re.compile(r"\brm\s+-[a-z]*r|\brm\s+-[a-z]*f|remove-item\b.*-recurse|\bdel\s+/[sq]|\brd\s+/s|\brmdir\s+/s|format(-volume)?\b|diskpart|"
                    r"git\s+push\s+.*(--force|-f\b)|git\s+reset\s+--hard|git\s+clean\s+-[a-z]*f|shutdown|restart-computer|reg(\.exe)?\s+(delete|add)|"
                    r"set-executionpolicy|mkfs|dd\s+if=|chmod\s+-R\s+777|curl[^|]*\|\s*(sh|bash)|iwr[^|]*\|\s*iex|invoke-expression|\biex\b|"
                    r"drop\s+(table|database)|truncate\s+table|npm\s+publish|twine\s+upload", re.I)
SAFE = re.compile(r"^\s*(git\s+(status|diff|log|show|branch)|ls|dir|pwd|cat|type|python\s+--version|node\s+--version|npm\s+(test|run\s+test)|"
                  r"pytest|python\s+-m\s+(pytest|unittest|py_compile)|py\s+-m\s+pytest|node\s+--check|go\s+test|cargo\s+test|npx\s+tsc\s+--noEmit)\b", re.I)


# ----------------------------------------------------------------------------- look and feel
def _ansi() -> bool:
    if os.name == "nt":
        os.system("")                      # turns on ANSI colours in the Windows console
    return sys.stdout.isatty()


COLOR = _ansi()


def c(text: str, code: str) -> str:
    return f"\033[{code}m{text}\033[0m" if COLOR else text


def violet(t): return c(t, "38;5;141")
def cyan(t): return c(t, "38;5;80")
def dim(t): return c(t, "2")
def green(t): return c(t, "32")
def red(t): return c(t, "31")
def yellow(t): return c(t, "33")
def bold(t): return c(t, "1")


def banner(root: Path) -> None:
    print(violet("  ╭─ ") + bold("Kalnavi Code") + dim(f" {VERSION}") + violet(" ─────────────────────────────"))
    print(violet("  │ ") + dim("proyek: ") + str(root))
    print(violet("  │ ") + dim("/undo  /review  /diff  /baru  /bantuan  /keluar"))
    print(violet("  ╰─────────────────────────────────────────────────"))


# ----------------------------------------------------------------------------- server
def call(path: str, payload: dict, tries: int = 4) -> dict:
    data = json.dumps(payload).encode()
    for k in range(tries):
        try:
            req = urllib.request.Request(SERVER + path, data=data, headers={"Content-Type": "application/json", "User-Agent": f"kalnavi-code/{VERSION}"})
            with urllib.request.urlopen(req, timeout=300) as r:
                out = json.loads(r.read().decode())
            if not out.get("busy"):
                return out
        except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
            if k == tries - 1:
                raise RuntimeError(f"Server Kalnavi tidak terjangkau: {exc}") from exc
        wait = 3 * (k + 1)
        print(dim(f"  … semua model sibuk, mencoba lagi dalam {wait} detik"))
        time.sleep(wait)
    return {"tool": {"name": "ask", "question": "Model sedang sibuk. Coba lagi sebentar lagi?"}}


# ----------------------------------------------------------------------------- the agent's hands
class Session:
    def __init__(self, root: Path, auto: bool, mode: str = ""):
        self.root = root.resolve()
        self.mode = mode or ("yes" if auto else "ask")   # ask · yes · auto · headless
        self.auto = self.mode != "ask"
        self.always_edit = self.auto
        self.plan: list[str] = []
        self.plan_at = 0
        self.task_text = ""
        self.fail_streak: dict[str, int] = {}
        self.edited_since_run = False
        self.tourneys = 0
        self.history: list[dict] = []
        self.task_no = 0
        self.touched: list[tuple[Path, Path | None]] = []        # (file, backup or None if it was new) for this task
        self.unverified = False                                     # edited since the last passing run
        self.reminded = 0
        (self.root / ".kalnavi").mkdir(exist_ok=True)
        gi = self.root / ".kalnavi" / ".gitignore"
        if not gi.exists():
            gi.write_text("*\n", encoding="utf-8")

    # paths ---------------------------------------------------------------
    def inside(self, rel: str) -> Path:
        p = (self.root / (rel or ".")).resolve()
        if p != self.root and self.root not in p.parents:
            raise PermissionError("di luar folder proyek")
        return p

    def rel(self, p: Path) -> str:
        return str(p.relative_to(self.root)).replace("\\", "/") or "."

    # tools ---------------------------------------------------------------
    def t_list(self, a: dict) -> str:
        base, depth, out = self.inside(a.get("path", ".")), int(a.get("depth") or 2), []
        for dirpath, dirs, files in os.walk(base):
            d = Path(dirpath)
            level = len(d.relative_to(base).parts)
            dirs[:] = sorted(x for x in dirs if x not in SKIP_DIRS and not x.startswith(".") or x in (".github",))
            if level >= depth:
                dirs[:] = []
            for f in sorted(files):
                out.append(self.rel(d / f))
                if len(out) >= 400:
                    return "\n".join(out) + "\n…(dipotong di 400 file)"
        return "\n".join(out) or "(kosong)"

    def t_read(self, a: dict) -> str:
        p = self.inside(a["path"])
        if SECRET.search(str(p)):
            return "DITOLAK: file rahasia tidak dibaca."
        lines = p.read_text(encoding="utf-8", errors="replace").splitlines()
        start = max(1, int(a.get("start") or 1))
        end = min(len(lines), int(a.get("end") or start + 399), start + 599)
        body = "\n".join(f"{i:5} | {lines[i - 1]}" for i in range(start, end + 1))
        more = f"\n…(file punya {len(lines)} baris; baca lanjut dengan start={end + 1})" if end < len(lines) else ""
        return (body[:60000] + more) or "(file kosong)"

    def t_search(self, a: dict) -> str:
        rx = re.compile(a.get("pattern") or ".", re.I)
        glob, base, hits = a.get("glob") or "*", self.inside(a.get("path", ".")), []
        for dirpath, dirs, files in os.walk(base):
            dirs[:] = [x for x in dirs if x not in SKIP_DIRS]
            for f in files:
                p = Path(dirpath) / f
                if not fnmatch.fnmatch(f, glob) or SECRET.search(str(p)) or p.stat().st_size > 1_000_000:
                    continue
                try:
                    for n, line in enumerate(p.read_text(encoding="utf-8", errors="ignore").splitlines(), 1):
                        if rx.search(line):
                            hits.append(f"{self.rel(p)}:{n}: {line.strip()[:200]}")
                            if len(hits) >= 200:
                                return "\n".join(hits) + "\n…(dipotong di 200 hasil)"
                except OSError:
                    continue
        return "\n".join(hits) or "(tidak ada yang cocok)"

    def _checkpoint(self, p: Path) -> None:
        if any(f == p for f, _ in self.touched):
            return
        if p.exists():
            bak = self.root / ".kalnavi" / "checkpoints" / str(self.task_no) / self.rel(p)
            bak.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(p, bak)
            self.touched.append((p, bak))
        else:
            self.touched.append((p, None))

    def _approve_change(self, p: Path, old: str, new: str) -> bool:
        diff = list(difflib.unified_diff(old.splitlines(), new.splitlines(), f"a/{self.rel(p)}", f"b/{self.rel(p)}", lineterm="", n=2))
        for line in diff[:120]:
            print("    " + (green(line) if line.startswith("+") and not line.startswith("+++") else red(line) if line.startswith("-") and not line.startswith("---") else dim(line)))
        if len(diff) > 120:
            print(dim(f"    …({len(diff) - 120} baris diff lagi)"))
        if self.always_edit:
            return True
        if self.mode == "headless":
            return True
        ans = input(cyan("  Terapkan perubahan ini? ") + dim("[y]a / [t]idak / [s]elalu: ")).strip().lower()
        if ans.startswith("s"):
            self.always_edit = True
            return True
        return ans.startswith("y")

    def t_write(self, a: dict) -> str:
        p = self.inside(a["path"])
        if SECRET.search(str(p)):
            return "DITOLAK: file rahasia tidak diubah."
        old = p.read_text(encoding="utf-8", errors="replace") if p.exists() else ""
        new = a.get("content", "")
        if not self._approve_change(p, old, new):
            return "Pengguna menolak perubahan ini."
        self._checkpoint(p)
        p.parent.mkdir(parents=True, exist_ok=True)
        p.write_text(new, encoding="utf-8")
        self.unverified = True
        return f"Tersimpan: {self.rel(p)} ({len(new.splitlines())} baris)"

    def t_edit(self, a: dict) -> str:
        p = self.inside(a["path"])
        if SECRET.search(str(p)):
            return "DITOLAK: file rahasia tidak diubah."
        if not p.exists():
            return f"GAGAL: {self.rel(p)} tidak ada. Pakai write untuk file baru."
        text = p.read_text(encoding="utf-8", errors="replace")
        old, new = a.get("old", ""), a.get("new", "")
        count = text.count(old) if old else 0
        if count == 1:
            updated = text.replace(old, new, 1)
        else:
            span = loose_find(text, old) if count == 0 else None       # same code, different spacing: still one clear place
            if not span:
                return f"GAGAL: teks 'old' ditemukan {count} kali (harus tepat 1). Baca ulang file dan salin potongan yang persis serta unik."
            updated = text[:span[0]] + new + text[span[1]:]
        if not self._approve_change(p, text, updated):
            return "Pengguna menolak perubahan ini."
        self._checkpoint(p)
        p.write_text(updated, encoding="utf-8")
        self.unverified = True
        return f"Diedit: {self.rel(p)}"

    def human(self, prompt: str, need: str = "y") -> bool:
        """Ask the person at the keyboard; in headless mode nobody is there, so the answer is no."""
        if self.mode == "headless":
            print(dim("    (headless: butuh manusia, jadi ditolak)"))
            return False
        ans = input(prompt).strip().lower()
        return ans == need if len(need) > 1 else ans.startswith(need)

    def t_run(self, a: dict) -> str:
        cmd = a.get("command", "").strip()
        if not cmd:
            return "GAGAL: perintah kosong."
        print("    " + yellow("$ ") + cmd)
        if DANGER.search(cmd):                          # fixed rule, every mode: a person must type "ya"
            print(red("  ⚠ Perintah ini bisa menghapus atau mengubah sistem."))
            if not self.human(red("  Ketik 'ya' untuk menjalankan: "), "ya"):
                return "Perintah berbahaya ini ditolak. Cari cara yang lebih aman."
        elif SAFE.search(cmd) or self.mode == "yes":
            pass
        elif self.mode in ("auto", "headless"):         # the AI judge decides; unsure or unreachable means ask
            try:
                v = call("/api/kog/dev/judge", {"command": cmd, "task": self.task_text}, tries=2)
            except RuntimeError:
                v = {"verdict": "ask", "reason": "hakim tidak terjangkau"}
            if v.get("verdict") == "allow":
                print(dim("    ✓ hakim: ") + dim(v.get("reason", "")))
            else:
                print(yellow("    ? hakim: ") + v.get("reason", ""))
                if not self.human(cyan("  Jalankan? ") + dim("[y]a / [t]idak: ")):
                    return f"Perintah ditolak ({v.get('reason', '')}). Cari cara lain atau jelaskan kenapa perlu."
        elif not self.human(cyan("  Jalankan? ") + dim("[y]a / [t]idak: ")):
            return "Pengguna menolak perintah ini."
        shell = ["powershell", "-NoProfile", "-Command", cmd] if os.name == "nt" else ["bash", "-lc", cmd]
        try:
            p = subprocess.run(shell, cwd=self.root, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=600)
        except subprocess.TimeoutExpired:
            return "GAGAL: lebih dari 10 menit, dihentikan."
        out = (p.stdout or "") + (("\n[stderr]\n" + p.stderr) if p.stderr.strip() else "")
        if len(out) > 14000:
            out = out[:4000] + "\n…(dipotong)…\n" + out[-9000:]
        shown = out.strip().splitlines()
        for line in shown[-12:]:
            print(dim("    │ ") + line[:160])
        print(dim(f"    └ kode keluar {p.returncode}"))
        if p.returncode == 0:
            self.unverified = False
            self.fail_streak[cmd] = 0
        elif self.edited_since_run:
            self.fail_streak[cmd] = self.fail_streak.get(cmd, 0) + 1
        self.edited_since_run = False
        result = f"exit code {p.returncode}\n{out}"
        first_fail = not self.touched and self.mode in ("auto", "headless") and TEST_CMD.search(cmd)
        if p.returncode != 0 and (first_fail or self.fail_streak.get(cmd, 0) >= 1) and self.tourneys < 2:
            # tests fail from the start (auto/headless) or still fail after an edit: the tournament starts by itself
            files = self.suspects(out)
            if files:
                self.tourneys += 1
                print(violet("  ◆ Tes gagal: turnamen otomatis dimulai (beberapa model, yang lulus tes menang)"))
                result += "\n\nAUTO-TURNAMEN: " + self.t_tournament({"command": cmd, "files": files, "goal": self.task_text})
                self.fail_streak[cmd] = 0
        return result

    def mention(self, text: str) -> str:
        """A file path as it appears in output (absolute, Windows or relative) → the project file it points at, or ""."""
        parts = [x for x in re.split(r"[\\/]+", text) if x and not x.endswith(":")]
        for i in range(len(parts)):
            cand = self.root / "/".join(parts[i:])
            if cand.is_file() and (cand.resolve() == self.root or self.root in cand.resolve().parents):
                return self.rel(cand.resolve())
        return ""

    def imports_of(self, test_rel: str) -> list[str]:
        """The project files a test file imports: tests rarely name the broken file in their output, but they import it."""
        p = self.root / test_rel
        try:
            text = p.read_text(encoding="utf-8", errors="ignore")
        except OSError:
            return []
        out = []
        for m in re.finditer(r"""(?:require\(\s*|from\s+)['"](\.{1,2}/[^'"]+)['"]""", text):            # JS/TS relative imports
            base = (p.parent / m.group(1)).resolve()
            for ext in ("", ".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs", "/index.js", "/index.ts"):
                c = Path(str(base) + ext)
                if c.is_file() and self.root in c.parents:
                    out.append(self.rel(c))
                    break
        for m in re.finditer(r"^\s*(?:from\s+([\w.]+)\s+import|import\s+([\w.]+))", text, re.M):     # Python modules in the project
            mod = (m.group(1) or m.group(2)).lstrip(".").replace(".", "/")
            for c in (self.root / f"{mod}.py", self.root / mod / "__init__.py", p.parent / f"{mod}.py", self.root / "src" / f"{mod}.py"):
                if mod and c.is_file():
                    out.append(self.rel(c.resolve()))
                    break
        return out

    def suspects(self, output: str) -> list[str]:
        """Files for a tournament: those edited in this task, project files named in the failure, and what failing tests import."""
        is_test = lambda r: re.search(r"(^|/)tests?[_/]|(^|/)__tests__/|_test\.|\.test\.|\.spec\.|(^|/)test_[^/]*$", r)  # noqa: E731
        files = [self.rel(f) for f, _ in self.touched if f.exists() and not is_test(self.rel(f))]
        tests = []
        for m in re.finditer(r"([\w.:/\\-]+\.(?:py|js|ts|jsx|tsx|mjs|cjs|go|rb|java))", output):
            r = self.mention(m.group(1))
            if not r or "site-packages" in r or "node_modules" in r:
                continue
            (tests if is_test(r) else files).append(r)
        for t in dict.fromkeys(tests):
            files += [f for f in self.imports_of(t) if not is_test(f)]
        return list(dict.fromkeys(files))[:6]

    def t_ship(self, a: dict) -> str:
        """Commit on a branch, push to GitHub and open a pull request (gh). Pushing needs your yes unless --allow-push."""
        def git(*args):
            p = subprocess.run(["git", *args], cwd=self.root, capture_output=True, text=True, encoding="utf-8", errors="replace")
            return p.returncode, (p.stdout + p.stderr).strip()
        if git("rev-parse", "--is-inside-work-tree")[0]:
            return "GAGAL: folder ini bukan repositori git."
        msg = " ".join(str(a.get("message") or a.get("summary") or self.task_text or "perubahan dari Kalnavi Code").split())[:120]
        _, branch = git("rev-parse", "--abbrev-ref", "HEAD")
        if branch in ("main", "master", "develop", "HEAD"):
            slug = re.sub(r"[^a-z0-9]+", "-", msg.lower()).strip("-")[:40] or "perubahan"
            branch = f"kalnavi/{slug}-{int(time.time()) % 100000}"
            rc, out = git("checkout", "-b", branch)
            if rc:
                return f"GAGAL membuat branch: {out[-500:]}"
        git("add", "-A")
        rc, out = git("commit", "-m", msg)
        if rc and "nothing to commit" not in out:
            return f"GAGAL commit: {out[-800:]}"
        print(cyan(f"  Branch {branch} siap: ") + msg)
        if not getattr(self, "allow_push", False) and not self.human(cyan("  Kirim ke GitHub (git push) dan buka Pull Request? ") + dim("[y]a / [t]idak: ")):
            return f"Commit dibuat di branch {branch}, tapi tidak dikirim ke GitHub (pengguna belum mengizinkan push)."
        rc, out = git("push", "-u", "origin", branch)
        if rc:
            return f"GAGAL push: {out[-800:]}"
        pr = ""
        if shutil.which("gh"):
            body = self.root / ".kalnavi" / "laporan.md"
            args = ["gh", "pr", "create", "--title", msg, "--head", branch] + (["--body-file", str(body)] if body.exists() else ["--body", "Dibuat oleh Kalnavi Code."])
            p = subprocess.run(args, cwd=self.root, capture_output=True, text=True, encoding="utf-8", errors="replace")
            pr = (p.stdout or p.stderr).strip().splitlines()[-1:] and (p.stdout or p.stderr).strip().splitlines()[-1]
        print(green("  ✓ Terkirim ke GitHub ") + (cyan(pr) if pr else ""))
        return f"Terkirim ke GitHub di branch {branch}." + (f" Pull request: {pr}" if pr else " (gh tidak ada: buka pull request dari GitHub.)")

    def t_plan(self, a: dict) -> str:
        steps = [str(x)[:140] for x in (a.get("steps") or self.plan) if str(x).strip()][:15]
        self.plan, self.plan_at = steps, int(a.get("current") or 0)
        for i, st in enumerate(steps):
            mark = green("✓") if i < self.plan_at else violet("▸") if i == self.plan_at else dim("○")
            print(f"    {mark} " + (st if i >= self.plan_at else dim(st)))
        return f"Rencana dicatat ({len(steps)} langkah, sekarang langkah {self.plan_at + 1})."

    def _shell(self, cmd: str, timeout: int = 600) -> tuple[int, str]:
        shell = ["powershell", "-NoProfile", "-Command", cmd] if os.name == "nt" else ["bash", "-lc", cmd]
        try:
            p = subprocess.run(shell, cwd=self.root, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout)
        except subprocess.TimeoutExpired:
            return 124, "timeout"
        return p.returncode, (p.stdout or "") + (p.stderr or "")

    def t_remember(self, a: dict) -> str:
        note = " ".join(str(a.get("note", "")).split())[:400]
        if not note:
            return "GAGAL: catatan kosong."
        mem = self.root / "KALNAVI.md"
        if not mem.exists():
            mem.write_text("# KALNAVI.md\n\nMemori proyek untuk Kalnavi Code: konvensi, perintah, dan hal penting.\n\n", encoding="utf-8")
        with mem.open("a", encoding="utf-8") as f:
            f.write(f"- {note}\n")
        print(green("    ✎ diingat di KALNAVI.md: ") + note[:100])
        return "Tersimpan di KALNAVI.md."

    def t_tournament(self, a: dict) -> str:
        """Several models each write a patch; each is applied, tested with the command, then the files are restored. Best one wins."""
        cmd, files = a.get("command", "").strip(), [str(f) for f in (a.get("files") or [])][:8]
        if not cmd or DANGER.search(cmd):
            return "GAGAL: turnamen butuh perintah tes yang aman."
        paths = {f: self.inside(f) for f in files}
        missing = [f for f, p in paths.items() if not p.exists() or SECRET.search(str(p))]
        if missing or not paths:
            return f"GAGAL: file tidak ada atau tidak boleh dibaca: {missing or 'kosong'}"
        if not self.auto and not self.human(cyan(f"  Turnamen: uji beberapa patch dengan '{cmd}' (file selalu dipulihkan)? ") + dim("[y]a / [t]idak: ")):
            return "Pengguna menolak turnamen."
        original = {f: p.read_text(encoding="utf-8", errors="replace") for f, p in paths.items()}
        code, output = self._shell(cmd)
        if code == 0:
            return "Perintah tes sudah lulus tanpa perubahan; tidak perlu turnamen."
        print(violet("  ◆ Turnamen: beberapa model menulis patch…"))
        cands = call("/api/kog/dev/candidates", {"goal": a.get("goal", "") or "make the command pass", "output": output[-12000:], "files": original}).get("candidates") or []
        if not cands:
            return "Turnamen: tidak ada patch yang bisa dibuat saat ini."
        results = []
        for i, cand in enumerate(cands, 1):
            texts, ok = dict(original), True
            for e in cand["edits"]:
                t = texts.get(e["path"], "")
                if t.count(e["old"]) == 1:
                    texts[e["path"]] = t.replace(e["old"], e.get("new", ""), 1)
                else:
                    span = loose_find(t, e["old"])
                    if not span:
                        ok = False
                        break
                    texts[e["path"]] = t[:span[0]] + e.get("new", "") + t[span[1]:]
            if not ok:
                results.append((cand, None, "patch tidak cocok"))
                print(dim(f"    {i}. {cand['model']}: patch tidak cocok dengan file"))
                continue
            try:
                for f, p in paths.items():
                    p.write_text(texts[f], encoding="utf-8")
                rc, out = self._shell(cmd, timeout=300)
            finally:
                for f, p in paths.items():
                    p.write_text(original[f], encoding="utf-8")      # always back to the original between rounds
            fails = len(re.findall(r"\b(FAILED|failed|Error|error)\b", out))
            results.append((cand, rc, out))
            print((green(f"    {i}. {cand['model']}: LULUS") if rc == 0 else yellow(f"    {i}. {cand['model']}: gagal (kode {rc})")) + dim(f" · {cand['why'][:80]}"))
            if rc == 0:
                break
        winner = next((r for r in results if r[1] == 0), None)
        try:                                            # the scoreboard: winners are tried first next time
            call("/api/kog/dev/learn", {"results": [{"model": c["model"], "passed": rc == 0} for c, rc, _ in results if rc is not None]}, tries=1)
        except Exception:  # noqa: BLE001
            pass
        if not winner:
            tried = "\n".join(f"- {c['model']}: {c['why']} → {('kode ' + str(rc)) if rc is not None else out}" for c, rc, out in results)
            return f"Turnamen selesai tanpa pemenang. Yang dicoba:\n{tried}\nKeluaran terakhir:\n{results[-1][2][-3000:] if results and results[-1][1] is not None else ''}"
        cand = winner[0]
        for e in cand["edits"]:
            r = self.t_edit({"path": e["path"], "old": e["old"], "new": e.get("new", "")})
            if not r.startswith("Diedit"):
                return f"Pemenang ({cand['model']}) lulus di uji, tapi gagal diterapkan: {r}"
        rc, out = self._shell(cmd, timeout=300)
        if rc == 0:
            self.unverified = False
        return f"Turnamen dimenangkan {cand['model']} ({cand['why']}). Patch diterapkan dan '{cmd}' {'LULUS' if rc == 0 else 'gagal: ' + out[-1500:]}."

    # undo / diff / review ------------------------------------------------
    def undo(self) -> None:
        if not self.touched:
            print(dim("  Tidak ada perubahan untuk dibatalkan."))
            return
        for f, bak in reversed(self.touched):
            if bak is None:
                f.unlink(missing_ok=True)
                print(green("  ↺ dihapus (file baru): ") + self.rel(f))
            else:
                shutil.copy2(bak, f)
                print(green("  ↺ dipulihkan: ") + self.rel(f))
        self.touched = []

    def diff_text(self) -> str:
        try:
            out = subprocess.run(["git", "diff", "HEAD"], cwd=self.root, capture_output=True, text=True, encoding="utf-8", errors="replace").stdout
            if out.strip():
                return out
        except OSError:
            pass
        parts = []
        for f, bak in self.touched:
            old = bak.read_text(encoding="utf-8", errors="replace") if bak else ""
            new = f.read_text(encoding="utf-8", errors="replace") if f.exists() else ""
            parts += difflib.unified_diff(old.splitlines(), new.splitlines(), f"a/{self.rel(f)}", f"b/{self.rel(f)}", lineterm="")
        return "\n".join(parts)

    def review(self, focus: str) -> None:
        diff = self.diff_text()
        if not diff.strip():
            print(dim("  Belum ada perubahan untuk direview."))
            return
        print(violet("  ◆ Majelis Kalnavi sedang mereview diff…"))
        out = call("/api/kog/dev/review", {"diff": diff[:180000], "focus": focus})
        models = ", ".join(out.get("models") or [])
        print(dim(f"  (pendapat dari: {models or 'satu model'})\n"))
        print(out.get("review", ""))

    # the loop ------------------------------------------------------------
    def compact(self) -> None:
        """Auto-compact: when the conversation gets long, older steps become one summary from the server."""
        if sum(len(m["content"]) for m in self.history) < COMPACT_AT or len(self.history) < 12:
            return
        head, middle, tail = self.history[:1], self.history[1:-6], self.history[-6:]
        try:
            summary = call("/api/kog/dev/compact", {"history": middle}, tries=2).get("summary", "")
        except RuntimeError:
            return
        if summary:
            self.history = head + [{"role": "user", "content": "SUMMARY OF EARLIER WORK:\n" + summary}] + tail
            print(dim(f"    ↻ konteks dipadatkan ({len(middle)} langkah diringkas)"))

    def report(self, status: str, summary: str, seconds: float) -> None:
        lines = [f"# Laporan Kalnavi Code", "", f"- Tugas: {self.task_text}", f"- Status: {status}", f"- Waktu: {round(seconds)} detik",
                 f"- Mode: {self.mode}", "", "## Ringkasan", summary or "-", "", "## File yang diubah"]
        lines += [f"- {self.rel(f)}{' (baru)' if b is None else ''}" for f, b in self.touched] or ["- tidak ada"]
        if self.plan:
            lines += ["", "## Rencana"] + [f"- [{'x' if i < self.plan_at or status == 'selesai' else ' '}] {s}" for i, s in enumerate(self.plan)]
        (self.root / ".kalnavi" / "laporan.md").write_text("\n".join(lines) + "\n", encoding="utf-8")

    def task(self, text: str) -> str:
        self.task_no += 1
        self.touched = []
        self.task_text, self.plan, self.plan_at, self.fail_streak, self.tourneys = text, [], 0, {}, 0
        self.unverified, self.reminded = False, 0
        intro = f"TASK: {text}\nOS: {platform.system()} | project root: {self.root.name}"
        if not self.history:                                   # a fresh conversation gets the project's memory and map
            mem = self.root / "KALNAVI.md"
            if mem.exists():
                intro += "\n\nKALNAVI.md (project memory):\n" + mem.read_text(encoding="utf-8", errors="replace")[:4000]
            intro += "\n\nREPO MAP:\n" + repo_map(self.root)
        self.history = (self.history[-20:] if self.history else []) + [{"role": "user", "content": intro}]
        t0 = time.time()
        for _ in range(MAX_STEPS):
            self.compact()
            out = call("/api/kog/dev/step", {"history": self.history, "os": platform.system()})
            tool = out.get("tool") or {}
            name = tool.get("name")
            if out.get("say"):
                print(violet("  ◆ ") + out["say"])
            self.history.append({"role": "assistant", "content": json.dumps({"say": out.get("say", ""), "tool": tool}, ensure_ascii=False)})
            if name == "done" and self.unverified and self.reminded < 2:
                self.reminded += 1                              # the verification gate: no "done" without a passing run
                print(yellow("    ↻ belum diverifikasi, Kalnavi diminta menguji dulu"))
                self.history.append({"role": "user", "content": "TOOL RESULT (done): Ditolak gerbang verifikasi. Kamu mengubah file tapi belum ada "
                                     "run yang lulus setelah edit terakhir. Jalankan tes atau programnya dulu; kalau memang tidak bisa diuji, "
                                     "jelaskan kenapa di summary dan panggil done lagi."})
                continue
            if name == "done":
                verified = not self.unverified
                print(green("\n  ✓ Selesai") + dim(f" · {round(time.time() - t0)} dtk · {len(self.touched)} file diubah · /undo untuk membatalkan"))
                print("  " + tool.get("summary", "").replace("\n", "\n  "))
                self.report("selesai" if verified else "selesai tanpa verifikasi", tool.get("summary", ""), time.time() - t0)
                return "done" if verified else "unverified"
            if name == "ask":
                print(cyan("\n  ? ") + tool.get("question", ""))
                self.report("butuh keputusan manusia", tool.get("question", ""), time.time() - t0)
                return "ask"
            if name == "plan":
                self.history.append({"role": "user", "content": f"TOOL RESULT (plan):\n{self.t_plan(tool)}"})
                continue
            label = {"list": "melihat", "read": "membaca", "search": "mencari", "write": "menulis", "edit": "mengedit", "run": "menjalankan",
                     "tournament": "turnamen untuk", "remember": "mengingat"}.get(name, name)
            if not hasattr(self, f"t_{name}"):
                self.history.append({"role": "user", "content": f"TOOL RESULT ({name}): alat tidak dikenal."})
                continue
            target = tool.get("path") or tool.get("pattern") or ", ".join(tool.get("files") or []) or ""
            if name in ("write", "edit"):
                self.edited_since_run = True
            if name != "run":
                print(dim(f"    {label} ") + cyan(str(target)))
            try:
                result = getattr(self, f"t_{name}")(tool)
            except KeyboardInterrupt:
                print(yellow("\n  Dihentikan."))
                self.report("dihentikan", "", time.time() - t0)
                return "stopped"
            except Exception as exc:  # noqa: BLE001  the error goes back to the model so it can adjust
                result = f"GAGAL: {type(exc).__name__}: {exc}"
            if result.startswith(("GAGAL", "DITOLAK")):
                print(red("    " + result.splitlines()[0][:160]))
            self.history.append({"role": "user", "content": f"TOOL RESULT ({name} {target}):\n{result}"})
        print(yellow(f"  Berhenti setelah {MAX_STEPS} langkah. Ketik 'lanjut' untuk meneruskan."))
        self.report("berhenti di batas langkah", "", time.time() - t0)
        return "limit"


# ----------------------------------------------------------------------------- bridge: Kalnavi Code on the website, your computer's hands
class Bridge:
    """`kalnavi.py --serve`: lets Kalnavi Code on www.kalnavi.com run commands and ship to GitHub in THIS folder.

    Listens on 127.0.0.1 only (never the network), answers only the Kalnavi website's origin, and every request must carry
    the 6-digit pairing code printed in this terminal. Commands still go through the same rules: dangerous ones need "ya"
    typed here, the AI judge in auto mode, and nothing at all is allowed once you stop this program (Ctrl+C)."""

    ORIGINS = ("https://www.kalnavi.com",)

    def __init__(self, session: "Session", port: int = 7878, origins: tuple = ()):
        import secrets
        self.s, self.port = session, port
        self.code = f"{secrets.randbelow(10 ** 6):06d}"
        self.origins = tuple(origins) or self.ORIGINS

    def handler(self):
        import hmac
        from http.server import BaseHTTPRequestHandler
        bridge = self

        class H(BaseHTTPRequestHandler):
            def log_message(self, *a):
                pass

            def _cors(self) -> bool:
                origin = self.headers.get("Origin", "")
                if origin not in bridge.origins:
                    return False
                self.send_header("Access-Control-Allow-Origin", origin)
                self.send_header("Access-Control-Allow-Headers", "Content-Type, X-Kalnavi-Pair")
                self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
                self.send_header("Access-Control-Allow-Private-Network", "true")
                self.send_header("Vary", "Origin")
                return True

            def do_OPTIONS(self):
                self.send_response(204 if self.headers.get("Origin", "") in bridge.origins else 403)
                self._cors()
                self.end_headers()

            def do_POST(self):
                ok_origin = self.headers.get("Origin", "") in bridge.origins
                ok_code = hmac.compare_digest(self.headers.get("X-Kalnavi-Pair", ""), bridge.code)
                try:
                    body = json.loads(self.rfile.read(int(self.headers.get("Content-Length") or 0) or 0) or b"{}")
                except json.JSONDecodeError:
                    body = {}
                if not (ok_origin and ok_code):
                    out, status = {"error": "kode pasangan atau asal tidak cocok"}, 403
                elif self.path == "/ping":
                    out, status = {"ok": True, "project": bridge.s.root.name, "version": VERSION, "mode": bridge.s.mode}, 200
                elif self.path == "/run":
                    print(cyan("\n  ⇄ dari situs Kalnavi:"))
                    out, status = {"result": bridge.s.t_run({"command": str(body.get("command", ""))})}, 200
                elif self.path == "/fs":
                    try:
                        out, status = bridge.fs(body), 200
                    except OSError as exc:                  # e.g. a file that does not exist: a clear answer, not a dropped request
                        out, status = {"error": f"{type(exc).__name__}: {exc}"[:300]}, 404
                elif self.path == "/ship":
                    print(cyan("\n  ⇄ dari situs Kalnavi:"))
                    out, status = {"result": bridge.s.t_ship(body)}, 200
                else:
                    out, status = {"error": "tidak dikenal"}, 404
                data = json.dumps(out, ensure_ascii=False).encode()
                self.send_response(status)
                self._cors()
                self.send_header("Content-Type", "application/json")
                self.send_header("Content-Length", str(len(data)))
                self.end_headers()
                self.wfile.write(data)
        return H

    def fs(self, body: dict) -> dict:
        """Raw file access for the website (edits are approved there; every write is checkpointed for /undo)."""
        op = body.get("op")
        if op == "list":
            files = []
            for dirpath, dirs, names in os.walk(self.s.root):
                dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")]
                for n in names:
                    p = Path(dirpath) / n
                    if not SECRET.search(str(p)):
                        files.append({"path": self.s.rel(p), "size": p.stat().st_size})
                if len(files) > 3000:
                    break
            return {"files": files}
        try:
            p = self.s.inside(str(body.get("path", "")))
        except PermissionError as exc:
            return {"error": str(exc)}
        if SECRET.search(str(p)):
            return {"error": "file rahasia"}
        if op == "read":
            return {"text": p.read_text(encoding="utf-8", errors="replace")}
        if op == "write":
            self.s._checkpoint(p)
            p.parent.mkdir(parents=True, exist_ok=True)
            p.write_text(str(body.get("text", "")), encoding="utf-8")
            print(dim("  ✎ ditulis dari situs: ") + self.s.rel(p))
            return {"ok": True}
        if op == "delete":
            p.unlink(missing_ok=True)
            return {"ok": True}
        return {"error": "op tidak dikenal"}

    def serve(self) -> None:
        from http.server import ThreadingHTTPServer
        srv = ThreadingHTTPServer(("127.0.0.1", self.port), self.handler())
        print(violet("  ╭─ ") + bold("Jembatan Kalnavi Code") + violet(" ──────────────────────────"))
        print(violet("  │ ") + "folder: " + str(self.s.root))
        print(violet("  │ ") + "buka: " + cyan("https://www.kalnavi.com/#/chat/kcode") + " → Di browser → Sambungkan komputer")
        print(violet("  │ ") + "kode pasangan: " + bold(self.code) + dim(f"   (port {self.port}, hanya komputer ini)"))
        print(violet("  ╰─ ") + dim("Ctrl+C untuk memutus. Perintah dari situs tampil di sini dan tetap mengikuti aturan keamanan."))
        try:
            srv.serve_forever()
        except KeyboardInterrupt:
            print(dim("\n  Jembatan diputus."))
        finally:
            srv.server_close()


def main() -> None:
    try:
        sys.stdout.reconfigure(encoding="utf-8")
    except Exception:  # noqa: BLE001
        pass
    ap = argparse.ArgumentParser(description="Kalnavi Code: agen coding di terminal")
    ap.add_argument("task", nargs="*", help="tugas (kosongkan untuk sesi interaktif)")
    ap.add_argument("--yes", action="store_true", help="setujui edit dan perintah aman otomatis (perintah berbahaya tetap ditanya)")
    ap.add_argument("--auto", action="store_true", help="mode auto: tanpa pertanyaan; hakim AI menilai tiap perintah, yang berbahaya tetap ditanya")
    ap.add_argument("--headless", action="store_true", help="tanpa manusia (CI): yang butuh manusia ditolak, laporan di .kalnavi/laporan.md")
    ap.add_argument("--serve", action="store_true", help="jembatan: Kalnavi Code di situs boleh menjalankan perintah di folder ini")
    ap.add_argument("--allow-push", action="store_true", help="izinkan git push dan pull request tanpa bertanya")
    ap.add_argument("--port", type=int, default=7878, help="port jembatan (hanya 127.0.0.1)")
    ap.add_argument("--ship", metavar="PESAN", help="commit, push ke GitHub, dan buka pull request, lalu selesai")
    ap.add_argument("--dir", default=".", help="folder proyek")
    args = ap.parse_args()
    mode = "headless" if args.headless else "auto" if args.auto else "yes" if args.yes else "ask"
    s = Session(Path(args.dir), mode != "ask", mode)
    s.allow_push = args.allow_push
    if args.serve:
        Bridge(s, args.port).serve()
        return
    if args.ship is not None:
        print(s.t_ship({"message": args.ship}))
        return
    if args.task:
        try:
            status = s.task(" ".join(args.task))
        except RuntimeError as exc:
            print(red("  " + str(exc)))
            status = "error"
        if mode == "headless":
            sys.exit(0 if status == "done" else 1)
        return
    if mode == "headless":
        sys.exit("--headless butuh tugas di baris perintah.")
    banner(s.root)
    while True:
        try:
            line = input(violet("\n  › ")).strip()
        except (EOFError, KeyboardInterrupt):
            print()
            return
        if not line:
            continue
        cmd, _, rest = line.partition(" ")
        if cmd in ("/keluar", "/exit", "/quit"):
            return
        if cmd == "/undo":
            s.undo()
        elif cmd == "/review":
            s.review(rest)
        elif cmd == "/diff":
            print(s.diff_text() or dim("  (tidak ada perubahan)"))
        elif cmd == "/ship":
            print(s.t_ship({"message": rest}))
        elif cmd == "/baru":
            s.history, s.touched = [], []
            print(dim("  Percakapan baru."))
        elif cmd in ("/bantuan", "/help"):
            print(__doc__)
        else:
            try:
                s.task(line)
            except KeyboardInterrupt:
                print(yellow("\n  Dihentikan."))
            except RuntimeError as exc:
                print(red("  " + str(exc)))


if __name__ == "__main__":
    main()
