# Copyright (c) 2026 PULSE Contributors. Proprietary. All rights reserved.
"""
Dimension C: Provenance Tracking — traces every knowledge item's journey
from capture through extraction, routing, consolidation, and promotion.

Public API (3 functions, SoC-compliant):
    append_provenance(fingerprint, stage_entry, knowledge_type="md")
    get_provenance(fingerprint)
    trace_by_source(agent, date=None)

Sidecar index: Hippocampus/Knowledge/provenance_index.json
"""

import hashlib
import json
import logging
from pathlib import Path

from pulse.core.brain_utils import KNOWLEDGE_DIR, now_iso
from pulse.core.brain_io import _acquire

logger = logging.getLogger("pulse.brain.provenance")

INDEX_FILE: Path = KNOWLEDGE_DIR / "provenance_index.json"

_EMPTY_INDEX = {
    "entries": {},
    "stats": {"total_tracked": 0},
    "last_updated": "",
}

# Module-level cache — avoids re-reading 3.4MB JSON file on every get_provenance() call
# (133 calls/search × 67ms/call = 8.9s wasted I/O without cache)
_CACHE: dict | None = None
_CACHE_MTIME: float = 0.0


# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------

def _fingerprint(text: str) -> str:
    """MD5 hash of first 80 chars (aligned with brain_search._item_key)."""
    return hashlib.md5(text[:80].lower().strip().encode("utf-8")).hexdigest()[:12]


def _load_index() -> dict:
    """Load sidecar provenance_index.json with mtime cache. Returns empty structure on error."""
    global _CACHE, _CACHE_MTIME
    if not INDEX_FILE.exists():
        _CACHE = None
        return json.loads(json.dumps(_EMPTY_INDEX))
    try:
        mtime = INDEX_FILE.stat().st_mtime
        if _CACHE is not None and mtime == _CACHE_MTIME:
            return _CACHE
        with open(INDEX_FILE, "r", encoding="utf-8") as f:
            data = json.load(f)
        if not isinstance(data, dict):
            return json.loads(json.dumps(_EMPTY_INDEX))
        data.setdefault("entries", {})
        data.setdefault("stats", {"total_tracked": 0})
        data.setdefault("last_updated", "")
        _CACHE = data
        _CACHE_MTIME = mtime
        return data
    except (json.JSONDecodeError, OSError):
        logger.debug("Failed to load provenance index, using empty", exc_info=True)
        return json.loads(json.dumps(_EMPTY_INDEX))


def _save_index(data: dict) -> None:
    """Save provenance index with filelock for concurrent-agent safety."""
    global _CACHE, _CACHE_MTIME
    _CACHE = None  # invalidate cache on write
    _CACHE_MTIME = 0.0
    INDEX_FILE.parent.mkdir(parents=True, exist_ok=True)
    tmp = INDEX_FILE.with_suffix(".tmp")
    try:
        with _acquire(INDEX_FILE):
            data["last_updated"] = now_iso()
            data["stats"]["total_tracked"] = len(data.get("entries", {}))
            with open(tmp, "w", encoding="utf-8") as f:
                json.dump(data, f, indent=2, ensure_ascii=False)
            import os
            os.replace(str(tmp), str(INDEX_FILE))
    except OSError:
        logger.debug("Failed to save provenance index", exc_info=True)
        if tmp.exists():
            try:
                tmp.unlink()
            except OSError:
                pass


def _validate_stage(entry: dict) -> dict:
    """Ensure stage entry has required fields, fill defaults."""
    out = dict(entry)
    out.setdefault("timestamp", now_iso())
    if "stage" not in out:
        out["stage"] = "unknown"
    return out


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

def append_provenance(fingerprint: str, stage_entry: dict,
                      knowledge_type: str = "md") -> dict:
    """Append a provenance stage to an item's chain.

    For MD items (knowledge_type="md"): writes to sidecar index.
    For JSON items (knowledge_type="json"): returns the entry dict to be
    merged into the item by the caller (also writes to sidecar).

    Args:
        fingerprint: MD5 hash identifying the item (use _fingerprint()).
        stage_entry: Dict with at least "stage" key. See module docstring.
        knowledge_type: "md" or "json".

    Returns:
        The validated stage entry dict.
    """
    entry = _validate_stage(stage_entry)

    # SQLite-first: update provenance column on existing row; skip JSON if it works
    sqlite_ok = False
    try:
        import json as _json
        from pulse.core.brain_db import get_conn
        conn = get_conn()
        prov_json = _json.dumps(entry, ensure_ascii=False)
        for table in ("lessons", "failures", "patterns", "decisions", "intents", "facts", "private"):
            with conn:
                conn.execute(
                    f"UPDATE {table} SET provenance = ? WHERE fingerprint = ?",
                    (prov_json, fingerprint),
                )
                if conn.execute("SELECT changes()").fetchone()[0] > 0:
                    sqlite_ok = True
                    break
    except Exception:
        pass  # SQLite failure → fall through to JSON sidecar

    if not sqlite_ok:
        try:
            idx = _load_index()
            if fingerprint not in idx["entries"]:
                idx["entries"][fingerprint] = {
                    "file": entry.get("target", ""),
                    "provenance": [],
                }
            idx["entries"][fingerprint]["provenance"].append(entry)
            # Update file field if a target was provided
            if entry.get("target"):
                idx["entries"][fingerprint]["file"] = entry["target"]
            _save_index(idx)
        except Exception:
            logger.debug("append_provenance failed for %s", fingerprint, exc_info=True)

    return entry


def get_provenance(fingerprint: str) -> list:
    """Get the full provenance chain for a given item fingerprint.

    Returns:
        List of stage dicts (ordered by append time), or [] if not tracked.
    """
    try:
        idx = _load_index()
        record = idx.get("entries", {}).get(fingerprint, {})
        return record.get("provenance", [])
    except Exception:
        logger.debug("get_provenance failed for %s", fingerprint, exc_info=True)
        return []


def trace_by_source(agent: str, date: str = None) -> list:
    """Reverse lookup: find all items originating from a specific agent.

    Args:
        agent: Agent name to filter by (e.g. "claude", "gemini").
        date: Optional ISO date string (YYYY-MM-DD) to narrow results.

    Returns:
        List of dicts: [{"fingerprint": ..., "file": ..., "stages": [...]}]
    """
    results = []
    try:
        idx = _load_index()
        for fp, record in idx.get("entries", {}).items():
            stages = record.get("provenance", [])
            matched = False
            for s in stages:
                if s.get("agent", "").lower() != agent.lower():
                    continue
                if date:
                    ts = s.get("timestamp", "")
                    if not ts.startswith(date):
                        continue
                matched = True
                break
            if matched:
                results.append({
                    "fingerprint": fp,
                    "file": record.get("file", ""),
                    "stages": stages,
                })
    except Exception:
        logger.debug("trace_by_source failed for %s", agent, exc_info=True)
    return results
