"""
Auftragsbuch — die Vorschlags-Inbox der Box (das Mensch-Gate als Klick statt Git-Handarbeit).

Quellen der Karten:
  • Vorschlags-Branches auf Gitea (wartung/*, orchestrator/*, doku/*) — die Werkstatt und der
    Orchestrator arbeiten propose-only und lassen ihre Ergebnisse dort liegen.
    Seit S3 (lucy-pipeline) aus ZWEI Repos: mission-control-v2 (Box-Stack) UND lucy (Desktop-App).
  • Skill-Kandidaten aus dem Wissens-Vault (~/wissens-vault/skill-kandidaten/) — Vorschläge des
    nächtlichen Traum-Crons, Gate war bisher „Commander sagt mach".

Annehmen (Branch) startet den Repo-eigenen Runner als EIGENE systemd-Unit (detached):
  • mc2:  deploy/auftrag-annehmen.sh — Merge im Worktree → Push main → Deploy → Health → Revert bei Rot.
  • lucy: deploy/lucy-annahme.sh — Merge im Worktree → Push main → PC baut dist (via PC-Executor)
    und startet Lucy neu, wenn sie lief → bei Rot Revert auf main (die laufende Lucy bleibt die alte).
Detached, weil deploy.sh mission-control-2 neu startet — ein Kind des Backends stürbe mittendrin.
Der Fortschritt landet in STATUS_PATH (JSON), das Skript schreibt, die API liest nur.
Status-Schlüssel: mc2 = Branch-Name pur (Bestand), lucy = "lucy:<branch>" (kollisionsfrei).

Lokal (Windows-Dev) ist alles harmlos: available=False, Aktionen geben Fehler statt zu crashen.
"""

import json
import logging
import os
import re
import shutil
import subprocess
import time
from pathlib import Path

from config import MODELS_DIR

log = logging.getLogger(__name__)

REPO = Path(os.environ.get("MC2_SOURCE_DIR", "~/mission-control-v2")).expanduser()
LUCY = Path(os.environ.get("MC2_LUCY_DIR", "~/lucy")).expanduser()
VAULT = Path(os.environ.get("MC_VAULT_DIR", "~/wissens-vault")).expanduser()
STATUS_PATH = Path(os.environ.get("MC2_AUFTRAG_STATUS", str(MODELS_DIR / "mc2-auftragsbuch.json")))
# Empfehlungs-Stempel des nächtlichen Karten-Gutachters (deploy/karten-gutachter.sh schreibt,
# die API liest nur) und das Lern-Gedächtnis der Ablehnungen (Radar/Specifier lesen es).
GUTACHTEN_PATH = Path(os.environ.get("MC2_KARTEN_GUTACHTEN", str(MODELS_DIR / "mc2-karten-gutachten.json")))
ABLEHNUNGEN_PATH = Path(os.environ.get("MC2_ABLEHNUNGEN", str(MODELS_DIR / "mc2-ablehnungen.jsonl")))

# Karten-Quellen. Die Runner-Skripte liegen IMMER im MC2-Checkout (deploy/) — auch der
# Lucy-Runner, denn er läuft auf der Box; nur der Build passiert am PC.
REPOS: dict[str, dict] = {
    "mc2": {"path": REPO, "runner": "auftrag-annehmen.sh", "key": ""},
    "lucy": {"path": LUCY, "runner": "lucy-annahme.sh", "key": "lucy:"},
}

# Persistenz für gemeldete Branches (Idempotenz: nur EINMAL pro Branch pingen).
# Muster wie mc2-announce.json unter MODELS_DIR.
ANNOUNCE_BRANCHES_PATH = Path(os.environ.get("MC2_ANNOUNCE_BRANCHES", str(MODELS_DIR / "mc2-announce-branches.json")))

# Nur diese Branch-Familien sind Vorschläge (main/HEAD & Fremdes bleiben draußen).
PREFIXES = ("wartung/", "orchestrator/", "doku/", "feature/")
# Branch-Namen kommen vom Client zurück → hart validieren (keine Shell-/Git-Injektion).
_BRANCH_RX = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,120}$")
_KANDIDAT_RX = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._ -]{0,120}\.md$")

_fetch_cache: dict = {}
_FETCH_EVERY = 30.0  # s — Gitea nicht bei jedem UI-Poll anfragen


def _available() -> bool:
    return os.name == "posix" and (REPO / ".git").exists()


def _repo_ok(repo: str) -> bool:
    r = REPOS.get(repo)
    return bool(r) and os.name == "posix" and (r["path"] / ".git").exists()


def _git(repo: str, args: list[str], timeout: int = 20) -> subprocess.CompletedProcess:
    return subprocess.run(["git", "-C", str(REPOS[repo]["path"]), *args],
                          capture_output=True, text=True, timeout=timeout)


def _status_key(repo: str, branch: str) -> str:
    return f"{REPOS[repo]['key']}{branch}"


def _fetch_throttled(repo: str) -> None:
    now = time.time()
    if now - _fetch_cache.get(repo, 0.0) < _FETCH_EVERY:
        return
    _fetch_cache[repo] = now
    try:
        _git(repo, ["fetch", "-q", "--prune", "origin"], timeout=30)
    except Exception:
        log.warning("auftragsbuch: git fetch (%s) fehlgeschlagen", repo, exc_info=True)


def _load_announce_branches() -> set:
    """Liefert die Menge der bereits gemeldeten Branch-Namen (Idempotenz)."""
    try:
        data = json.loads(ANNOUNCE_BRANCHES_PATH.read_text(encoding="utf-8"))
        return set(data) if isinstance(data, list) else set()
    except Exception:
        return set()


def _save_announce_branches(branches: set) -> None:
    """Speichert die Menge gemelder Branch-Namen."""
    try:
        tmp = ANNOUNCE_BRANCHES_PATH.with_suffix(".tmp")
        tmp.write_text(json.dumps(sorted(branches), ensure_ascii=False), encoding="utf-8")
        tmp.replace(ANNOUNCE_BRANCHES_PATH)
    except OSError:
        log.warning("auftragsbuch: Announce-Branches %s nicht schreibbar", ANNOUNCE_BRANCHES_PATH, exc_info=True)


def _statuses() -> dict:
    try:
        return (json.loads(STATUS_PATH.read_text(encoding="utf-8")) or {}).get("branches", {})
    except Exception:
        return {}


def _set_status(key: str, state: str, detail: str) -> None:
    try:
        try:
            data = json.loads(STATUS_PATH.read_text(encoding="utf-8"))
        except Exception:
            data = {}
        data.setdefault("branches", {})[key] = {"state": state, "detail": detail, "ts": time.time()}
        tmp = STATUS_PATH.with_suffix(".tmp")
        tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
        tmp.replace(STATUS_PATH)
    except OSError:
        log.warning("auftragsbuch: Status %s nicht schreibbar", STATUS_PATH, exc_info=True)


def _gutachten() -> dict:
    """Alle Karten-Stempel ("<repo>:<branch>" → {empfehlung, satz, richter, commit_ts, ts})."""
    try:
        data = json.loads(GUTACHTEN_PATH.read_text(encoding="utf-8"))
        return data if isinstance(data, dict) else {}
    except Exception:
        return {}


def _remote_branches(repo: str) -> list[str]:
    r = _git(repo, ["for-each-ref", "--format=%(refname:short)", "refs/remotes/origin"])
    out = []
    for line in (r.stdout or "").splitlines():
        name = line.strip().removeprefix("origin/")
        if name and name != "HEAD" and name.startswith(PREFIXES):
            out.append(name)
    return out


def _valid_branch(branch: str) -> bool:
    return bool(_BRANCH_RX.match(branch)) and ".." not in branch and branch.startswith(PREFIXES)


def _repo_items(repo: str, statuses: dict, gutachten: dict) -> list[dict]:
    items = []
    for branch in _remote_branches(repo):
        ref = f"origin/{branch}"
        status = statuses.get(_status_key(repo, branch))
        try:
            ahead = int((_git(repo, ["rev-list", "--count", f"origin/main..{ref}"]).stdout or "0").strip() or 0)
            if ahead == 0 and ((status or {}).get("state") not in ("laeuft", "rollback")):
                continue  # bereits in main enthalten → keine offene Entscheidung mehr
            behind = int((_git(repo, ["rev-list", "--count", f"{ref}..origin/main"]).stdout or "0").strip() or 0)
            # Kaputt aufgesetzte Branches entlarven (Worker machte git init/Shallow statt zu
            # klonen): ohne gemeinsamen Vorfahren kann git NIE mergen — Annehmen wäre ein
            # garantierter Fehllauf („refusing to merge unrelated histories").
            verwaist = _git(repo, ["merge-base", "origin/main", ref]).returncode != 0
            show = _git(repo, ["show", "-s", "--format=%s%x1f%b%x1f%ct%x1f%an", ref])
            subject, body, cts, author = ((show.stdout or "").split("\x1f") + ["", "", "", ""])[:4]
            ts_val = int(cts) if cts.strip().isdigit() else None
            # Stempel nur zeigen, wenn er zum AKTUELLEN Commit gehört (nachgeschobener
            # Commit macht das alte Gutachten ungültig — der Nacht-Lauf stempelt neu).
            stempel = gutachten.get(f"{repo}:{branch}")
            if not (isinstance(stempel, dict) and stempel.get("commit_ts") == ts_val):
                stempel = None
            stat = (_git(repo, ["diff", "--shortstat", f"origin/main...{ref}"]).stdout or "").strip()
            files_raw = (_git(repo, ["diff", "--name-status", f"origin/main...{ref}"]).stdout or "").splitlines()
            files = []
            for line in files_raw[:60]:
                parts = line.split("\t")
                if len(parts) >= 2:
                    files.append({"status": parts[0][:2], "path": parts[-1]})
            paths = [f["path"] for f in files]
            frontend_src = any(p.startswith("frontend/src") for p in paths)
            frontend_dist = any(p.startswith("frontend/dist") for p in paths)
            items.append({
                "repo": repo,
                "branch": branch,
                "kind": branch.split("/", 1)[0],
                "subject": subject.strip(),
                "body": body.strip()[:2000],
                "author": author.strip(),
                "ts": ts_val,
                "empfehlung": stempel,
                "ahead": ahead,
                "behind": behind,
                "verwaist": verwaist,
                # Diff gegen main ist leer → der Branch brächte nichts (Inhalt schon in main
                # oder Worker hat am Repo vorbei gearbeitet). Bei verwaist ist der Diff
                # technisch leer (kein merge-base) — dann zählt nur das verwaist-Flag.
                "leer": not verwaist and ahead > 0 and not files_raw,
                "shortstat": stat,
                "files": files,
                "files_truncated": len(files_raw) > 60,
                # Nur mc2: Frontend-Quelltext ohne gebautes Bundle — die Box deployt dist so, wie
                # es im Repo liegt. Lucy wird dagegen IMMER am PC gebaut (kein dist im Repo).
                "frontend_ohne_build": repo == "mc2" and frontend_src and not frontend_dist,
                "status": None if (status or {}).get("state") == "eingespielt" and ahead > 0 else status,
            })
        except Exception:
            log.warning("auftragsbuch: Branch %s (%s) nicht lesbar", branch, repo, exc_info=True)
    return items


def list_proposals() -> dict:
    """Alle offenen Vorschläge: Branches aus beiden Repos (mit Commit-/Diff-Zusammenfassung +
    Status) und Skill-Kandidaten aus dem Vault. Eine Antwort für die ganze Auftragsbuch-Seite.
    
    Nebenbei: Pinge neu auftauchende Vorschlags-Branches per Telegram und füge sie dem
    Briefkasten hinzu (Idempotenz: nur EINMAL pro Branch)."""
    if not _available():
        return {"available": False, "items": [], "skill_kandidaten": [], "open_count": 0}

    statuses = _statuses()
    gutachten = _gutachten()
    items = []
    for repo in REPOS:
        if not _repo_ok(repo):
            continue  # z. B. ~/lucy (noch) nicht geklont → Karten dieses Repos einfach weglassen
        _fetch_throttled(repo)
        items.extend(_repo_items(repo, statuses, gutachten))
    items.sort(key=lambda i: i.get("ts") or 0, reverse=True)

    # --- Telegram-Ping für NEUE Vorschlags-Branches (Idempotenz) ---
    # Alle aktuellen Branch-Namen aus beiden Repos sammeln
    aktuelle_branches = set()
    for repo in REPOS:
        if _repo_ok(repo):
            aktuelle_branches.update(_remote_branches(repo))
    
    # Bereits gemeldete Branches laden
    gemeldet = _load_announce_branches()
    
    # Nur wirklich NEUE Branches pingen
    neue = aktuelle_branches - gemeldet
    for branch in neue:
        # NotifyTelegram best-effort, niemals crashen
        try:
            from services import announce
            subject = "[Auftragsbuch]"
            text = f"Neue Karte wartet auf deinen Klick: {branch}"
            announce.notify_telegram(subject, text)
            # Und ein Eintrag für den Briefkasten (damit Lucy es spricht)
            announce.add(text, subject, "auftragsbuch", "normal")
        except Exception:
            log.warning("auftragsbuch: Telegram-Ping für %s fehlgeschlagen", branch, exc_info=True)
    
    # Alle aktuellen Branches als gemeldet speichern
    _save_announce_branches(aktuelle_branches)

    kandidaten = list_skill_kandidaten()
    open_count = sum(1 for i in items
                     if (i.get("status") or {}).get("state") not in ("eingespielt",)) + len(kandidaten)
    return {"available": True, "items": items, "skill_kandidaten": kandidaten, "open_count": open_count}


def diff_of(branch: str, repo: str = "mc2") -> dict:
    if not _repo_ok(repo):
        return {"ok": False, "error": "Nur auf der Box verfügbar."}
    if not _valid_branch(branch) or branch not in _remote_branches(repo):
        return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
    r = _git(repo, ["diff", f"origin/main...origin/{branch}"], timeout=30)
    text = r.stdout or ""
    truncated = len(text) > 200_000
    return {"ok": True, "diff": text[:200_000], "truncated": truncated}


def accept(branch: str, repo: str = "mc2") -> dict:
    """Annehmen: detached Runner starten. mc2: Merge→Push→Deploy→Health→ggf. Rollback.
    lucy: Merge→Push→PC-Build+Neustart→ggf. Revert (der Runner spricht den PC-Executor an)."""
    if not _repo_ok(repo):
        return {"ok": False, "error": "Annehmen geht nur auf der Box."}
    if not _valid_branch(branch) or branch not in _remote_branches(repo):
        return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
    key = _status_key(repo, branch)
    state = (_statuses().get(key) or {}).get("state")
    if state in ("laeuft", "rollback"):
        return {"ok": False, "error": "Für diesen Vorschlag läuft bereits ein Annahme-Lauf."}
    # Kaputt aufgesetzter Branch (kein gemeinsamer Vorfahre) → Merge kann NIE gelingen;
    # gar nicht erst einen Runner starten (die UI blendet den Knopf aus, die API hält dicht).
    if _git(repo, ["merge-base", "origin/main", f"origin/{branch}"]).returncode != 0:
        return {"ok": False, "error": "Dieser Branch hat keinen gemeinsamen Ursprung mit main "
                "(kaputt aufgesetzt, z. B. git init statt Klonen) — Annehmen ist technisch unmöglich. "
                "Bitte ablehnen (gern mit Grund) und die Idee neu in die Queue geben."}

    # Runner als /tmp-Kopie starten: deploy.sh resettet das Repo hart — das Original-Skript
    # würde einem laufenden bash unter den Füßen getauscht (bekannte Selbst-Reset-Falle).
    src = REPO / "deploy" / REPOS[repo]["runner"]
    if not src.is_file():
        return {"ok": False, "error": f"Runner fehlt im Checkout: {src.name}"}
    runner = Path(f"/tmp/mc2-auftrag-runner-{int(time.time())}.sh")
    try:
        shutil.copyfile(src, runner)
    except OSError as exc:
        return {"ok": False, "error": f"Runner nicht kopierbar: {exc}"}

    slug = re.sub(r"[^a-z0-9-]+", "-", f"{repo}-{branch}".lower())[:40].strip("-")
    unit = f"mc2-auftrag-{slug}-{int(time.time())}"
    r = subprocess.run(
        ["systemd-run", "--user", "--collect", f"--unit={unit}",
         "/bin/bash", str(runner), branch],
        capture_output=True, text=True, timeout=20)
    if r.returncode != 0:
        return {"ok": False, "error": f"Start fehlgeschlagen: {(r.stderr or r.stdout).strip()[:300]}"}
    _set_status(key, "laeuft", "Annahme-Lauf gestartet")
    return {"ok": True, "unit": unit}


def reject(branch: str, repo: str = "mc2", grund: str = "") -> dict:
    """Ablehnen: Remote-Branch löschen (die Arbeit bleibt in der Gitea-Historie referenzierbar,
    aber die Entscheidung ist gefallen). Der optionale GRUND ist das Lern-Gedächtnis des
    Kreislaufs: er landet in ABLEHNUNGEN_PATH (jsonl), und Idle-Radar/Analysten bekommen
    ihn als „NIE wieder vorschlagen"-Material vorgelegt. Meldung in den Briefkasten."""
    if not _repo_ok(repo):
        return {"ok": False, "error": "Ablehnen geht nur auf der Box."}
    if not _valid_branch(branch) or branch not in _remote_branches(repo):
        return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
    grund = (grund or "").strip()[:400]
    # Betreff VOR dem Löschen sichern — danach ist der Ref weg.
    subject = (_git(repo, ["show", "-s", "--format=%s", f"origin/{branch}"]).stdout or "").strip()[:200]
    r = _git(repo, ["push", "origin", "--delete", branch], timeout=30)
    if r.returncode != 0:
        return {"ok": False, "error": f"Löschen fehlgeschlagen: {(r.stderr or '').strip()[:300]}"}
    _git(repo, ["fetch", "-q", "--prune", "origin"], timeout=30)
    detail = "Vom Commander abgelehnt — Branch gelöscht"
    if grund:
        detail += f" (Grund: {grund})"
    _set_status(_status_key(repo, branch), "abgelehnt", detail)
    try:
        with ABLEHNUNGEN_PATH.open("a", encoding="utf-8") as f:
            f.write(json.dumps({"ts": int(time.time()), "repo": repo, "branch": branch,
                                "subject": subject, "grund": grund}, ensure_ascii=False) + "\n")
    except OSError:
        log.warning("auftragsbuch: Ablehn-Gedächtnis %s nicht schreibbar", ABLEHNUNGEN_PATH, exc_info=True)
    try:
        from services import announce
        wo = "im Lucy-Repo " if repo == "lucy" else ""
        warum = f" Grund: {grund}" if grund else ""
        announce.add(f"Vorschlag '{branch}' {wo}wurde abgelehnt und der Branch gelöscht.{warum}",
                     "[Auftragsbuch]", "auftragsbuch", "silent")
    except Exception:
        pass
    return {"ok": True}


# ── Skill-Kandidaten (Wissens-Vault) ─────────────────────────────────────────

def _kandidaten_dir() -> Path:
    return VAULT / "skill-kandidaten"


def list_skill_kandidaten() -> list[dict]:
    d = _kandidaten_dir()
    if os.name != "posix" or not d.is_dir():
        return []
    out = []
    for p in sorted(d.glob("*.md"), key=lambda x: x.stat().st_mtime, reverse=True):
        try:
            text = p.read_text(encoding="utf-8", errors="replace")
            first = next((ln.strip().lstrip("# ") for ln in text.splitlines() if ln.strip()), p.stem)
            out.append({"file": p.name, "title": first[:160],
                        "preview": text[:3000], "mtime": p.stat().st_mtime})
        except OSError:
            continue
    return out


def _vault_git(args: list[str]) -> None:
    try:
        subprocess.run(["git", "-C", str(VAULT), *args], capture_output=True, text=True, timeout=15)
    except Exception:
        pass


def _kandidat_path(fname: str) -> Path | None:
    if not _KANDIDAT_RX.match(fname):
        return None
    p = (_kandidaten_dir() / fname).resolve()
    if not p.is_relative_to(_kandidaten_dir().resolve()) or not p.is_file():
        return None
    return p


def skill_accept(fname: str) -> dict:
    """Skill-Kandidat beauftragen: Inhalt als Werkstatt-Auftrag an die bewährte
    `hermes -z`-CLI-Lane (detached — der Lauf dauert Minuten und braucht das Backend nicht).
    Ergebnis ist wieder propose-only: ein neuer Branch, der hier als Karte auftaucht."""
    if not _available():
        return {"ok": False, "error": "Beauftragen geht nur auf der Box."}
    p = _kandidat_path(fname)
    if not p:
        return {"ok": False, "error": f"Kandidat nicht gefunden: {fname}"}

    beauftragt = _kandidaten_dir() / "beauftragt"
    beauftragt.mkdir(parents=True, exist_ok=True)
    target = beauftragt / p.name
    try:
        content = p.read_text(encoding="utf-8", errors="replace")
        p.rename(target)
    except OSError as exc:
        return {"ok": False, "error": f"Kandidat nicht verschiebbar: {exc}"}
    _vault_git(["add", "-A"])
    _vault_git(["commit", "-q", "-m", f"Skill-Kandidat beauftragt: {p.name}"])

    order = ("Nutze den orchestrator-Skill (propose-only, Zwei-Kritiker-Gate). "
             "Auftrag aus dem Wissens-Vault (vom Commander im Auftragsbuch freigegeben):\n\n"
             + content)
    order_file = Path(f"/tmp/mc2-skill-auftrag-{int(time.time())}.txt")
    try:
        order_file.write_text(order, encoding="utf-8")
    except OSError as exc:
        return {"ok": False, "error": f"Auftrag nicht schreibbar: {exc}"}
    unit = f"mc2-skill-auftrag-{int(time.time())}"
    r = subprocess.run(
        ["systemd-run", "--user", "--collect", f"--unit={unit}",
         "/bin/bash", "-lc", f'hermes -z "$(cat {order_file})"'],
        capture_output=True, text=True, timeout=20)
    if r.returncode != 0:
        return {"ok": False, "error": f"Start fehlgeschlagen: {(r.stderr or r.stdout).strip()[:300]}"}
    try:
        from services import announce
        announce.add(f"Skill-Kandidat '{fname}' wurde beauftragt — die Werkstatt arbeitet, "
                     "das Ergebnis erscheint als neuer Vorschlag im Auftragsbuch.",
                     "[Auftragsbuch]", "auftragsbuch", "silent")
    except Exception:
        pass
    return {"ok": True, "unit": unit}


def skill_reject(fname: str) -> dict:
    if not _available():
        return {"ok": False, "error": "Verwerfen geht nur auf der Box."}
    p = _kandidat_path(fname)
    if not p:
        return {"ok": False, "error": f"Kandidat nicht gefunden: {fname}"}
    verworfen = _kandidaten_dir() / "verworfen"
    verworfen.mkdir(parents=True, exist_ok=True)
    try:
        p.rename(verworfen / p.name)
    except OSError as exc:
        return {"ok": False, "error": f"Kandidat nicht verschiebbar: {exc}"}
    _vault_git(["add", "-A"])
    _vault_git(["commit", "-q", "-m", f"Skill-Kandidat verworfen: {p.name}"])
    return {"ok": True}
