#!/usr/bin/env python3
"""Staleness-guarded regeneration of the structural ontology layer.

``refresh(repo_root, ...)`` regenerates ``<ontology>/structural/`` only when the committed
index is stale — missing, or a discovered source file is newer than ``code-graph.json`` — so
callers (the SessionStart hook, ``/session-start``, wave wrapup) can keep the structural layer
current cheaply. The generator is deterministic, so when nothing changed a regeneration
produces byte-identical output: a refresh is always safe even if the guard is bypassed
(``force=True``).

For a ``meta-and-children`` project it also re-aggregates the cross-repo graph from whatever
per-repo indices are present (absent ones skipped). The hand-curated SEMANTIC OVERLAY is never
touched here — that is the ontology_tracker's + ``/ontology-rebuild``'s job.

Stdlib only; no network. Pairs with :mod:`ontology_gen.generate` (the generator) and
:mod:`ontology_gen.aggregate` (the cross-repo roll-up).
"""

from __future__ import annotations

from pathlib import Path

from .generate import discover, generate

_STRUCTURAL_SUBDIR = "structural"
_INDEX_NAME = "code-graph.json"


def is_stale(repo_root: Path, out_dir: Path) -> bool:
    """True if the structural index is missing or older than any discovered source file.

    Cheap: stats discovered source files (no parsing) and compares against the index mtime.
    A fresh checkout where the committed index post-dates its sources reads as fresh; any
    later source edit reads as stale. Unreadable files are skipped (never block on them).
    """
    index = out_dir / _INDEX_NAME
    try:
        index_mtime = index.stat().st_mtime
    except OSError:
        return True
    for rel in discover(repo_root):
        try:
            if (repo_root / rel).stat().st_mtime > index_mtime:
                return True
        except OSError:
            continue
    return False


def refresh(
    repo_root: Path | str,
    *,
    ontology_path: str = "ontology",
    repo_name: str | None = None,
    model: str = "single-repo",
    force: bool = False,
) -> dict:
    """Regenerate the structural index if stale (or ``force``). Returns a status dict.

    Keys: ``regenerated`` (bool), ``status`` (``"fresh"`` | ``"regenerated"``), and on
    regeneration the ``files``/``nodes``/``edges`` counts. For ``meta-and-children`` an
    ``aggregate`` sub-dict carries the cross-repo roll-up counts (best-effort; omitted on
    failure). Never raises for ordinary I/O — callers (hooks) stay fail-open.

    Regeneration barrier (#141): :func:`is_stale` is a deliberately cheap mtime heuristic,
    and under coarse-granularity / same-tick filesystem timing (e.g. a full-suite run) a
    source can read as newer than the index even though nothing changed. The generator is
    deterministic, so when the staleness guard fires we capture the prior index and compare
    it against the regenerated bytes: byte-identical output means the staleness was an mtime
    artifact — we report ``"fresh"`` (and leave the cross-repo roll-up untouched) rather than
    misreporting a phantom regeneration. A ``force`` refresh always reports ``regenerated``.
    """
    repo_root = Path(repo_root)
    out_dir = repo_root / ontology_path / _STRUCTURAL_SUBDIR
    if not force and not is_stale(repo_root, out_dir):
        return {"regenerated": False, "status": "fresh"}

    index_path = out_dir / _INDEX_NAME
    try:
        prior = index_path.read_text(encoding="utf-8")
    except OSError:
        prior = None  # no comparable prior index (first generation) — a real regeneration.

    counts = generate(repo_root, out_dir, repo_name or repo_root.name)

    if not force and prior is not None:
        try:
            if index_path.read_text(encoding="utf-8") == prior:
                # Deterministic output reproduced the committed index byte-for-byte: the
                # source only *looked* newer (coarse/same-tick mtime). Nothing changed.
                return {"regenerated": False, "status": "fresh"}
        except OSError:
            pass  # can't confirm identity — fall through and report a regeneration.

    result: dict = {"regenerated": True, "status": "regenerated", **counts}

    if model == "meta-and-children":
        try:
            from .aggregate import DEFAULT_OUT, write_aggregate

            nodes, edges, statuses = write_aggregate(repo_root, repo_root / DEFAULT_OUT)
            result["aggregate"] = {
                "nodes": nodes,
                "edges": edges,
                "repos": sum(1 for s in statuses if s.present),
            }
        except Exception:
            pass  # aggregation is best-effort; the per-repo index already refreshed.
    return result
