"""Provider-Layer: LLM-Calls über Claude-CLI, OpenCode-CLI oder direkte MiniMax-API.

Port aus creator/backend/agents.py mit zwei bewussten Abweichungen:
- KEIN Provider-Fallback: resolve_role(role) kommt vollständig aus der .env; ist eine
  Rolle unkonfiguriert oder der Provider nicht nutzbar, wird der Lauf pausiert
  (AgentInfraError) — nie still gewechselt (Leitprinzip 2).
- Leere-Ausgabe-Triage (Phase-0-Erkenntnis 13): leerer Text bei rc=0 ist ein Fehler.
  OpenCode-Pfad: keine Session in der OpenCode-DB → Netz/Provider → AgentInfraError
  (Pause); Session vorhanden → Retry-Fehler (Thinking-Overrun/Stall).

Der Event-Sink `on_event` wird von außen injiziert (CLI setzt database.add_event) —
dieses Modul bleibt DB-frei.
"""

import asyncio
import heapq
import logging
import os
import re
import shutil
import signal
import sqlite3
import tempfile
import time
from contextlib import asynccontextmanager
from pathlib import Path

import httpx

from config import (MAX_CONCURRENT_AGENTS, MAX_CONCURRENT_AGENTS_PER_TOPIC,
                    MAX_CONCURRENT_API_AGENTS, OPENCODE_CONFIG, PROVIDERS,
                    RAM_MIN_FREE_PCT, fake_agents_aktiv, resolve_role)

log = logging.getLogger("planer.agents")


class AgentInfraError(Exception):
    """Infrastruktur nicht nutzbar (Netz/Provider down, Rolle unkonfiguriert). KEIN
    inhaltliches Urteil — der Aufrufer pausiert den Lauf, nie fail-open."""


_active_processes: dict[str, asyncio.subprocess.Process] = {}
_active_api: set[str] = set()            # laufende direkte API-Calls (kein Prozess zu killen)
_active_started: dict[str, float] = {}   # agent_key → Wandzeit-Start (Live-Anzeige)
_active_labels: dict[str, str] = {}


def active_agents(scope_prefix: str | None = None) -> list[dict]:
    """Laufende Agenten mit Laufzeit, längste zuerst. → [{key, label, runtime}]"""
    now = time.time()
    out = [{"key": k, "label": _active_labels.get(k, ""), "runtime": round(now - t, 1)}
           for k, t in list(_active_started.items())
           if (k in _active_processes or k in _active_api)
           and (not scope_prefix or k.startswith(scope_prefix))]
    return sorted(out, key=lambda a: -a["runtime"])


# --- Abbruch (Prefix-basiert) --------------------------------------------------------
_cancelled_prefixes: set[str] = set()


def cancel_scope(prefix: str) -> None:
    _cancelled_prefixes.add(prefix)


def clear_scope(prefix: str) -> None:
    _cancelled_prefixes.discard(prefix)


def _scope_cancelled(agent_key: str) -> bool:
    return any(agent_key.startswith(p) for p in _cancelled_prefixes)


class _PrioritySemaphore:
    """Semaphore-Variante: bei knappen Slots gewinnt die NIEDRIGSTE Prioritätszahl
    (FIFO innerhalb gleicher Priorität) — frühe Board-Spalten vor späten."""

    def __init__(self, value: int):
        self._value = value
        self._waiters: list = []  # Heap aus [priority, seq, future]
        self._seq = 0

    async def acquire(self, priority: int = 100):
        if self._value > 0:
            self._value -= 1
            return
        fut = asyncio.get_event_loop().create_future()
        entry = [priority, self._seq, fut]
        self._seq += 1
        heapq.heappush(self._waiters, entry)
        try:
            await fut  # release() reicht den Slot direkt weiter (kein value-Increment)
        except BaseException:
            entry[2] = None  # Tombstone: release() überspringt tote Waiter
            if fut.done() and not fut.cancelled():
                self.release()  # Slot kam exakt vor dem Cancel → weitergeben
            raise

    def release(self):
        while self._waiters:
            entry = heapq.heappop(self._waiters)
            if entry[2] is not None and not entry[2].done():
                entry[2].set_result(None)
                return
        self._value += 1


_batch_sem = _PrioritySemaphore(MAX_CONCURRENT_AGENTS)          # Prozess-Tier (~310 MB RSS)
_batch_sem_api = _PrioritySemaphore(MAX_CONCURRENT_API_AGENTS)  # API-Tier (~0 RAM)
_topic_sems: dict[str, _PrioritySemaphore] = {}

# Kleinerer Index = höhere Priorität: Karten fertig machen statt neues WIP öffnen.
_STAGE_PRIORITY = ("sichten", "judge", "nachfass", "schneiden")


def _agent_priority(key: str) -> int:
    for i, tag in enumerate(_STAGE_PRIORITY):
        if f"-{tag}-" in key or key.endswith(f"-{tag}"):
            return i
    return len(_STAGE_PRIORITY)


@asynccontextmanager
async def _batch_gate(scope: str | None, priority: int, api: bool = False):
    """Erst der Per-Topic-Slot (fair), dann der GLOBALE Slot nach Priorität. Ein Waiter
    hält beim globalen Anstehen nur seinen Topic-Slot. Topic-Cap gilt über beide Tiers."""
    topic_sem = _topic_sems.setdefault(scope, _PrioritySemaphore(MAX_CONCURRENT_AGENTS_PER_TOPIC)) if scope else None
    global_sem = _batch_sem_api if api else _batch_sem
    if topic_sem is not None:
        await topic_sem.acquire(priority)
    await global_sem.acquire(priority)
    try:
        yield
    finally:
        global_sem.release()
        if topic_sem is not None:
            topic_sem.release()


# Stagger für OpenCode-Starts: gleichzeitige Kaltstarts kollidieren auf der internen
# Session-DB (Phase-0-Erkenntnis 13a: alle hängen, keine Session entsteht). Token-Bucket:
# der Lock vergibt nur den Start-Slot, geschlafen wird außerhalb.
_opencode_start_lock = asyncio.Lock()
_OPENCODE_START_DELAY = float(os.getenv("OPENCODE_START_DELAY", "0.5"))
_opencode_next_start = 0.0


async def _opencode_slot() -> None:
    global _opencode_next_start
    loop = asyncio.get_running_loop()
    async with _opencode_start_lock:
        now = loop.time()
        start_at = max(now, _opencode_next_start)
        _opencode_next_start = start_at + _OPENCODE_START_DELAY
    await asyncio.sleep(max(0.0, start_at - now))


# --- RAM-Gate: unter RAM_MIN_FREE_PCT freiem Speicher warten neue CLI-Prozesse ---------
_RAM_GATE_FLOOR = 2                # unter so vielen Läufen: immer zulassen (Deadlock-Schutz)
_RAM_PER_PROC_KB = 350 * 1024
_RAM_COMMIT_WINDOW_S = 10.0        # frische Zulassungen zählen als schon verbrauchtes RAM
_RAM_POLL_S = 2.0
_cli_running = 0
_cli_recent_starts: list[tuple[float, int]] = []


def _meminfo() -> tuple[int, int] | None:
    try:
        text = Path("/proc/meminfo").read_text()
    except OSError:
        return None
    m = {k: v for k, v in re.findall(r"^(MemTotal|MemAvailable):\s+(\d+)", text, re.MULTILINE)}
    if "MemTotal" not in m or "MemAvailable" not in m:
        return None
    return int(m["MemAvailable"]), int(m["MemTotal"])


async def _ram_gate(agent_key: str, est_kb: int) -> bool:
    """True = Start zugelassen (Commit registriert), False = Scope während des Wartens
    abgebrochen. Check und Commit-Append im selben synchronen Block (kein await dazwischen)."""
    if RAM_MIN_FREE_PCT <= 0:
        return True
    waited = False
    while True:
        if _scope_cancelled(agent_key):
            return False
        mem = _meminfo()
        if mem is None or _cli_running < _RAM_GATE_FLOOR:
            break
        avail_kb, total_kb = mem
        now = time.monotonic()
        _cli_recent_starts[:] = [(t, kb) for t, kb in _cli_recent_starts
                                 if now - t < _RAM_COMMIT_WINDOW_S]
        committed_kb = sum(kb for _, kb in _cli_recent_starts)
        if avail_kb - committed_kb >= total_kb * RAM_MIN_FREE_PCT / 100:
            break
        if not waited:
            log.info("agent %s: RAM-Gate wartet (%.0f%% frei)", agent_key, avail_kb * 100 / total_kb)
            waited = True
        await asyncio.sleep(_RAM_POLL_S)
    _cli_recent_starts.append((time.monotonic(), est_kb))
    return True


# Capability → Claude --allowedTools / OpenCode-Agent
_CLAUDE_TOOLS = {"full": "Write,Bash,Read,WebSearch,WebFetch", "files": "Read,Bash,Write",
                 "read": "Read", "none": None}
_OPENCODE_AGENTS = {"full": "full", "files": "files", "read": "readonly", "none": "text"}


def _use_text_api(provider: str, model: str, capabilities: str, on_line) -> bool:
    """Direkter API-Pfad nur für tool-lose, nicht-streamende MiniMax-Calls — spart den
    OpenCode-Prozess (und dessen Stall-/Kaltstart-Probleme). PLANER_TEXT_API=0 = aus."""
    return (capabilities == "none" and on_line is None
            and PROVIDERS[provider]["cli"] == "opencode"
            and model.split("/", 1)[0] in ("minimax", "minimax-kalt")
            and bool(os.environ.get("MINIMAX_API_KEY"))
            and os.getenv("PLANER_TEXT_API", "1") != "0")


def provider_available(provider: str) -> bool:
    cfg = PROVIDERS.get(provider)
    if not cfg:
        return False
    if shutil.which(cfg["cli"]) is None:
        return False
    env_key = cfg.get("env_key")
    if env_key and not os.environ.get(env_key):
        return False
    return True


def _kill(process) -> None:
    """Agent + Kindprozesse über die Prozessgruppe killen (Kinder halten sonst die
    Pipes offen und blockieren communicate())."""
    try:
        os.killpg(os.getpgid(process.pid), signal.SIGKILL)
    except (ProcessLookupError, PermissionError):
        try:
            process.kill()
        except ProcessLookupError:
            pass


def kill_process(agent_key_prefix: str) -> None:
    for key, process in list(_active_processes.items()):
        if process.returncode is not None:
            _active_processes.pop(key, None)
            _active_started.pop(key, None)
            continue
        if key.startswith(agent_key_prefix):
            log.debug("kill agent %s", key)
            _kill(process)


# Event-Sink (CLI injiziert database.add_event). Fire-and-forget je fertigem Agenten.
on_event = None


async def run_agent(
    agent_key: str,
    prompt: str,
    timeout: int,
    role: str,
    capabilities: str = "none",
    scope: str | None = None,
    on_line=None,
    label: str = "",
) -> tuple[int, str, str]:
    """Ein LLM-Call der Rolle `role`. → (rc, stdout, stderr). Wirft AgentInfraError,
    wenn die Infrastruktur nicht nutzbar ist (Rolle unkonfiguriert, Provider down,
    leere Antwort ohne Session) — der Lauf soll dann pausieren, nicht raten."""
    if fake_agents_aktiv():  # Sekunden-Smoke: deterministisch statt LLM
        import fake_agents
        start = time.monotonic()
        res = await fake_agents.respond(agent_key, prompt, capabilities)
        if on_event is not None and scope is not None:  # Telemetrie auch im Fake-Pfad testbar
            await on_event(topic=scope, kind="agent", key=agent_key, label=label,
                           status="ok" if res[0] == 0 else "error",
                           dur_ms=int((time.monotonic() - start) * 1000), wait_ms=0,
                           meta={"provider": "fake", "model": "fake", "role": role, "rc": res[0]})
        return res
    if _scope_cancelled(agent_key):
        return 1, "", "cancelled"
    try:
        provider, model = resolve_role(role)  # NUR aus .env — kein Default, kein Fallback
    except RuntimeError as e:
        raise AgentInfraError(str(e)) from e
    use_api = _use_text_api(provider, model, capabilities, on_line)
    if not use_api and shutil.which(PROVIDERS[provider]["cli"]) is None:
        raise AgentInfraError(f"CLI '{PROVIDERS[provider]['cli']}' nicht installiert (Provider {provider})")
    queued = time.monotonic()
    async with _batch_gate(scope, _agent_priority(agent_key), api=use_api):
        if _scope_cancelled(agent_key):
            return 1, "", "cancelled"
        wait_ms = int((time.monotonic() - queued) * 1000)
        start = time.monotonic()
        status = "error"
        rc = None
        err_tail = ""
        api_tokens = None
        try:
            log.info("agent %s: %s %s (Rolle %s)", agent_key, provider, model, role)
            if use_api:
                rc_, out_, err_, api_tokens = await _run_text_api(agent_key, prompt, timeout, model, label=label)
                res = (rc_, out_, err_)
            elif PROVIDERS[provider]["cli"] == "opencode":
                res = await _run_opencode(agent_key, prompt, timeout, model, capabilities,
                                          on_line=on_line, label=label)
                res = await _leere_ausgabe_triage(agent_key, res, on_line)
            else:
                res = await _run_claude_cli(agent_key, prompt, timeout, model, capabilities, label=label)
            rc = res[0]
            status = "ok" if rc == 0 else ("killed" if rc is not None and rc < 0 else "error")
            if rc not in (0, None) and rc >= 0:
                err_tail = (res[2] or res[1] or "").strip()[-300:]
            return res
        except asyncio.TimeoutError:
            status = "timeout"
            raise
        except asyncio.CancelledError:
            status = "cancelled"
            raise
        except AgentInfraError:
            status = "infra"
            raise
        finally:
            if on_event is not None and scope is not None:  # nie fatal
                try:
                    meta = {"provider": provider, "model": model, "role": role, "rc": rc}
                    if err_tail:
                        meta["stderr"] = err_tail
                    if api_tokens:
                        meta["tokens"] = api_tokens
                    elif PROVIDERS[provider]["cli"] == "opencode":
                        if (tok := await asyncio.to_thread(_session_tokens, agent_key)):
                            meta["tokens"] = tok
                    await on_event(topic=scope, kind="agent", key=agent_key, label=label,
                                   status=status, dur_ms=int((time.monotonic() - start) * 1000),
                                   wait_ms=wait_ms, meta=meta)
                except Exception:
                    log.debug("on_event failed", exc_info=True)


async def _leere_ausgabe_triage(agent_key: str, res: tuple[int, str, str], on_line) -> tuple[int, str, str]:
    """Erkenntnis 13: leerer Text bei rc=0 ist nie ok. Keine OpenCode-Session zum Key →
    der Call kam nie beim Provider an (Netz) → AgentInfraError (Lauf pausiert).
    Session vorhanden → Thinking-Overrun/Stall → normaler Fehler (Engine-Retry)."""
    rc, out, err = res
    if rc != 0 or out.strip() or on_line is not None:
        return res
    tok = await asyncio.to_thread(_session_tokens, agent_key)
    if tok is None:
        raise AgentInfraError(f"agent {agent_key}: leere Ausgabe ohne OpenCode-Session — Provider/Netz nicht erreichbar")
    return 1, "", f"leere Ausgabe (Session vorhanden, output-tokens={tok.get('output')}) — Overrun/Stall"


async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None, timeout: int,
                       stagger: bool = False, on_line=None, label: str = "",
                       env: dict | None = None, est_kb: int = _RAM_PER_PROC_KB) -> tuple[int, str, str]:
    start = time.monotonic()

    async def spawn():
        return await asyncio.create_subprocess_exec(
            *cmd,
            stdin=asyncio.subprocess.PIPE if stdin_data is not None else asyncio.subprocess.DEVNULL,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
            start_new_session=True,  # eigene Prozessgruppe → killpg trifft auch Kinder
            env=env,
        )

    global _cli_running
    if stagger:
        if not await _ram_gate(agent_key, est_kb):
            return 1, "", "cancelled"
        await _opencode_slot()  # Gate VOR dem Start-Slot: Zulassungswelle wird trotzdem gespaced
    process = await spawn()
    if stagger:
        _cli_running += 1
    # Kollisionssicheres Tracking: identische Keys bekommen ~n-Suffix (Prefix-Kill matcht weiter)
    track_key = agent_key
    n = 2
    while track_key in _active_processes:
        track_key = f"{agent_key}~{n}"
        n += 1
    _active_processes[track_key] = process
    _active_started[track_key] = time.time()
    _active_labels[track_key] = label
    try:
        try:
            if on_line is not None:
                out_chunks: list[str] = []

                async def _pump():
                    async for raw in process.stdout:
                        s = raw.decode("utf-8", errors="replace")
                        out_chunks.append(s)
                        try:
                            on_line(s)
                        except Exception:
                            log.debug("on_line callback failed", exc_info=True)
                await asyncio.wait_for(_pump(), timeout=timeout)
                await process.wait()
                stderr_b = await process.stderr.read()
                stdout, stderr = "".join(out_chunks).encode("utf-8"), stderr_b
            else:
                stdout, stderr = await asyncio.wait_for(
                    process.communicate(input=stdin_data),
                    timeout=timeout,
                )
        except asyncio.TimeoutError:
            _kill(process)
            try:
                await asyncio.wait_for(process.wait(), timeout=5)
            except asyncio.TimeoutError:
                pass
            log.info("agent %s: timeout after %ds", agent_key, timeout)
            raise
        log.info("agent %s: exit %s after %.1fs (%d bytes stdout)",
                 agent_key, process.returncode, time.monotonic() - start, len(stdout))
        return process.returncode, stdout.decode("utf-8", errors="replace"), stderr.decode("utf-8", errors="replace")
    finally:
        if stagger:
            _cli_running -= 1
        if _active_processes.get(track_key) is process:
            del _active_processes[track_key]
            _active_started.pop(track_key, None)
            _active_labels.pop(track_key, None)


async def _run_claude_cli(agent_key: str, prompt: str, timeout: int, model: str,
                          capabilities: str, label: str = "") -> tuple[int, str, str]:
    cmd = ["claude", "-p", "--model", model]
    tools = _CLAUDE_TOOLS.get(capabilities)
    if tools:
        cmd += ["--allowedTools", tools]
    cmd += ["--dangerously-skip-permissions"]
    return await _communicate(agent_key, cmd, prompt.encode("utf-8"), timeout, stagger=True, label=label)


_OPENCODE_DB = Path.home() / ".local" / "share" / "opencode" / "opencode.db"


def _session_tokens(agent_key: str) -> dict | None:
    """Token-Zähler der neuesten OpenCode-Session mit title=agent_key (via run --title).
    Best-effort, read-only, nie fatal — None wenn DB/Zeile fehlt. Auch Timeouts haben
    Tokens verbraucht; die Verschwendung soll sichtbar bleiben."""
    try:
        con = sqlite3.connect(f"file:{_OPENCODE_DB}?mode=ro", uri=True, timeout=1)
        try:
            row = con.execute(
                "SELECT tokens_input, tokens_output, tokens_reasoning,"
                " tokens_cache_read, tokens_cache_write"
                " FROM session WHERE title=? ORDER BY time_created DESC LIMIT 1",
                (agent_key,)).fetchone()
        finally:
            con.close()
    except Exception:
        return None
    if row is None:
        return None
    keys = ("input", "output", "reasoning", "cache_read", "cache_write")
    return {k: int(v or 0) for k, v in zip(keys, row)}


async def _run_opencode(agent_key: str, prompt: str, timeout: int, model: str, capabilities: str,
                        on_line=None, label: str = "") -> tuple[int, str, str]:
    # Prompt per Temp-Datei statt argv (ARG_MAX-Schutz bei großen Chunks)
    with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8") as f:
        f.write(prompt)
        prompt_path = Path(f.name)
    # Die positionale Message MUSS vor -f stehen (-f ist ein Array-Flag).
    cmd = [
        "opencode", "run",
        "Folge exakt den Anweisungen in der angehängten Datei. Sie sind der vollständige Auftrag.",
        "-m", model,
        "--agent", _OPENCODE_AGENTS.get(capabilities, "text"),
        "--title", agent_key,  # Token-Accounting: verbindet die OpenCode-Session mit unserem Event
        "-f", str(prompt_path),
    ]
    if on_line is not None:
        cmd += ["--format", "json"]
    env = {**os.environ, "OPENCODE_CONFIG": str(OPENCODE_CONFIG)}
    try:
        rc, stdout, stderr = await _communicate(agent_key, cmd, None, timeout, stagger=True,
                                                on_line=on_line, label=label, env=env)
        return rc, (stdout if on_line is not None else _clean_opencode_output(stdout)), stderr
    finally:
        prompt_path.unlink(missing_ok=True)


# --- Direkter API-Pfad (MiniMax, Anthropic-Messages-Format) ---------------------------
_API_URL = "https://api.minimax.io/anthropic/v1/messages"
_API_VERSION = "2023-06-01"
_API_MAX_TOKENS = 32_000


def _api_model_opts(model: str) -> dict:
    """kalt-Route = niedrige Temperatur ohne Thinking; nativ = Endpoint-Defaults.
    Bewusst prefix-basiert statt Modell-Tabelle — Modell-IDs gehören nicht in den Code."""
    if model.startswith("minimax-kalt/"):
        return {"temperature": 0.3, "thinking": {"type": "disabled"}}
    return {}


async def _run_text_api(agent_key: str, prompt: str, timeout: int, model: str,
                        label: str = "") -> tuple[int, str, str, dict | None]:
    """→ (rc, text, err, tokens). Tokens direkt aus der Response-usage, auch bei rc!=0 —
    Verschwendung bleibt sichtbar. Leere Antwort und max_tokens-Abbruch sind Fehler."""
    body = {
        "model": model.split("/", 1)[1],  # ohne OpenCode-Provider-Prefix
        "max_tokens": _API_MAX_TOKENS,
        "messages": [{"role": "user", "content": prompt}],
        **_api_model_opts(model),
    }
    api_key = os.environ.get("MINIMAX_API_KEY")
    if not api_key:
        raise AgentInfraError("MINIMAX_API_KEY fehlt — in .env setzen")
    headers = {"x-api-key": api_key, "anthropic-version": _API_VERSION}
    track_key = agent_key
    n = 2
    while track_key in _active_api:
        track_key = f"{agent_key}~{n}"
        n += 1
    _active_api.add(track_key)
    _active_started[track_key] = time.time()
    _active_labels[track_key] = label
    start = time.monotonic()
    try:
        async with httpx.AsyncClient(timeout=httpx.Timeout(timeout, connect=30)) as client:
            resp = await asyncio.wait_for(
                client.post(_API_URL, json=body, headers=headers), timeout=timeout)
    except httpx.TimeoutException:
        raise asyncio.TimeoutError
    except httpx.HTTPError as e:
        return 1, "", f"{type(e).__name__}: {e}", None
    finally:
        _active_api.discard(track_key)
        _active_started.pop(track_key, None)
        _active_labels.pop(track_key, None)
        log.info("agent %s: api done after %.1fs", agent_key, time.monotonic() - start)
    if resp.status_code != 200:
        return 1, "", f"HTTP {resp.status_code}: {resp.text[:300]}", None
    data = resp.json()
    # Nur Text-Blöcke zählen — Thinking-Blöcke (native Route) werden übersprungen.
    text = "".join(b.get("text", "") for b in data.get("content", []) if b.get("type") == "text")
    u = data.get("usage") or {}
    tokens = {"input": int(u.get("input_tokens") or 0), "output": int(u.get("output_tokens") or 0),
              "reasoning": 0, "cache_read": int(u.get("cache_read_input_tokens") or 0),
              "cache_write": int(u.get("cache_creation_input_tokens") or 0)}
    if not text.strip():
        return 1, "", f"leere Antwort (stop_reason={data.get('stop_reason')})", tokens
    err = "stop_reason=max_tokens (abgeschnitten)" if data.get("stop_reason") == "max_tokens" else ""
    return 0, text, err, tokens


_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")


def _clean_opencode_output(text: str) -> str:
    """ANSI-Codes und den führenden Banner ("> agent · model") entfernen."""
    text = _ANSI_RE.sub("", text)
    lines = text.splitlines()
    while lines and (not lines[0].strip() or lines[0].lstrip().startswith(">")):
        lines.pop(0)
    return "\n".join(lines).strip()
