"""Path helpers + environment fingerprinting.

Vault layout:

    system/evolution/
    ├── status.md
    ├── reflexes/
    │   └── {reflex_id}.md
    ├── anti-reflexes/
    │   └── {anti_id}.md
    ├── promotions/
    │   └── {YYYY-MM-DD}-{reflex_id}-{promotion_id}.md
    └── reports/
        └── drift-{YYYY-MM-DD}.md

All writes go through VaultWriter with ``trust_reason='evolution_persist'``
so the runtime guard passes without forcing every enclosing scope to
authorize a specific contract_action. This follows the CLAUDE.md pattern
for internal-bookkeeping writes that are side-effects of a higher-level
decision already authorized upstream.

``env_fingerprint()`` is a cheap 5-minute cached snapshot of the
environment dimensions that affect a run: vault content hash, graph
revision id, tool registry hash, settings hash, contract version. A run
can later be compared to other runs by dict-diffing env_fingerprint.
"""

from __future__ import annotations

import hashlib
import logging
import time
from pathlib import Path
from typing import Any

log = logging.getLogger(__name__)


# ── Vault path constants ──
EVOLUTION_ROOT = "system/evolution"
REFLEX_DIR = f"{EVOLUTION_ROOT}/reflexes"
ANTI_REFLEX_DIR = f"{EVOLUTION_ROOT}/anti-reflexes"
PROMOTION_DIR = f"{EVOLUTION_ROOT}/promotions"
REPORTS_DIR = f"{EVOLUTION_ROOT}/reports"
STATUS_PATH = f"{EVOLUTION_ROOT}/status.md"


def reflex_artifact_path(reflex_id: str, kind: str = "reflex") -> str:
    """Return the logical vault path for a reflex's readable artifact."""
    base = ANTI_REFLEX_DIR if kind == "anti" else REFLEX_DIR
    return f"{base}/{reflex_id}.md"


def promotion_artifact_path(promotion_id: str, reflex_id: str, date_iso: str) -> str:
    """Return the logical vault path for a promotion decision record.

    ``date_iso`` is the YYYY-MM-DD prefix; filename includes reflex_id
    and promotion_id so multiple promotions on the same day are
    distinguishable.
    """
    day = (date_iso or "")[:10]
    return f"{PROMOTION_DIR}/{day}-{reflex_id}-{promotion_id}.md"


def drift_report_path(date_iso: str) -> str:
    day = (date_iso or "")[:10]
    return f"{REPORTS_DIR}/drift-{day}.md"


# ── env_fingerprint — cached for 5 minutes ──

_FP_CACHE: dict[str, Any] = {"ts": 0.0, "value": {}}
_FP_TTL_SECONDS: int = 300


def env_fingerprint(
    *,
    vault_reader: object | None = None,
    graph: object | None = None,
    registry: object | None = None,
    settings: object | None = None,
    now: float | None = None,
) -> dict[str, str]:
    """Cheap snapshot of the environment dimensions that affect a run.

    Cached for ``_FP_TTL_SECONDS`` (5 minutes) — we don't need to rehash
    the entire vault on every invoke_skill call. Returns a dict of short
    hex digests + a few scalar metadata fields, safe to JSON-serialize.

    Pass ``now=`` to force cache invalidation in tests.

    Keys returned (all strings):
        vault_sha         — sha256 of sorted (path, mtime, size) tuples in system/identity/ + system/skills/
        graph_rev         — short fingerprint of the knowledge graph revision
        tool_registry_sha — sha256 of sorted tool names + their contract_actions
        settings_sha      — sha256 of relevant settings (llm mode, model tier, profile)
        contract_version  — CONTRACT.md mtime as ISO string (proxy for version)

    Any source that fails to introspect degrades to ``"unknown"`` —
    fingerprinting never crashes the run.
    """
    now_t = now if now is not None else time.time()
    if now_t - _FP_CACHE["ts"] < _FP_TTL_SECONDS and _FP_CACHE["value"]:
        return dict(_FP_CACHE["value"])

    out: dict[str, str] = {
        "vault_sha": "unknown",
        "graph_rev": "unknown",
        "tool_registry_sha": "unknown",
        "settings_sha": "unknown",
        "contract_version": "unknown",
    }

    # Vault fingerprint: sha of (path, mtime, size) in identity + skill dirs.
    # We deliberately don't hash contents — that would be O(N_files × size)
    # per call. Mtime + size catches every real change in practice.
    if vault_reader is not None:
        try:
            vault_root = getattr(vault_reader, "vault_dir", None)
            if vault_root is not None:
                out["vault_sha"] = _sha_of_dirs(Path(vault_root), [
                    "system/identity", "system/skills",
                ])
        except Exception:
            log.debug("vault fingerprint failed", exc_info=True)

    # Graph revision: prefer a revision counter if the graph exposes one;
    # fall back to triple count + last-update timestamp as a proxy.
    if graph is not None:
        try:
            rev = getattr(graph, "_revision", None) or getattr(graph, "revision", None)
            if rev:
                out["graph_rev"] = str(rev)
            else:
                count = len(getattr(graph, "_triples", []) or [])
                last = getattr(graph, "_last_update", "") or ""
                out["graph_rev"] = _short_sha(f"{count}|{last}")
        except Exception:
            log.debug("graph fingerprint failed", exc_info=True)

    # Tool registry: sorted tool names + their contract_actions.
    if registry is not None:
        try:
            tools = getattr(registry, "_tools", {})
            if tools:
                payload = "|".join(
                    f"{name}:{getattr(t, 'contract_action', '')}"
                    for name, t in sorted(tools.items())
                )
                out["tool_registry_sha"] = _short_sha(payload)
        except Exception:
            log.debug("tool registry fingerprint failed", exc_info=True)

    # Settings: the dimensions that affect routing (llm mode, model tier).
    if settings is not None:
        try:
            llm = getattr(settings, "llm", None)
            if llm is not None:
                parts = [
                    f"mode={getattr(llm, 'mode', '')}",
                    f"cloud_provider={getattr(llm, 'cloud_provider', '')}",
                    f"cloud_model={getattr(llm, 'cloud_model', '')}",
                    f"local_model={getattr(llm, 'local_model', '')}",
                ]
                out["settings_sha"] = _short_sha("|".join(parts))
        except Exception:
            log.debug("settings fingerprint failed", exc_info=True)

    # Contract version: CONTRACT.md mtime as ISO string. Cheap and
    # reliable — every user edit to the contract changes the mtime.
    if vault_reader is not None:
        try:
            vault_root = getattr(vault_reader, "vault_dir", None)
            if vault_root is not None:
                contract = Path(vault_root) / "system" / "identity" / "CONTRACT.md"
                if contract.is_file():
                    out["contract_version"] = str(int(contract.stat().st_mtime))
        except Exception:
            log.debug("contract fingerprint failed", exc_info=True)

    _FP_CACHE["ts"] = now_t
    _FP_CACHE["value"] = dict(out)
    return out


def invalidate_env_fingerprint_cache() -> None:
    """Force the next ``env_fingerprint`` call to recompute — for tests."""
    _FP_CACHE["ts"] = 0.0
    _FP_CACHE["value"] = {}


# ── Internals ──


def _short_sha(payload: str) -> str:
    return hashlib.sha256(payload.encode("utf-8", "replace")).hexdigest()[:16]


def _sha_of_dirs(root: Path, relatives: list[str]) -> str:
    """Fingerprint a set of subdirectories by (path, mtime, size) tuples.

    Sorted deterministically so the fingerprint is stable across OS /
    filesystem ordering differences.
    """
    entries: list[str] = []
    for rel in relatives:
        subdir = root / rel
        if not subdir.exists():
            continue
        try:
            for p in sorted(subdir.rglob("*")):
                if p.is_file():
                    try:
                        st = p.stat()
                        entries.append(f"{p.relative_to(root).as_posix()}|{int(st.st_mtime)}|{st.st_size}")
                    except OSError:
                        continue
        except Exception:
            continue
    if not entries:
        return "empty"
    return _short_sha("\n".join(entries))
