"""版本化落盘：``<ai_output>/output/code_gen_tree/<topic_slug>/`` + manifest。"""

from __future__ import annotations

import hashlib
import json
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from filelock import FileLock, Timeout

# 同一 ``topic_slug`` 目录下的互斥：Linux/macOS 用 ``fcntl`` flock，Windows 用 py-filelock 的实现，
# 避免多进程/多线程同时读到相同 ``prev_rev`` 后覆写 manifest。
_WRITE_LOCK_FILENAME = ".code_gen_tree_write.lock"
_ENV_LOCK_TIMEOUT = "AICD_CODE_GEN_TREE_LOCK_TIMEOUT_SEC"


def _lock_timeout_sec() -> float:
    raw = os.environ.get(_ENV_LOCK_TIMEOUT, "600")
    try:
        v = float(raw)
    except ValueError:
        v = 600.0
    return max(1.0, min(86400.0, v))


def _versioned_tree_file_lock(base_dir: Path) -> FileLock:
    return FileLock(str((base_dir / _WRITE_LOCK_FILENAME).resolve()))


def _atomic_write_text(path: Path, text: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.parent / (path.name + ".tmp")
    tmp.write_text(text, encoding="utf-8")
    os.replace(tmp, path)


MANIFEST_SCHEMA = "aicd_code_gen_tree_manifest"
MANIFEST_VERSION = 1
REV_PREFIX = "rev_"


def conventional_slug_dir(ai_output: Path, topic_slug: str) -> Path:
    return (ai_output / "output" / "code_gen_tree" / topic_slug).resolve()


def manifest_path(ai_output: Path, topic_slug: str) -> Path:
    return conventional_slug_dir(ai_output, topic_slug) / "manifest.json"


def _fingerprint_payload(payload: dict[str, Any]) -> str:
    nodes = payload.get("nodes") or []
    parts: list[str] = []
    for n in nodes:
        parts.append(
            f"{n.get('file','')}|{n.get('qname','')}|{n.get('line_start')}|{n.get('kind','')}"
        )
    parts.sort()
    h = hashlib.sha256("\n".join(parts).encode("utf-8")).hexdigest()
    return h[:24]


def read_manifest(ai_output: Path, topic_slug: str) -> dict[str, Any] | None:
    mp = manifest_path(ai_output, topic_slug)
    if not mp.is_file():
        return None
    try:
        return json.loads(mp.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return None


def _load_tree_json(path: Path) -> dict[str, Any] | None:
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return None
    if not isinstance(data, dict):
        return None
    ir = data.get("index_revision")
    if not isinstance(ir, int):
        return None
    return data


def manifest_tree_resolves(
    ai_output: Path, topic_slug: str, man: dict[str, Any] | None
) -> tuple[bool, Path | None]:
    """``manifest`` 中 latest 指向的 ``tree.json`` 存在且 ``index_revision`` 与 manifest 一致。"""
    if not man or not isinstance(man.get("latest_revision"), int):
        return False, None
    rel = man.get("latest_tree_path")
    if not isinstance(rel, str) or not rel.strip():
        return False, None
    base = conventional_slug_dir(ai_output, topic_slug)
    path = (base / rel).resolve()
    if not path.is_file():
        return False, None
    data = _load_tree_json(path)
    if data is None:
        return False, None
    if int(data["index_revision"]) != int(man["latest_revision"]):
        return False, None
    return True, path


def _iter_revision_tree_files(base: Path) -> list[tuple[int, Path]]:
    if not base.is_dir():
        return []
    out: list[tuple[int, Path]] = []
    for child in base.iterdir():
        if not child.is_dir():
            continue
        name = child.name
        if not name.startswith(REV_PREFIX):
            continue
        try:
            n = int(name[len(REV_PREFIX) :])
        except ValueError:
            continue
        tp = child / "tree.json"
        if tp.is_file():
            out.append((n, tp))
    out.sort(key=lambda x: x[0])
    return out


def _rebuild_manifest_dict(
    ai_output: Path,
    topic_slug: str,
    *,
    fallback_scan_root: str,
    max_history: int,
) -> dict[str, Any] | None:
    """根据磁盘上有效的 ``rev_*/tree.json`` 重建 manifest 内容字典（不写入）。"""
    base = conventional_slug_dir(ai_output, topic_slug)
    triples: list[tuple[int, Path, dict[str, Any]]] = []
    for n, tp in _iter_revision_tree_files(base):
        data = _load_tree_json(tp)
        if data is None:
            continue
        if int(data["index_revision"]) != n:
            continue
        triples.append((n, tp, data))
    if not triples:
        return None
    new_rev = triples[-1][0]
    _n, tree_path, max_body = triples[-1]
    fp = _fingerprint_payload(max_body)
    scan_root = (fallback_scan_root or "").strip()
    if not scan_root:
        r = max_body.get("root")
        if isinstance(r, str) and r.strip():
            scan_root = r.strip()
    now = datetime.now(timezone.utc).isoformat()
    history_entries: list[dict[str, Any]] = []
    for n, tp, data in reversed(triples[-max_history:]):
        history_entries.append(
            {
                "revision": n,
                "generated_at": (
                    str(data.get("generated_at") or now)
                    if data.get("generated_at")
                    else now
                ),
                "content_fingerprint": _fingerprint_payload(data),
                "files_scanned": data.get("files_scanned", 0),
                "node_count": len(data.get("nodes") or []),
                "rel_path": f"{REV_PREFIX}{n:04d}/tree.json",
            }
        )
    rel_path = f"{REV_PREFIX}{new_rev:04d}/tree.json"
    return {
        "schema": MANIFEST_SCHEMA,
        "version": MANIFEST_VERSION,
        "topic_slug": topic_slug,
        "scan_root": scan_root,
        "latest_revision": new_rev,
        "latest_tree_path": rel_path,
        "latest_content_fingerprint": fp,
        "updated_at": now,
        "revision_history": history_entries,
        "agent_continuation_rule": (
            "Unless the user pins an older revision, always load "
            f"{topic_slug} via latest_revision / latest_tree_path for follow-up work."
        ),
    }


def read_manifest_for_use(
    ai_output: Path,
    topic_slug: str,
    *,
    attempt_repair: bool = True,
) -> tuple[dict[str, Any] | None, list[str]]:
    """读取 manifest；若损坏或与磁盘不一致且 ``attempt_repair``，则在持锁下尝试从 ``rev_*`` 自愈。"""
    issues: list[str] = []
    man = read_manifest(ai_output, topic_slug)
    ok, _path = manifest_tree_resolves(ai_output, topic_slug, man)
    if ok:
        return man, issues
    if man is not None:
        issues.append("manifest_invalid_or_tree_mismatch")
    else:
        issues.append("manifest_missing")
    if not attempt_repair:
        return None, issues

    base = conventional_slug_dir(ai_output, topic_slug)
    base.mkdir(parents=True, exist_ok=True)
    lock = _versioned_tree_file_lock(base)
    try:
        lock.acquire(timeout=_lock_timeout_sec())
    except Timeout:
        issues.append("lock_timeout_during_manifest_repair")
        return None, issues
    try:
        man2 = read_manifest(ai_output, topic_slug)
        if manifest_tree_resolves(ai_output, topic_slug, man2)[0]:
            return man2, issues
        fb = ""
        if man2 and isinstance(man2.get("scan_root"), str):
            fb = man2["scan_root"]
        rebuilt = _rebuild_manifest_dict(
            ai_output,
            topic_slug,
            fallback_scan_root=fb,
            max_history=32,
        )
        if rebuilt is None:
            issues.append("repair_failed_no_valid_revision_on_disk")
            return None, issues
        mp = manifest_path(ai_output, topic_slug)
        _atomic_write_text(mp, json.dumps(rebuilt, ensure_ascii=False, indent=2) + "\n")
        issues.append("manifest_repaired_from_disk")
        return read_manifest(ai_output, topic_slug), issues
    finally:
        lock.release()


def write_versioned_tree(
    ai_output: Path,
    topic_slug: str,
    payload: dict[str, Any],
    *,
    scan_root: str,
    max_history: int = 32,
) -> dict[str, Any]:
    """
    每次成功同步 **index_revision 自增**；写入 ``rev_XXXX/tree.json``；更新 manifest。

    约定：多任务接续时以 **manifest.latest_revision** 与 **latest_tree_path** 为准。

    同一 ``(ai_output, topic_slug)`` 在**多线程、多进程**（Linux / Windows / macOS）下并发调用时，
    由目录内 **``filelock``** 锁文件串行化，保证 ``index_revision`` 单调、manifest 一致。
    超时秒数：**``AICD_CODE_GEN_TREE_LOCK_TIMEOUT_SEC``**（默认 **600**，范围 **1–86400**）。
    """
    base = conventional_slug_dir(ai_output, topic_slug)
    base.mkdir(parents=True, exist_ok=True)
    lock = _versioned_tree_file_lock(base)
    try:
        lock.acquire(timeout=_lock_timeout_sec())
    except Timeout:
        return {
            "ok": False,
            "error": (
                f"code_gen_tree write lock timeout after {_lock_timeout_sec()}s "
                f"({_ENV_LOCK_TIMEOUT}) — another process may be syncing the same topic_slug"
            ),
            "code": "lock_timeout",
            "topic_slug": topic_slug,
        }
    try:
        return _write_versioned_tree_unlocked(
            ai_output,
            topic_slug,
            payload,
            scan_root=scan_root,
            max_history=max_history,
        )
    finally:
        lock.release()


def _write_versioned_tree_unlocked(
    ai_output: Path,
    topic_slug: str,
    payload: dict[str, Any],
    *,
    scan_root: str,
    max_history: int,
) -> dict[str, Any]:
    base = conventional_slug_dir(ai_output, topic_slug)

    prev = read_manifest(ai_output, topic_slug)
    prev_rev = 0
    history: list[dict[str, Any]] = []
    if prev and isinstance(prev.get("latest_revision"), int):
        prev_rev = int(prev["latest_revision"])
    if prev and isinstance(prev.get("revision_history"), list):
        history = list(prev["revision_history"])

    new_rev = prev_rev + 1
    rev_name = f"{REV_PREFIX}{new_rev:04d}"
    rev_dir = base / rev_name
    rev_dir.mkdir(parents=True, exist_ok=True)
    tree_path = rev_dir / "tree.json"

    fp = _fingerprint_payload(payload)
    nodes = payload.get("nodes") or []
    body = {
        **{k: v for k, v in payload.items() if k != "nodes"},
        "index_revision": new_rev,
        "topic_slug": topic_slug,
        "content_fingerprint": fp,
        "nodes": nodes,
        "generated_at": datetime.now(timezone.utc).isoformat(),
    }
    tree_text = json.dumps(body, ensure_ascii=False, indent=2) + "\n"
    tree_tmp = rev_dir / "tree.json.tmp"
    tree_tmp.write_text(tree_text, encoding="utf-8")
    os.replace(tree_tmp, tree_path)

    entry = {
        "revision": new_rev,
        "generated_at": body["generated_at"],
        "content_fingerprint": fp,
        "files_scanned": payload.get("files_scanned", 0),
        "node_count": len(nodes),
        "rel_path": f"{rev_name}/tree.json",
    }
    history = [entry] + [h for h in history if h.get("revision") != new_rev]
    history = history[:max_history]

    man = {
        "schema": MANIFEST_SCHEMA,
        "version": MANIFEST_VERSION,
        "topic_slug": topic_slug,
        "scan_root": scan_root,
        "latest_revision": new_rev,
        "latest_tree_path": entry["rel_path"],
        "latest_content_fingerprint": fp,
        "updated_at": body["generated_at"],
        "revision_history": history,
        "agent_continuation_rule": (
            "Unless the user pins an older revision, always load "
            f"{topic_slug} via latest_revision / latest_tree_path for follow-up work."
        ),
    }
    mp = manifest_path(ai_output, topic_slug)
    man_text = json.dumps(man, ensure_ascii=False, indent=2) + "\n"
    _atomic_write_text(mp, man_text)

    return {
        "ok": True,
        "topic_slug": topic_slug,
        "index_revision": new_rev,
        "tree_json_path": str(tree_path.resolve()),
        "manifest_path": str(manifest_path(ai_output, topic_slug)),
        "content_fingerprint": fp,
        "conventional_dir": str(base),
    }
