"""``materialize`` — decrypt the store into the bare-value ``my_stuff/`` cache.

The reconcile step that lets every existing consumer keep reading the SAME files
(zero-breakage): for each declared ``local-file`` surface it writes a **bare-value**
file at the exact registry path, enforcing the declared file mode (0600) and forcing
each parent directory to 0700. It **never deletes anything** — undeclared files
(including ``.bak``/``old_*`` leftovers) are reported for a deliberate human prune,
never removed by the tool (SPEC §4, §5).

The **plan is key-free**: computing what WOULD be written needs no secret key and
reads no value — only ``--apply`` decrypts. So a plan against the real registry
shows the full id→path map with zero values and zero writes (the AC-6 import plan).

Doc-form death (SPEC §5): the materialized file is the bare value only; the username /
token-name / notes that used to share a doc-form file now live in the registry entry,
so the ``cat``-a-doc leak class becomes structurally impossible.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any

from .fsutil import octal_mode as _octal
from .store import cipher_path, store_get

if TYPE_CHECKING:
    from collections.abc import Sequence

    from .registry import Registry

#: File-name shapes the doctor's hygiene check flags as prune-worthy leftovers.
_LEAK_SUFFIXES = (".bak",)
_LEAK_PREFIXES = ("old_", "old-")

#: Plan statuses. ``create``/``chmod`` are the only ones ``--apply`` acts on.
STATUS_CREATE = "create"  # target absent → will write
STATUS_CHMOD = "chmod"  # present but mode != declared → will rewrite + chmod
STATUS_STALE = "stale"  # present at the declared mode but CONTENT != the store → will rewrite
STATUS_PRESENT = "present"  # present, right mode, content verified equal to the store
STATUS_UNVERIFIED = "unverified"  # present at the declared mode; no key, so content NOT compared
STATUS_HELD = "held"  # entry state=held → refused (never materialized)
STATUS_NO_STORE = "no-store"  # no ciphertext yet → nothing to materialize (import first)

_PENDING = frozenset({STATUS_CREATE, STATUS_CHMOD, STATUS_STALE})


@dataclass(frozen=True)
class PlanItem:
    """One declared ``local-file`` target and what materialize would do with it."""

    id: str
    repo: str
    rel: str
    declared_mode: str
    target: Path
    state: str
    store_present: bool
    actual_mode: str | None
    status: str

    @property
    def pending(self) -> bool:
        return self.status in _PENDING

    def to_json(self) -> dict[str, Any]:
        return {
            "id": self.id,
            "repo": self.repo,
            "path": self.rel,
            "target": str(self.target),
            "declared_mode": self.declared_mode,
            "actual_mode": self.actual_mode,
            "state": self.state,
            "store_present": self.store_present,
            "status": self.status,
            "pending": self.pending,
        }


@dataclass
class ApplyResult:
    """The outcome of ``materialize --apply`` — metadata only, never a value."""

    written: list[str] = field(default_factory=list)
    refused_held: list[str] = field(default_factory=list)
    skipped_no_store: list[str] = field(default_factory=list)
    leftovers: list[str] = field(default_factory=list)

    def to_json(self) -> dict[str, Any]:
        return {
            "written": sorted(self.written),
            "refused_held": sorted(self.refused_held),
            "skipped_no_store": sorted(self.skipped_no_store),
            "leftovers": sorted(self.leftovers),
        }


def _target(fed_root: Path, repo: str, rel: str) -> Path:
    return fed_root / repo / "my_stuff" / rel


def plan(
    registry: Registry,
    *,
    fed_root: str | Path,
    root: str | Path,
    only: Sequence[str] | None = None,
    identity_path: str | Path | None = None,
) -> list[PlanItem]:
    """What each declared ``local-file`` surface would materialize to.

    Writes nothing. ``only`` restricts to the given ids.

    Content is compared against the store when a decryption key is usable, so a cache
    holding a **stale** (e.g. just-rotated-away) value is reported ``stale`` and is
    pending, not ``present`` (FEEDBACK PAPER-01: status used to be existence + mode
    only, so a file holding a revoked credential was indistinguishable from a current
    one). When no key is available the plan stays key-free and says ``unverified`` —
    never ``present``, because a check that did not run proves nothing.
    """
    fed = Path(fed_root)
    wanted = set(only) if only else None
    items: list[PlanItem] = []
    for entry in registry.with_surface("local-file"):
        if wanted is not None and entry.id not in wanted:
            continue
        for s in entry.surfaces_of("local-file"):
            repo = str(s["repo"])
            rel = str(s["path"])
            declared_mode = str(s["mode"])
            target = _target(fed, repo, rel)
            actual = _octal(target) if target.exists() else None
            store_present = cipher_path(root, entry.id).is_file()
            status = _status(
                entry.state,
                store_present,
                target,
                actual,
                declared_mode,
                entry_id=entry.id,
                root=root,
                identity_path=identity_path,
            )
            items.append(
                PlanItem(
                    id=entry.id,
                    repo=repo,
                    rel=rel,
                    declared_mode=declared_mode,
                    target=target,
                    state=entry.state,
                    store_present=store_present,
                    actual_mode=actual,
                    status=status,
                )
            )
    return sorted(items, key=lambda i: (i.id, str(i.target)))


def _status(
    state: str,
    store_present: bool,
    target: Path,
    actual_mode: str | None,
    declared_mode: str,
    *,
    entry_id: str,
    root: str | Path,
    identity_path: str | Path | None = None,
) -> str:
    if state == "held":
        return STATUS_HELD
    if not store_present:
        return STATUS_NO_STORE
    if not target.exists():
        return STATUS_CREATE
    if actual_mode is not None and actual_mode.lstrip("0") != declared_mode.lstrip("0"):
        return STATUS_CHMOD
    return _content_status(entry_id, target, root=root, identity_path=identity_path)


def _content_status(
    entry_id: str,
    target: Path,
    *,
    root: str | Path,
    identity_path: str | Path | None = None,
) -> str:
    """``present`` iff the file's bytes equal the store's plaintext; ``stale`` if they
    differ; ``unverified`` when no usable key makes the comparison possible.

    Never returns the value or puts it in an exception message (AG-18) — only the
    verdict escapes. A missing/locked identity is not an error here: the plan simply
    degrades to ``unverified``, which is honest, whereas ``present`` would be a lie.
    """
    try:
        want = store_get(entry_id, root=root, identity_path=identity_path)
        have = target.read_bytes()
    except Exception:  # no key, unreadable file, wrong recipients — cannot compare
        return STATUS_UNVERIFIED
    return STATUS_PRESENT if have == want else STATUS_STALE


def _enforce_parent_dirs(fed_root: Path, repo: str, target: Path) -> None:
    """Create + force every dir from ``my_stuff`` down to the target's parent to 0700."""
    base = fed_root / repo / "my_stuff"
    base.mkdir(parents=True, exist_ok=True)
    base.chmod(0o700)
    # chmod each intermediate directory between base and the target's parent.
    rel_parent = target.parent.relative_to(base)
    cur = base
    for part in rel_parent.parts:
        cur = cur / part
        cur.mkdir(exist_ok=True)
        cur.chmod(0o700)


def apply(
    items: Sequence[PlanItem],
    *,
    fed_root: str | Path,
    root: str | Path,
    identity_path: str | Path | None = None,
) -> ApplyResult:
    """Write bare-value files for every non-held, store-present planned item.

    Enforces the declared mode (0600) and forces parent dirs to 0700. NEVER deletes
    anything; ``.bak``/``old_*`` leftovers under the touched ``my_stuff`` trees are
    reported for a deliberate prune. AG-18: decrypted values go only to their target
    file — never logged.
    """
    fed = Path(fed_root)
    result = ApplyResult()
    touched_repos: set[str] = set()
    for item in items:
        touched_repos.add(item.repo)
        if item.status == STATUS_HELD:
            result.refused_held.append(item.id)
            continue
        if item.status == STATUS_NO_STORE:
            result.skipped_no_store.append(item.id)
            continue
        # Decrypt and write the bare value at the declared mode.
        value = store_get(item.id, root=root, identity_path=identity_path)
        _enforce_parent_dirs(fed, item.repo, item.target)
        mode = int(item.declared_mode, 8)
        import os

        fd = os.open(item.target, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode)
        try:
            os.write(fd, value)
        finally:
            os.close(fd)
        item.target.chmod(mode)
        result.written.append(item.id)
    result.leftovers = _scan_leftovers(fed, touched_repos)
    return result


def _scan_leftovers(fed_root: Path, repos: set[str]) -> list[str]:
    out: list[str] = []
    for repo in sorted(repos):
        base = fed_root / repo / "my_stuff"
        if not base.is_dir():
            continue
        for p in sorted(base.rglob("*")):
            if not p.is_file():
                continue
            n = p.name
            if n.endswith(_LEAK_SUFFIXES) or n.startswith(_LEAK_PREFIXES):
                out.append(str(p.relative_to(fed_root)))
    return out
