#!/usr/bin/env python3
"""upgrade.py — the framework's update hook as a COMPUTATION.

Mechanical since spec/protocol-2.md Amendment 4 (canon reconcile =
overwrite + re-apply declared divergences) and the hex-only move
(Amendment 7): install/reconcile the canon into docs/, materialize the
agent skills into the harness, re-pin, and LIST pending migrations
(migration docs are agent/human instructions — printed, never
improvised). No three-way merge, no reconstruction of intent, no
second distribution channel.

Roots resolve across all three habitats (repo checkout / installed
wheel / hex priv), same pattern as the canon resolver in check_join.
"""

import os
import re
import shutil
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)

import check_join  # noqa: E402

DDS_SECTION_MARK = "<!-- dds:carried-section -->"

# carried canon file → repo destination (enforced verbatim-modulo-divergences)
CANON_TABLE = {
    "principles.md": "docs/principles.md",
    "conventions.md": "docs/conventions.md",
    "registers.md": "docs/registers.md",
    "claim-files.md": "docs/claim-files.md",
    "docs/README.md": "docs/README.md",
    "docs/adr-README.md": "docs/adr/README.md",
    "docs/goals-README.md": "docs/goals/README.md",
    "docs/domain-README.md": "docs/domain/README.md",
}
# starting structure, human-owned after install — never reconciled
STUB_TABLE = {
    "docs/what-the-product-is.stub.md": "docs/domain/what-the-product-is.md",
    "docs/risks-README.md": "docs/risks/README.md",
    "docs/governance.stub.md": "docs/governance.md",
}
# mixed-pen / placeholder files: installed once, appended, or reported
SPECIAL = {
    "AGENTS.md": "AGENTS.md",
    "CLAUDE.md": "CLAUDE.md",
    "CODEOWNERS": ".github/CODEOWNERS",
}


def _first_dir(cands):
    return next((p for p in cands if os.path.isdir(p)), None)


def canon_root():
    return _first_dir([
        os.path.join(HERE, "..", "..", "dds-sync", "assets"),
        os.path.join(HERE, "canon"),
    ])


def skills_root():
    return _first_dir([
        os.path.join(HERE, "..", "..", ".."),  # repo: .apm/skills/../.. → .apm? no: HERE=…/dds-check/assets → ../../.. = .apm
        os.path.join(HERE, "skills"),
    ])


def _real_skills_root():
    # repo layout: HERE/../../ == .apm/skills ; bundle layout: HERE/skills
    repo = os.path.normpath(os.path.join(HERE, "..", ".."))
    if os.path.basename(repo) == "skills" and os.path.isdir(repo):
        return repo
    bundled = os.path.join(HERE, "skills")
    return bundled if os.path.isdir(bundled) else None


def migrations_root():
    return _first_dir([
        os.path.normpath(os.path.join(HERE, "..", "..", "dds-upgrade", "migrations")),
        os.path.join(HERE, "migrations"),
    ])


def bundle_version():
    for cand in [
        os.path.join(HERE, "VERSION"),
        os.path.normpath(os.path.join(HERE, "..", "..", "..", "..", "apm.yml")),
    ]:
        if os.path.isfile(cand):
            text = open(cand, encoding="utf-8").read()
            m = re.search(r"version:\s*(\S+)", text) if cand.endswith(".yml") else None
            return m.group(1) if m else text.strip().splitlines()[0]
    try:
        from importlib.metadata import version
        return version("dds-toolchain")
    except Exception:
        return "unknown"


def read(path):
    try:
        return open(path, encoding="utf-8").read()
    except OSError:
        return None


def write(path, text):
    os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
    with open(path, "w", encoding="utf-8") as f:
        f.write(text)


# ── canon: install / reconcile ───────────────────────────────────────


def apply_local_slots(carried, local):
    """Splice the repo's local-slot contents into the new carried text
    (keyed by the full open marker, which carries the slot name)."""
    locals_ = {m.group(1): m.group(2) for m in check_join.Check.LOCAL_SLOT.finditer(local)}

    def sub(m):
        return m.group(1) + locals_.get(m.group(1), m.group(2)) + m.group(3)

    return check_join.Check.LOCAL_SLOT.sub(sub, carried)


def sync_meta(repo, croot, report):
    """The carried meta-register: install missing files verbatim;
    reconcile existing ones (new carried body, LOCAL SLOTS preserved)."""
    for rel, carried_rel in check_join.Check.meta_map(croot).items():
        carried = read(os.path.join(croot, carried_rel))
        target = os.path.join(repo, rel)
        local = read(target)
        if local is None:
            write(target, carried)
            report.append(f"meta       {rel} (carried — inherited, local slots yours)")
        else:
            merged = apply_local_slots(carried, local)
            if merged != local:
                write(target, merged)
                report.append(f"meta       {rel} reconciled (local slots preserved)")


def install_canon(repo, croot, report):
    for src, dst in CANON_TABLE.items():
        write(os.path.join(repo, dst), read(os.path.join(croot, src)))
        report.append(f"installed  {dst}")
    for src, dst in STUB_TABLE.items():
        target = os.path.join(repo, dst)
        if os.path.exists(target):
            report.append(f"kept       {dst} (stub exists — human-owned)")
        else:
            write(target, read(os.path.join(croot, src)))
            report.append(f"installed  {dst} (stub — yours to author)")
    for src, dst in SPECIAL.items():
        carried = read(os.path.join(croot, src))
        target = os.path.join(repo, dst)
        existing = read(target)
        if existing is None:
            write(target, carried)
            report.append(f"installed  {dst}")
        elif DDS_SECTION_MARK in existing or existing == carried:
            report.append(f"kept       {dst}")
        else:
            write(target, existing.rstrip() + f"\n\n{DDS_SECTION_MARK}\n\n" + carried)
            report.append(f"appended   {dst} (carried section under marker — human content untouched)")
    report.append("ACTION     .github/CODEOWNERS: substitute your team handles (placeholders installed)")


def reconcile_canon(repo, croot, report):
    """Amendment 4, executed: overwrite with the carried canon,
    re-apply the repo's declared divergence blocks, surface stale
    anchors. Undeclared drift stops the file (never silently lost)."""
    chk = check_join.Check(repo, None, None, None)
    chk.delta_files = set()
    for src, dst in CANON_TABLE.items():
        carried = read(os.path.join(croot, src))
        local = read(os.path.join(repo, dst))
        if local is None:
            write(os.path.join(repo, dst), carried)
            report.append(f"installed  {dst} (was missing)")
            continue
        chk.findings = []
        blocks, whole = chk.parse_divergences(dst, local)
        if whole:
            report.append(f"kept       {dst} (whole-file divergence declared — wholly local)")
            continue
        malformed = [f["text"] for f in chk.findings]
        if malformed:
            report.append(f"STOPPED    {dst}: malformed divergence block(s) — fix first: {malformed[0][:80]}")
            continue
        # rebuild what the file SHOULD have been (blocks → anchors), so
        # content beyond the declared divergences is reported, never
        # silently absorbed. (The old canon is unknowable here; git holds
        # the prior state and the diff is the review surface — the CLI
        # never commits.)
        parts, last = [], 0
        for b in sorted(blocks, key=lambda x: x["start"]):
            parts.append(local[last : b["start"]])
            if b["kind"] in ("replace", "delete"):
                parts.append(b["anchor"])
            last = b["end"]
        parts.append(local[last:])
        rebuilt = check_join.norm_ws("".join(parts))
        new_text = carried
        stale = []
        for b in blocks:
            anchor = b["anchor"]
            if anchor in new_text:
                if b["kind"] in ("replace", "delete"):
                    new_text = new_text.replace(anchor, b["raw"], 1)
                else:  # insert-after
                    idx = new_text.index(anchor) + len(anchor)
                    new_text = new_text[:idx] + "\n" + b["raw"] + new_text[idx:]
            else:
                stale.append(b)
        undeclared = rebuilt != check_join.norm_ws(carried)
        write(os.path.join(repo, dst), new_text)
        line = f"reconciled {dst}"
        if blocks:
            line += f" ({len(blocks) - len(stale)} divergence(s) re-applied)"
        report.append(line)
        if undeclared:
            report.append(
                f"NOTE       {dst}: content beyond the declared divergences was "
                "replaced by the new canon (version skew, or undeclared edits) — "
                "review the diff; the prior state is in git"
            )
        for b in stale:
            report.append(
                f"STALE      {dst} · `{b['slug']}`: its anchor no longer appears in the "
                "new canon — the block was NOT re-applied; re-base it onto the new text "
                "or drop it (its text is preserved in git history)"
            )


# ── skills: materialize into the harness ────────────────────────────


def materialize_skills(repo, sroot, report, harness=".claude/skills"):
    names = sorted(
        d for d in os.listdir(sroot)
        if os.path.isdir(os.path.join(sroot, d)) and d.startswith("dds-")
    )
    for name in names:
        src = os.path.join(sroot, name)
        dst = os.path.join(repo, harness, name)
        if os.path.isdir(dst):
            shutil.rmtree(dst)
        shutil.copytree(
            src, dst,
            ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "__init__.py"),
        )
        report.append(f"skill      {harness}/{name}")
    return names


# ── migrations: list, never improvise ────────────────────────────────


def _ver_tuple(v):
    return tuple(int(x) for x in re.findall(r"\d+", v)[:3]) or (0,)


def pinned_version(repo):
    text = read(os.path.join(repo, ".dds-version")) or ""
    m = re.search(r"^version:\s*(\S+)", text, re.M)
    return m.group(1) if m else None


def pending_migrations(repo, mroot, to_version):
    cur = pinned_version(repo)
    out = []
    for name in sorted(os.listdir(mroot)):
        if not name.endswith(".md"):
            continue
        v = name[:-3]
        if cur and _ver_tuple(v) <= _ver_tuple(cur):
            continue
        if _ver_tuple(v) > _ver_tuple(to_version):
            continue
        out.append(os.path.join(mroot, name))
    return out


def write_pin(repo, version):
    write(
        os.path.join(repo, ".dds-version"),
        "# development-driven-specs version pin — managed by `dds upgrade`.\n"
        f"version: {version}\n"
        "protocol: 2\n"
        "source: https://hex.pm/packages/dds (and the git tag of the same version)\n",
    )


# ── the command ──────────────────────────────────────────────────────


def run(repo):
    croot, sroot, mroot = canon_root(), _real_skills_root(), migrations_root()
    version = bundle_version()
    report = []
    if croot is None:
        return ["ERROR: carried canon not found in this bundle"], 2
    fresh = read(os.path.join(repo, "docs/principles.md")) is None
    if fresh:
        install_canon(repo, croot, report)
    else:
        reconcile_canon(repo, croot, report)
    sync_meta(repo, croot, report)
    if sroot:
        materialize_skills(repo, sroot, report)
    else:
        report.append("skills     not bundled here — skipped")
    if fresh:
        report.append("migrations none (a fresh install lands at the current shape)")
    elif mroot:
        pend = pending_migrations(repo, mroot, version)
        for p in pend:
            report.append(f"MIGRATION  {os.path.basename(p)[:-3]}: execute {p}")
        if not pend:
            report.append("migrations none pending")
    write_pin(repo, version)
    report.append(f"pinned     .dds-version → {version} (protocol 2)")
    rc = 2 if any(r.startswith(("STOPPED", "ERROR")) for r in report) else 0
    return report, rc
