"""The ``.state/`` manifest: what Core files are installed, their version, and checksums.

Drives ``alexandria update`` (replace Core, leave Config/Content untouched) and
hand-edit drift detection. Per-machine runtime state (e.g. sweep once/day flags) stays
off-repo and is not tracked here.
"""
from __future__ import annotations

import datetime as dt
import hashlib
import json
import subprocess
from pathlib import Path

STATE_DIR = ".state"
MANIFEST_NAME = "manifest.json"
RENDER_LOCK = "render.lock"
LAST_UPDATE = "last-update.md"


def state_dir(vault_path) -> Path:
    return Path(vault_path) / STATE_DIR


def sha256_file(p) -> str:
    return hashlib.sha256(Path(p).read_bytes()).hexdigest()


def source_build_info(root=None) -> dict:
    """Best-effort git identity of the Alexandria SOURCE checkout this engine runs from,
    so a rendered vault records the exact **commit** it was built from (version-by-commit),
    not just the hand-set semver in config. In CI the update workflow ``pip install -e``'s a
    fresh clone at ``source_ref``, so HEAD there is precisely the deployed commit.

    Falls back to the package ``__version__`` with null commit fields when the software is
    not a git checkout (e.g. installed from a tarball) — callers still get software_version."""
    from . import __version__
    base = Path(root) if root else Path(__file__).resolve().parents[1]
    info = {"software_version": __version__, "source_commit": None,
            "source_commit_short": None, "source_ref": None, "source_dirty": None}

    def _git(*args):
        try:
            r = subprocess.run(["git", "-C", str(base), *args],
                               capture_output=True, text=True, timeout=5)
            return r.stdout.strip() if r.returncode == 0 else None
        except Exception:
            return None

    # A zip-installed app sits INSIDE the vault's own git repo with no .git of
    # its own — a bare `git -C app` climbs to the vault and would report the
    # OWNER'S vault commit as the software's build identity. Only trust git when
    # the discovered toplevel IS the software root.
    top = _git("rev-parse", "--show-toplevel")
    if not top or Path(top).resolve() != base.resolve():
        return info
    commit = _git("rev-parse", "HEAD")
    if commit:
        info["source_commit"] = commit
        info["source_commit_short"] = commit[:12]
        info["source_ref"] = _git("rev-parse", "--abbrev-ref", "HEAD")
        porcelain = _git("status", "--porcelain")
        info["source_dirty"] = bool(porcelain) if porcelain is not None else None
    return info


def config_fingerprint(config) -> str:
    """A stable hash of the config that the templates actually read.

    Key-sorted so a rewrite that only reorders keys is not mistaken for a change
    the owner made."""
    blob = json.dumps(config or {}, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(blob.encode("utf-8")).hexdigest()


def write_state(vault, written, config, template_ref, *, stamp=None, build=None):
    """Write .state/manifest.json (path/bucket/sha per rendered file) + render.lock.

    render.lock records the **source identity** of this render — the git commit of the
    Alexandria checkout it ran from (``build``; computed via :func:`source_build_info` if
    not supplied) — so ``alexandria status`` and the rendered ``Agent/Version.md`` can say
    exactly what the vault is running, by commit."""
    vault = Path(vault)
    sd = state_dir(vault)
    sd.mkdir(parents=True, exist_ok=True)
    entries = []
    for w in written:
        fp = vault / w["path"]
        entries.append({
            "path": w["path"],
            "bucket": w["bucket"],
            "sha256": sha256_file(fp) if fp.exists() else None,
        })
    (sd / MANIFEST_NAME).write_text(
        json.dumps({"files": entries}, indent=2) + "\n", encoding="utf-8")
    if build is None:
        build = source_build_info()
    alex = config.get("alexandria") or {}
    # The rendering engine's own version is authoritative. The config's hand-set
    # version/template_ref are literals nobody updates (they drift — e.g. a vault built
    # by v2.2 still claiming v0.1), so derive both from software_version and only fall
    # back to the config value when the engine somehow has none.
    sw = build.get("software_version")
    (sd / RENDER_LOCK).write_text(json.dumps({
        # What the Core was rendered FROM, not just which engine rendered it. The
        # boot-time updater skips the render when the version is unchanged, which
        # is right for the engine but was wrong for the config: an owner who
        # renamed their agent, added an area, or turned on a feature changed every
        # Jinja input and got no re-render until the next release happened to bump
        # the version. Comparing this fingerprint makes "the config moved" a reason
        # to re-render in its own right.
        "config_sha256": config_fingerprint(config),
        "template_ref": f"v{sw}" if sw else template_ref,
        "version": sw or alex.get("version"),
        "software_version": sw,
        "source_repo": alex.get("source_repo"),
        "source_ref": build.get("source_ref") or alex.get("source_ref"),
        "source_commit": build.get("source_commit"),
        "source_commit_short": build.get("source_commit_short"),
        "source_dirty": build.get("source_dirty"),
        "rendered_at": stamp,
    }, indent=2) + "\n", encoding="utf-8")
    return entries


def load_manifest(vault) -> dict:
    p = state_dir(vault) / MANIFEST_NAME
    if not p.exists():
        return {}
    return json.loads(p.read_text(encoding="utf-8"))


def drift(vault) -> list:
    """Core files whose on-disk sha differs from the manifest (i.e. hand-edited)."""
    out = []
    for e in load_manifest(vault).get("files", []):
        if e.get("bucket") != "core" or not e.get("sha256"):
            continue
        fp = Path(vault) / e["path"]
        if fp.exists() and sha256_file(fp) != e["sha256"]:
            out.append(e["path"])
    return out
