"""Гейты между фазами.

Гейт — это функция, возвращающая список претензий. Пустой список = проход.
Ни один гейт не спрашивает модель «убедись, что…»: всё проверяется по данным
состояния и по журналу доказательств. Провал — не предупреждение, фаза переделывается.
"""

from __future__ import annotations

from .store import Run

GATE_PHASE = {"G1": "briefing", "G2": "spec", "G3": "plan", "G4": "accept"}

GATE_TITLE = {
    "G1": "Требования разобраны",
    "G2": "Спека покрывает бриф",
    "G3": "План прослеживается в обе стороны",
    "G4": "Приёмка доказана прогоном",
}

LIVE = ("open", "in-spec", "in-task", "done", "placeholder")


def g1(run: Run) -> list[str]:
    st = run.state
    problems = []
    if not st["requirements"]:
        problems.append("манифест пуст: бриф не атомизирован в требования")
    for r in st["requirements"]:
        if r["status"] == "open" and not (r.get("note") or "").strip():
            problems.append(f"{r['id']}: статус open без записанной причины")
        if not (r.get("quote") or "").strip():
            problems.append(f"{r['id']}: нет дословной цитаты из брифа")
    return problems


def g2(run: Run) -> list[str]:
    st = run.state
    problems = [f"{r['id']}: остался в статусе open" for r in st["requirements"] if r["status"] == "open"]
    coverage = st.get("gates", {}).get("G2", {}).get("coverage")
    if not coverage:
        problems.append(
            "нет отчёта независимого читателя. Запустите субагента, дав ему ТОЛЬКО "
            "brief.md и spec.md (манифест не давать), и запишите вывод через "
            "`chekan gate G2 --coverage <файл>`"
        )
    elif coverage.get("unresolved"):
        for item in coverage["unresolved"]:
            problems.append(f"читатель нашёл пропуск, он не закрыт: {item}")
    return problems


def g3(run: Run) -> list[str]:
    st = run.state
    problems = []
    tracked = {rid for t in st["tasks"] for rid in t.get("requirements", [])}
    for r in st["requirements"]:
        if r["status"] in ("dropped", "deferred", "placeholder"):
            continue
        if r["id"] not in tracked:
            problems.append(f"{r['id']} не покрыто ни одним таском")
    for t in st["tasks"]:
        if not t.get("requirements"):
            problems.append(f"таск {t['id']} не привязан ни к одному требованию")
        for dep in t.get("deps", []):
            if not run.task(dep):
                problems.append(f"таск {t['id']} зависит от несуществующего {dep}")
    problems.extend(_zone_conflicts(st["tasks"]))
    return problems


def _reaches(tasks: list[dict], src: str, dst: str) -> bool:
    """Дойдёт ли src до dst по цепочке зависимостей.

    Считать нужно транзитивно: 04 зависит от 02, 02 от 01 — значит 04 и 01 уже
    выстроены в очередь и параллельно не пойдут. Проверка только прямых связей
    поднимала ложную тревогу, а гейт, кричащий впустую, перестают читать.
    """
    deps = {t["id"]: list(t.get("deps") or []) for t in tasks}
    seen, stack = set(), list(deps.get(src, []))
    while stack:
        node = stack.pop()
        if node == dst:
            return True
        if node in seen:
            continue
        seen.add(node)
        stack.extend(deps.get(node, []))
    return False


def _zones_overlap(z1: str, z2: str) -> bool:
    s1 = z1.strip().strip("/")
    s2 = z2.strip().strip("/")
    if not s1 or not s2:
        return False
    if s1 == s2:
        return True
    parts1 = s1.split("/")
    parts2 = s2.split("/")
    if len(parts1) <= len(parts2) and parts2[:len(parts1)] == parts1:
        return True
    if len(parts2) <= len(parts1) and parts1[:len(parts2)] == parts2:
        return True
    return False


def _zone_paths(zone: str) -> list[str]:
    """Зона — один путь или несколько через запятую (как в chekan/zonelock.py).

    Пустые куски и пробелы вокруг запятых путями не считаются: `"a/, ,b/"`
    даёт `["a", "b"]`, а не запирает всех подряд из-за мусора между запятыми.
    """
    return [p for p in (part.strip().strip("/") for part in (zone or "").split(",")) if p]


def _zone_common_path(p1: str, p2: str) -> str:
    """Общий путь двух пересекающихся путей — тот, что лежит выше по дереву."""
    return p1 if len(p1.split("/")) <= len(p2.split("/")) else p2


def _zone_conflicts(tasks: list[dict]) -> list[str]:
    """Два таска делят путь в зоне, не выстроенные в очередь, столкнутся при параллели.

    Зона может быть многопутевой (через запятую) — пересечение ищется по любой паре
    путей из двух зон, а не по совпадению зон целиком строкой: иначе зона
    `a,b,c` против `a,d,e` считалась бы непересекающейся.
    """
    problems = []
    for i, a in enumerate(tasks):
        paths_a = _zone_paths(a.get("zone") or "")
        if not paths_a:
            continue
        for b in tasks[i + 1:]:
            paths_b = _zone_paths(b.get("zone") or "")
            if not paths_b:
                continue
            overlap_path = None
            for p1 in paths_a:
                for p2 in paths_b:
                    if _zones_overlap(p1, p2):
                        overlap_path = _zone_common_path(p1, p2)
                        break
                if overlap_path:
                    break
            if overlap_path is None:
                continue
            if not _reaches(tasks, a["id"], b["id"]) and not _reaches(tasks, b["id"], a["id"]):
                problems.append(
                    f"таски {a['id']} и {b['id']} делят зону по пути {overlap_path} и не связаны "
                    "зависимостью — они столкнутся при параллельном запуске"
                )
    return problems


def g4(run: Run) -> list[str]:
    st = run.state
    problems = []
    for t in st["tasks"]:
        green = run.green_evidence(t["id"])
        if t["status"] == "done" and not green:
            problems.append(f"таск {t['id']} закрыт без зелёного доказательства в журнале")
        # Переиспользование экономит время внутри сборки, но приёмке предъявляют
        # только то, что действительно прогонялось: зачёт по отпечатку — самый
        # удобный способ провести мимо гейта код, которого никто не проверял.
        if t["status"] == "done" and green and green.get("reused"):
            problems.append(
                f"таск {t['id']} закрыт переиспользованным доказательством "
                f"(`{green['cmd']}` засчитана по отпечатку, а не прогнана). "
                f"Прогоните по-настоящему: chekan run \"{green['cmd']}\" --task {t['id']} --no-reuse"
            )
        if t["status"] in ("pending", "in-progress", "review", "repair", "failed"):
            problems.append(f"таск {t['id']} не закрыт (статус {t['status']})")
    accept = st.get("gates", {}).get("G4", {}).get("acceptance")
    if not accept:
        problems.append(
            "приёмка не проводилась. `chekan accept` вызывает профильный проверяльщик "
            "проекта и записывает его вывод"
        )
    elif accept.get("exit") is None:
        # У профиля нет своих команд — значит доказательством служит то, что владелец
        # прогнал сам через `chekan run --kind accept`. Не засчитывать эту запись значило
        # отправлять к команде, которую гейт же и игнорирует: замкнутый круг, из
        # которого чужой агент не выходит.
        own = [e for e in run.evidence() if e.get("kind") == "accept" and e.get("exit") == 0]
        if not own:
            problems.append(
                f"приёмка профиля «{accept.get('profile')}» ничего не прогнала — доказательств нет. "
                "Назовите реальную команду сами: `chekan run \"<сборка или тесты>\" --kind accept`, "
                "она и станет доказательством приёмки"
            )
    elif accept.get("exit") != 0:
        problems.append(
            f"приёмка красная: `{accept.get('cmd')}` завершилась с кодом {accept.get('exit')}"
        )
    if accept and accept.get("skill"):
        mechanical = {c.get("cmd") for c in accept.get("checks") or []}
        live = [
            e
            for e in run.evidence()
            if e.get("kind") == "accept" and e.get("exit") == 0 and e.get("cmd") not in mechanical
        ]
        if not live:
            problems.append(
                f"живой сценарий не подтверждён исполнением: профиль называет скилл "
                f"«{accept['skill']}», но зелёного прогона сверх механических проверок в "
                "журнале нет. Механические тесты не доказывают, что сервис отвечает "
                "пользователю — прогоните проверяльщик через `chekan run ... --kind accept`"
            )

    blind = st.get("gates", {}).get("G4", {}).get("blind")
    if not blind:
        problems.append(
            "нет вердикта слепого приёмщика. Запустите субагента, дав ему ТОЛЬКО "
            "brief.md и репозиторий (спеку и таски не давать)"
        )
    elif blind.get("missing"):
        for item in blind["missing"]:
            problems.append(f"слепой приёмщик не нашёл в работе: {item}")
    return problems


CHECKS = {"G1": g1, "G2": g2, "G3": g3, "G4": g4}


def check(run: Run, name: str) -> list[str]:
    if name not in CHECKS:
        raise KeyError(f"неизвестный гейт {name}, доступны {sorted(CHECKS)}")
    return CHECKS[name](run)


def record(run: Run, name: str, problems: list[str]) -> None:
    from .store import now

    st = run.state
    entry = st.setdefault("gates", {}).setdefault(name, {})
    entry["status"] = "passed" if not problems else "failed"
    entry["at"] = now()
    entry["problems"] = problems
    run.save(st)


DEFAULT_STALE_TIMEOUT_SEC = 1200  # 20 минут (R02)


def stale_timeout_sec() -> int:
    import os
    env = os.environ.get("CHEKAN_TASK_STALE_TIMEOUT_SEC")
    if env:
        try:
            val = int(env)
            if val > 0:
                return val
        except ValueError:
            pass
    return DEFAULT_STALE_TIMEOUT_SEC


def stale_tasks(run: Run) -> list[str]:
    """Задачи in-progress, затихшие без движения дольше установленного срока."""
    from datetime import datetime, timezone
    from .store import _ms, now, paused_ms_between

    if run.is_paused():
        return []
    timeout_sec = stale_timeout_sec()
    now_str = now()
    now_ms = _ms(now_str) or int(datetime.now(timezone.utc).timestamp() * 1000)
    problems = []
    for t in run.state.get("tasks", []):
        if t.get("status") != "in-progress":
            continue
        started = _ms(t.get("started_at"))
        if not started:
            continue
        stamps = [started]
        for row in run.evidence(t["id"]):
            stamps.append(_ms(row.get("ts")))
        last = max(x for x in stamps if x)
        from datetime import datetime as _dt
        last_iso = _dt.fromtimestamp(last / 1000, timezone.utc).isoformat()
        quiet_ms = max(0, (now_ms - last) - paused_ms_between(run.state, last_iso, now_str))
        if quiet_ms >= timeout_sec * 1000:
            mins = max(1, round(quiet_ms / 60000))
            problems.append(
                f"таск {t['id']} затих без движения более {mins} мин; "
                f"перезапустите или сбросьте: chekan task reset {t['id']}"
            )
    return problems


def build_invariant(run: Run) -> list[str]:
    """Проверка, применимая в любой момент фазы сборки."""
    problems = [
        f"таск {t['id']} в статусе done без зелёного доказательства"
        for t in run.state["tasks"]
        if t["status"] == "done" and not run.green_evidence(t["id"])
    ]
    problems.extend(stale_tasks(run))
    return problems
