"""Persistent file-index for the knowledge-graph build (Phase 2).

This module is the foundation for the incremental graph build refactor
described in ``tasks/plan-incremental-graph-build.md``. Phase 2 ships a
fully-implemented ``BuildIndex`` with load/save/diff/mutation but does NOT
wire it into ``KnowledgeGraph.build()`` — that's Phase 3.

Why a separate module: importing ``build_index`` MUST NOT pull in
``graph.py``. The one-way dependency lets us unit-test the index in
isolation (no NetworkX, no embeddings, no LLM) and lets future tools
(``agntspace doctor``, vault inspectors) read the index without bringing
the whole graph subsystem online.

Index file shape (lives at ``<index_path>``, typically
``<vault>/agntspace/memory/graph/build_index.json``):

.. code-block:: json

    {
      "schema_version": 1,
      "build_completed_at": "2026-04-25T14:32:11Z",
      "code_fingerprint": "sha256-of-build-pipeline-tag",
      "files": {
        "knowledge/general/wiki/concepts/ontology.md": {
          "mtime_ns": 1745605931214700800,
          "size": 4821,
          "sha256": "9c3f...ab12",
          "scanned_at": "2026-04-25T14:32:09Z",
          "contribution": {
            "wiki_links": 12,
            "structural_triples_emitted": 0,
            "extracted_triple_ids": []
          }
        }
      }
    }

Field rationale lives in the plan; do not duplicate here.

Phase boundaries:

- **Phase 1:** type definitions + class surface only; method bodies raise
  ``NotImplementedError``. (Shipped 2026-04-25.)
- **Phase 2 (this revision):** implement ``load`` / ``save`` / ``diff`` /
  ``record_*`` / ``compute_code_fingerprint`` with full unit-test coverage.
  Still unused by ``build()``.
- **Phase 3:** wire into ``KnowledgeGraph.build()`` behind a feature flag
  (``AGNTSPACE_GRAPH_INCREMENTAL``). Default on.
- **Phase 4-7:** persist wiki-link snapshot, compose with structural
  retraction, observability + migration, CI convergence invariant.
"""

from __future__ import annotations

import functools
import hashlib
import importlib
import inspect
import json
import logging
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path

log = logging.getLogger(__name__)


# Bumped when the on-disk index format changes in a way that invalidates
# existing entries. Any change to extraction semantics (new entity-type
# heuristic, new structural predicate, change to scan prefix set, etc.)
# that is NOT directly reflected in the source of the methods listed in
# CODE_FINGERPRINT_TARGETS should bump this. A mismatch on load forces a
# silent full rebuild.
SCHEMA_VERSION = 1


# Methods whose source is hashed into ``code_fingerprint`` so a refactor
# that changes derivation logic — but doesn't bump SCHEMA_VERSION — also
# triggers a one-time full rebuild. Kept as a tuple of fully-qualified
# attribute names rather than function objects to keep this module free of
# any ``graph`` import at module-load time. Resolution happens lazily
# inside ``compute_code_fingerprint``.
CODE_FINGERPRINT_TARGETS: tuple[str, ...] = (
    "agntspace.memory.graph.KnowledgeGraph.build",
    "agntspace.memory.graph.KnowledgeGraph._scan_files_full",
    "agntspace.memory.graph.KnowledgeGraph._collect_structural_records",
    "agntspace.memory.graph.KnowledgeGraph._scan_for_registry",
    "agntspace.memory.graph.KnowledgeGraph._overlay_structural_triples",
    "agntspace.memory.graph.KnowledgeGraph._overlay_spo_edges",
    "agntspace.memory.graph.KnowledgeGraph._cross_reference_triples",
    "agntspace.memory.graph.KnowledgeGraph._merge_duplicate_nodes",
    "agntspace.memory.graph.KnowledgeGraph.rebuild_registry_from_files",
)


@dataclass(frozen=True)
class FileStat:
    """Captured filesystem metadata for a single tracked file.

    ``mtime_ns`` is captured via ``os.stat().st_mtime_ns`` (nanosecond
    precision). ``size`` is ``st_size`` in bytes. ``sha256`` is computed
    only when the index actually re-processes the file — never recomputed
    on every boot, since that would defeat the optimization.
    """

    mtime_ns: int
    size: int
    sha256: str = ""


@dataclass
class FileContribution:
    """Diagnostic counters describing what a file contributed at scan time.

    Used by ``GET /api/graph/stats`` to surface "this file produced N
    triples" without scanning the entire triples list. Triple-level
    retraction during file deletion uses ``triple["source_files"]``
    (tracked elsewhere), NOT this dict.
    """

    wiki_links: int = 0
    structural_triples_emitted: int = 0
    extracted_triple_ids: list[int] = field(default_factory=list)


@dataclass
class BuildDelta:
    """Result of diffing a freshly-stat'd vault against the persisted index.

    - ``added``: path is present in current_paths, absent from index.
    - ``modified``: path is in both, but ``(mtime_ns, size)`` differs. The
      caller is responsible for deciding whether to re-hash the file: if
      the stored sha256 still matches the new content, only the mtime entry
      needs to be refreshed (cheap update, no re-extraction).
    - ``unchanged``: path is in both, ``(mtime_ns, size)`` matches. Skip
      entirely.
    - ``deleted``: path is in the index, absent from current_paths. Caller
      should retract the triples this file contributed.
    """

    added: list[str] = field(default_factory=list)
    modified: list[str] = field(default_factory=list)
    unchanged: list[str] = field(default_factory=list)
    deleted: list[str] = field(default_factory=list)


def _now_iso() -> str:
    """ISO-8601 UTC timestamp with second precision."""
    return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")


@functools.cache
def compute_code_fingerprint() -> str:
    """SHA-256 over the source of every method in CODE_FINGERPRINT_TARGETS.

    Resolution is fully lazy: the targets are stored as fully-qualified
    string names, and ``importlib.import_module`` is only called when this
    function actually runs. ``build_index`` itself never imports ``graph``
    at module-load time.

    A fingerprint mismatch on ``load()`` triggers a full rebuild — cheap
    insurance against forgetting to bump ``SCHEMA_VERSION`` after a
    refactor that changes derivation logic.

    Cached via ``functools.lru_cache``. Tests can invalidate via
    ``compute_code_fingerprint.cache_clear()``.

    Targets that are missing or unreadable get an explicit ``MISSING:``
    sentinel in the hashed payload — the resulting fingerprint will differ
    from any prior one, which forces a full rebuild rather than a crash.
    """
    parts: list[str] = []
    for fqn in CODE_FINGERPRINT_TARGETS:
        # fqn = "agntspace.memory.graph.KnowledgeGraph.build"
        # → module_path = "agntspace.memory.graph"
        # → class_name = "KnowledgeGraph"
        # → method_name = "build"
        try:
            module_path, class_name, method_name = fqn.rsplit(".", 2)
            mod = importlib.import_module(module_path)
            cls = getattr(mod, class_name)
            method = getattr(cls, method_name)
            src = inspect.getsource(method)
            parts.append(f"{fqn}\n{src}")
        except (ValueError, ImportError, AttributeError, OSError, TypeError) as exc:
            # Tracked method missing or unreadable — fingerprint changes,
            # which forces a full rebuild. Better than crashing.
            parts.append(f"{fqn}\nMISSING: {type(exc).__name__}: {exc}")

    combined = "\n----\n".join(parts).encode("utf-8")
    return hashlib.sha256(combined).hexdigest()


class BuildIndex:
    """Persistent map of ``(file_path -> FileStat + FileContribution)``.

    The on-disk file lives at the ``index_path`` passed to the constructor.
    The caller (typically the future Phase 3 wiring code in ``graph.py``)
    is responsible for resolving the logical vault path
    ``"memory/graph/build_index.json"`` to a physical ``Path`` via
    ``VaultPaths`` before constructing — keeping ``BuildIndex`` itself free
    of any vault-internal dependencies.

    Read failures (missing, corrupt, schema mismatch, fingerprint mismatch)
    all degrade silently to a fresh in-memory state and trigger a full
    rebuild on the next ``build()`` call.
    """

    def __init__(
        self,
        index_path: Path,
        *,
        schema_version: int = SCHEMA_VERSION,
    ) -> None:
        self.index_path = Path(index_path)
        self.schema_version = schema_version
        self.code_fingerprint: str = ""
        self.build_completed_at: str = ""
        self.files: dict[str, dict] = {}
        # ``is_fresh`` is True when load() failed or no index existed yet.
        # Phase 3 reads this to decide between full and incremental rebuild.
        self.is_fresh: bool = True

    # ------------------------------------------------------------------
    # Persistence
    # ------------------------------------------------------------------

    def load(self) -> BuildIndex:
        """Read the index from disk, populate self in place.

        On any error (missing file, corrupt JSON, schema mismatch,
        fingerprint mismatch) leaves ``is_fresh = True`` and returns self
        unchanged. Logs each failure mode at info level so first-boot-
        after-upgrade is visible in the log.

        Returns self to allow ``BuildIndex(path).load()`` chaining.
        """
        if not self.index_path.is_file():
            log.info("BuildIndex: no prior index at %s — full rebuild needed", self.index_path)
            return self

        try:
            raw = self.index_path.read_text(encoding="utf-8")
        except OSError as exc:
            log.info("BuildIndex: cannot read %s (%s) — full rebuild needed", self.index_path, exc)
            return self

        if not raw.strip():
            # Empty file (partial write, never populated) — treat as fresh.
            log.info("BuildIndex: %s is empty — full rebuild needed", self.index_path)
            return self

        try:
            payload = json.loads(raw)
        except json.JSONDecodeError as exc:
            # Genuine corruption. Back it up so repeated corruptions don't
            # collide, then fall through to fresh state. Mirrors
            # _read_compile_state's pattern in kb/service.py.
            log.warning(
                "BuildIndex: %s is corrupted (%s) — backing up and rebuilding",
                self.index_path, exc,
            )
            try:
                stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S")
                backup = self.index_path.with_suffix(f".json.corrupted.{stamp}")
                self.index_path.replace(backup)
            except Exception:
                log.exception("BuildIndex: could not back up corrupted index")
            return self

        if not isinstance(payload, dict):
            log.warning("BuildIndex: %s root is not a dict — full rebuild needed", self.index_path)
            return self

        stored_schema = payload.get("schema_version")
        if stored_schema != self.schema_version:
            log.info(
                "BuildIndex: schema_version mismatch (stored=%s, expected=%s) — full rebuild needed",
                stored_schema, self.schema_version,
            )
            return self

        stored_fingerprint = payload.get("code_fingerprint", "")
        current_fingerprint = compute_code_fingerprint()
        if stored_fingerprint != current_fingerprint:
            log.info(
                "BuildIndex: code_fingerprint mismatch — extraction logic changed, full rebuild needed",
            )
            return self

        files = payload.get("files")
        if not isinstance(files, dict):
            log.warning("BuildIndex: %s files is not a dict — full rebuild needed", self.index_path)
            return self

        # All gates passed — adopt the persisted state.
        self.files = files
        self.code_fingerprint = stored_fingerprint
        self.build_completed_at = payload.get("build_completed_at", "")
        self.is_fresh = False
        log.debug("BuildIndex: loaded %d entries from %s", len(self.files), self.index_path)
        return self

    def save(self, *, build_completed_at: str | None = None) -> None:
        """Atomically persist the current state to disk.

        Writes to ``<index_path>.tmp`` then ``Path.replace()``. Pattern
        mirrors ``kb/service.py:_write_compile_state``. Updates self's
        ``code_fingerprint`` and ``build_completed_at`` (the latter only
        when explicitly passed) so subsequent ``load()`` calls see the
        same shape.
        """
        completed = build_completed_at or self.build_completed_at or _now_iso()
        self.build_completed_at = completed
        self.code_fingerprint = compute_code_fingerprint()

        payload = {
            "schema_version": self.schema_version,
            "build_completed_at": self.build_completed_at,
            "code_fingerprint": self.code_fingerprint,
            "files": self.files,
        }

        # First-run vault won't have memory/graph/ on disk yet.
        self.index_path.parent.mkdir(parents=True, exist_ok=True)

        tmp = self.index_path.with_suffix(self.index_path.suffix + ".tmp")
        try:
            tmp.write_text(
                json.dumps(payload, indent=2, sort_keys=True),
                encoding="utf-8",
            )
            tmp.replace(self.index_path)
        except OSError:
            # Synced drives (OneDrive / iCloud) occasionally break rename
            # atomicity. Same risk profile as triples.json's write path —
            # log and clean up the .tmp without partial-corrupting the
            # existing index.
            log.exception("BuildIndex: failed to write %s", self.index_path)
            if tmp.exists():
                try:
                    tmp.unlink()
                except OSError:
                    log.debug("BuildIndex: could not clean up .tmp at %s", tmp)
            raise

    # ------------------------------------------------------------------
    # Diff
    # ------------------------------------------------------------------

    def diff(self, current_paths: dict[str, FileStat]) -> BuildDelta:
        """Classify every path into added / modified / unchanged / deleted.

        ``current_paths`` is built by stat-ing the vault — typically
        thousands of cheap metadata calls. Diffing then bucketizes per
        the ``BuildDelta`` semantics in this module's docstring.
        """
        delta = BuildDelta()

        current_keys = set(current_paths.keys())
        index_keys = set(self.files.keys())

        for path in current_keys - index_keys:
            delta.added.append(path)

        for path in index_keys - current_keys:
            delta.deleted.append(path)

        for path in current_keys & index_keys:
            stat = current_paths[path]
            entry = self.files[path]
            stored_mtime = entry.get("mtime_ns")
            stored_size = entry.get("size")
            if stored_mtime == stat.mtime_ns and stored_size == stat.size:
                delta.unchanged.append(path)
            else:
                delta.modified.append(path)

        # Sort each bucket for determinism — tests and the build summary
        # both benefit from stable ordering.
        delta.added.sort()
        delta.modified.sort()
        delta.unchanged.sort()
        delta.deleted.sort()
        return delta

    # ------------------------------------------------------------------
    # Mutation
    # ------------------------------------------------------------------

    def record_processed(
        self,
        path: str,
        stat: FileStat,
        contribution: FileContribution,
    ) -> None:
        """Update or insert an entry after a file has been re-processed.

        Sets ``scanned_at`` to the current ISO timestamp.
        """
        self.files[path] = {
            "mtime_ns": stat.mtime_ns,
            "size": stat.size,
            "sha256": stat.sha256,
            "scanned_at": _now_iso(),
            "contribution": {
                "wiki_links": contribution.wiki_links,
                "structural_triples_emitted": contribution.structural_triples_emitted,
                "extracted_triple_ids": list(contribution.extracted_triple_ids),
            },
        }

    def record_deleted(self, path: str) -> dict:
        """Drop an entry whose source file has vanished.

        Returns the dropped contribution dict (empty if the path wasn't
        tracked) so callers can use it for retraction stats
        (``files_retracted`` in the build summary).
        """
        entry = self.files.pop(path, None)
        if entry is None:
            return {}
        return entry.get("contribution") or {}
