"""Knowledge graph API — query entities, traverse connections, SPO triples, visualize."""

from __future__ import annotations

import asyncio
import logging

from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel

log = logging.getLogger(__name__)

router = APIRouter(prefix="/graph", tags=["graph"])


# ── Entity-resolution audit helpers (Rule #13: every merge is observable) ──
#
# Every successful destructive merge emits three records:
#   1. ``entity_resolution_audit`` row — captures the BEFORE state so
#      ``unmerge_entities`` can reverse it
#   2. ``decision_log`` entry — actor=user, action_type=entity_merged
#   3. activity_logger event — category=knowledge, importance=medium
#
# Both 2 and 3 are non-fatal: a broken decision/activity logger never blocks
# the merge or audit. The audit row itself is the load-bearing reversibility
# anchor; if it fails to persist, the merge result still includes the
# ``audit_id=0`` sentinel so the UI can warn the user.


async def _record_merge_audit(
    request: Request,
    result: dict,
    *,
    triggered_by: str = "api_user",
    source: str = "user_initiated",
) -> int:
    """Persist audit row + emit decision/activity for a successful merge.

    Returns the audit_id (0 when the audit log isn't wired or the merge
    result is missing the required snapshot fields).
    """
    if not result.get("merged"):
        return 0

    audit_log = getattr(request.app.state, "entity_audit", None)
    snapshot = result.get("removed_snapshot") or {}
    if not audit_log or not snapshot.get("canonical_name"):
        return 0

    kept_slug = result.get("slug_keep", "")
    removed_slug = result.get("slug_remove", "")
    repointed_ids = list(result.get("repointed_triple_ids", []) or [])
    aliases = list(snapshot.get("aliases", []) or [])

    try:
        audit_id = await audit_log.record(
            kept_slug=kept_slug,
            removed_slug=removed_slug,
            removed_canonical_name=snapshot.get("canonical_name", ""),
            removed_entity_type=snapshot.get("entity_type", "") or "",
            removed_aliases=aliases,
            repointed_triple_ids=repointed_ids,
            source=source,
            evidence={"removed_entity": snapshot},
        )
    except Exception:
        log.exception("Failed to record entity_resolution_audit row")
        return 0

    decisions = getattr(request.app.state, "decisions", None)
    if decisions is not None:
        try:
            await decisions.record(
                actor="user",
                action_type="entity_merged",
                action_target=f"{removed_slug} -> {kept_slug}",
                contract_action="merge_entities",
                contract_result="allowed",
                triggered_by=triggered_by,
                rationale=f"User merged entity via {triggered_by}",
                details={
                    "audit_id": audit_id,
                    "kept_slug": kept_slug,
                    "removed_slug": removed_slug,
                    "triples_repointed": result.get("triples_repointed", 0),
                    "aliases_inherited": aliases,
                    "source": source,
                },
            )
        except Exception:
            log.debug("merge decision record failed", exc_info=True)

    activity = getattr(request.app.state, "activity_logger", None)
    if activity is not None:
        try:
            activity.log(
                "knowledge",
                "entity_merged",
                f"Merged '{snapshot.get('canonical_name', removed_slug)}' into '{kept_slug}'",
                {
                    "audit_id": audit_id,
                    "kept_slug": kept_slug,
                    "removed_slug": removed_slug,
                    "triples_repointed": result.get("triples_repointed", 0),
                },
                importance="medium",
            )
        except Exception:
            log.debug("merge activity log failed", exc_info=True)

    return audit_id


async def _record_unmerge_event(
    request: Request,
    audit: dict,
    unmerge_result: dict,
    *,
    triggered_by: str = "api_user",
) -> None:
    """Emit decision + activity events for a successful unmerge.

    Audit row's ``retracted_at`` is set by the route handler (via
    ``audit_log.mark_retracted``) — this helper only handles the parallel
    decision + activity records. Non-fatal on every path.
    """
    kept_slug = audit.get("kept_slug", "")
    removed_slug = audit.get("removed_slug", "")
    audit_id = audit.get("id", 0)
    canonical = audit.get("removed_canonical_name") or removed_slug

    decisions = getattr(request.app.state, "decisions", None)
    if decisions is not None:
        try:
            await decisions.record(
                actor="user",
                action_type="entity_unmerged",
                action_target=f"{kept_slug} -> {removed_slug}",
                contract_action="unmerge_entities",
                contract_result="allowed",
                triggered_by=triggered_by,
                rationale=f"User retracted merge audit_id={audit_id}",
                details={
                    "audit_id": audit_id,
                    "kept_slug": kept_slug,
                    "removed_slug": removed_slug,
                    "triples_restored": unmerge_result.get("triples_restored", 0),
                    "triples_missing": unmerge_result.get("triples_missing", 0),
                    "warnings": list(unmerge_result.get("warnings", []) or []),
                },
            )
        except Exception:
            log.debug("unmerge decision record failed", exc_info=True)

    activity = getattr(request.app.state, "activity_logger", None)
    if activity is not None:
        try:
            activity.log(
                "knowledge",
                "entity_unmerged",
                f"Resurrected '{canonical}' from '{kept_slug}' (audit_id={audit_id})",
                {
                    "audit_id": audit_id,
                    "kept_slug": kept_slug,
                    "removed_slug": removed_slug,
                    "triples_restored": unmerge_result.get("triples_restored", 0),
                    "triples_missing": unmerge_result.get("triples_missing", 0),
                },
                importance="medium",
            )
        except Exception:
            log.debug("unmerge activity log failed", exc_info=True)


@router.get("")
async def get_graph(request: Request) -> dict:
    """Get full graph data for UI visualization (nodes + edges)."""
    graph = request.app.state.knowledge_graph
    return graph.get_graph_data()


@router.get("/stats")
async def graph_stats(request: Request) -> dict:
    """Get graph statistics including SPO triple data."""
    graph = request.app.state.knowledge_graph
    return graph.get_stats()


@router.post("/build")
async def rebuild_graph(request: Request) -> dict:
    """Force a full rebuild of the knowledge graph from vault files.

    Phase 6 — explicitly forces ``force_full=True`` so this endpoint
    keeps its historical "rebuild from scratch" semantics. For the
    cheap incremental path (typically used after a normal restart) hit
    ``POST /api/graph/refresh`` instead.

    Use cases for force-full:
    - After a bulk vault import / migration
    - When the wiki-link graph or article-entities cross-references look
      stale (rare — Phase 4 keeps them in sync automatically)
    - When the build_index or wikilink_index has been deleted manually
    - To recover from any divergence between the persisted state and
      the on-disk vault
    """
    graph = request.app.state.knowledge_graph
    edges = graph.build(force_full=True)
    return {
        "rebuilt": True,
        "edges": edges,
        "summary": dict(graph._last_build_summary),
    }


@router.post("/refresh")
async def refresh_graph(request: Request) -> dict:
    """Run an incremental graph build (mode chosen automatically).

    Phase 6 — explicit incremental trigger. The mode is picked by the
    same heuristics ``build()`` uses on server boot: incremental when
    the persisted indices are usable, otherwise a full rebuild. The
    response ``summary`` field carries the mode + delta counters so the
    caller can verify what actually happened.

    Use this from the UI's "rebuild graph" button on a normal day —
    it's typically <10s on a vault that's been steady.
    """
    graph = request.app.state.knowledge_graph
    edges = graph.build(force_full=False)
    return {
        "refreshed": True,
        "edges": edges,
        "summary": dict(graph._last_build_summary),
    }


@router.post("/rebuild-registry")
async def rebuild_registry(request: Request) -> dict:
    """Rebuild the entity registry from scanned file frontmatter.

    Walks knowledge dirs, reads frontmatter from each markdown file, and
    populates the registry with entities (title + entity_type + aliases).
    Use this to recover after entities.json has been wiped or to backfill
    entities from existing wiki/agent/skill pages.
    """
    graph = request.app.state.knowledge_graph
    added = graph.rebuild_registry_from_files()
    # Rebuild the wikilink/SPO graph so the new entities canonicalize wikilinks
    edges = graph.build()
    return {
        "rebuilt": True,
        "entities_registered": added,
        "edges": edges,
        "total_entities": len(graph._registry.entities),
    }


@router.get("/entity/{entity}")
async def get_entity(request: Request, entity: str, depth: int = 2) -> list[dict]:
    """Get all entities connected to a given entity (wiki-link based)."""
    graph = request.app.state.knowledge_graph
    return graph.get_connected(entity, depth=depth)


@router.get("/entity/{entity}/triples")
async def get_entity_triples(request: Request, entity: str, depth: int = 2) -> dict:
    """Get structured knowledge about an entity (SPO triples + connections)."""
    graph = request.app.state.knowledge_graph
    result = graph.query_entity(entity, depth=depth)
    if result is None:
        return {"found": False, "message": f"Entity '{entity}' not found."}
    return {"found": True, **result}


@router.get("/path/{entity_a}/{entity_b}")
async def get_path(request: Request, entity_a: str, entity_b: str) -> dict:
    """Find the connection path between two entities."""
    graph = request.app.state.knowledge_graph
    path = graph.query_path(entity_a, entity_b)
    if path is None:
        return {"found": False, "message": f"No path between '{entity_a}' and '{entity_b}'."}
    return {"found": True, "path": path}


@router.get("/search")
async def search_entities(request: Request, q: str) -> list[dict]:
    """Search for entities by name (searches both wiki-links and SPO entities)."""
    graph = request.app.state.knowledge_graph
    return graph.search_entities(q)


class ExtractRequest(BaseModel):
    text: str
    source_file: str = ""
    authority: str = "system"


@router.post("/triples/extract")
async def extract_triples(request: Request, body: ExtractRequest) -> dict:
    """Extract SPO triples from text using LLM."""
    graph = request.app.state.knowledge_graph
    triples = await graph.extract_triples_llm(
        body.text,
        source_file=body.source_file,
        authority=body.authority,
    )
    return {"extracted": len(triples), "triples": triples}


@router.post("/decay/evaluate")
async def evaluate_decay(request: Request) -> dict:
    """Evaluate decay conditions on all entities and triples."""
    graph = request.app.state.knowledge_graph
    return graph.evaluate_decay()


@router.get("/contradictions")
async def get_contradictions(request: Request) -> dict:
    """Find all contradictions in the knowledge graph."""
    graph = request.app.state.knowledge_graph
    contradictions = graph.get_contradictions()
    return {"count": len(contradictions), "contradictions": contradictions}


@router.get("/ontology/predicates")
async def get_predicates(request: Request) -> list[dict]:
    """List all predicate definitions from the ontology."""
    from agntspace.memory.ontology import list_predicates
    return list_predicates()


@router.get("/ontology/entity-types")
async def get_entity_types(request: Request) -> list[dict]:
    """List all entity types from the ontology."""
    from agntspace.memory.ontology import EntityType
    return [{"value": t.value, "name": t.name} for t in EntityType]


@router.get("/ontology/fallbacks")
async def get_fallbacks(request: Request, min_count: int = 1) -> dict:
    """Get predicates that fell back to related_to — candidates for new definitions."""
    from agntspace.memory.ontology import get_fallback_report
    report = get_fallback_report(min_count)
    return {"fallbacks": report, "count": len(report)}


@router.post("/ontology/reload")
async def reload_ontology_endpoint(request: Request) -> dict:
    """Force-reload ontology from YAML after changes."""
    from agntspace.memory.ontology import reload_ontology
    graph = request.app.state.knowledge_graph
    skills_dir = graph._vault.vault_dir / "skills"
    result = reload_ontology(str(skills_dir) if skills_dir.exists() else None)
    return result


@router.get("/registry/stats")
async def registry_stats(request: Request) -> dict:
    """Get entity registry statistics."""
    graph = request.app.state.knowledge_graph
    return graph.registry.stats()


@router.get("/registry/search")
async def registry_search(request: Request, q: str, limit: int = 20) -> list[dict]:
    """Search the entity registry."""
    graph = request.app.state.knowledge_graph
    return graph.registry.search(q, limit=limit)


class MergeRequest(BaseModel):
    keep: str
    remove: str


@router.post("/registry/merge")
async def merge_entities(request: Request, body: MergeRequest) -> dict:
    """Merge two entities (keep one, absorb the other).

    Writes an ``entity_resolution_audit`` row before persisting so the
    operation can be reversed via ``POST /api/graph/merge-audit/{id}/retract``.
    """
    graph = request.app.state.knowledge_graph
    # Resolve names to slugs
    keep_slug = graph.registry.resolve(body.keep)
    remove_slug = graph.registry.resolve(body.remove)
    if not keep_slug:
        return {"merged": False, "error": f"Entity '{body.keep}' not found"}
    if not remove_slug:
        return {"merged": False, "error": f"Entity '{body.remove}' not found"}

    # Use unified merge that also repoints triple references (Fix #8)
    result = graph.merge_entities(keep_slug, remove_slug)
    if not result["merged"]:
        return {"merged": False, "error": result.get("reason", "Merge failed")}

    audit_id = await _record_merge_audit(
        request, result, triggered_by="api_user:registry_merge",
    )

    return {
        "merged": True,
        "kept": body.keep,
        "removed": body.remove,
        "triples_updated": result["triples_repointed"],
        "audit_id": audit_id,
    }


@router.get("/article-triples/{article_path:path}")
async def get_article_triples(request: Request, article_path: str) -> dict:
    """Get all triples that are expressed in a wiki article."""
    graph = request.app.state.knowledge_graph
    triples = graph.get_article_triples(article_path)
    return {"article": article_path, "triples": triples, "count": len(triples)}


@router.get("/triple/{triple_id}/articles")
async def get_triple_articles(request: Request, triple_id: int) -> dict:
    """Get all wiki articles that express a given triple."""
    graph = request.app.state.knowledge_graph
    articles = graph.get_triple_articles(triple_id)
    return {"triple_id": triple_id, "articles": articles, "count": len(articles)}


@router.get("/entity/{entity}/at/{date}")
async def get_entity_at_date(
    request: Request, entity: str, date: str, time_type: str = "world",
) -> dict:
    """Query what was known about an entity at a specific point in time.

    time_type=world (default): returns triples valid IN REALITY at the date.
    time_type=system: returns triples we BELIEVED at the date (even if
    they were already stale or we later learned they were wrong).

    Unknown time_type values silently fall back to "world" so a typo
    never returns an error. Use /graph/schema for the valid set.
    """
    graph = request.app.state.knowledge_graph
    tt = time_type if time_type in ("world", "system") else "world"
    result = graph.query_entity_at(entity, date, time_type=tt)
    if result is None:
        return {"found": False, "message": f"No data for '{entity}' at {date}", "time_type": tt}
    return {"found": True, **result}


@router.get("/stale-articles")
async def get_stale_articles(request: Request) -> dict:
    """Find wiki articles that may contain outdated information."""
    graph = request.app.state.knowledge_graph
    stale = graph.get_stale_articles()
    return {"stale_articles": stale, "count": len(stale)}


@router.post("/propagate/{triple_id}")
async def propagate_change(request: Request, triple_id: int) -> dict:
    """Find articles affected by a change to a triple."""
    graph = request.app.state.knowledge_graph
    return graph.propagate_change(triple_id)


# ── Embedding backfill ──────────────────────────────────────────────────────


class _BackfillRequest(BaseModel):
    batch_size: int = 50
    max_entities: int | None = None


@router.post("/embeddings/backfill")
async def backfill_embeddings(
    request: Request, payload: _BackfillRequest | None = None,
) -> dict:
    """Compute embeddings for every registered entity not yet cached.

    Side-effect-free w.r.t. the epistemic graph: no triple, status,
    authority, or weight is touched. Embeddings are a similarity lookup
    tool used in entity resolution. Worst case (Ollama unreachable, model
    not pulled): the cache stays empty and the user sees ``embedded=0``.

    Runs in a worker thread (``asyncio.to_thread``) so the embed calls,
    which can take seconds per batch, never block the asyncio main loop.
    """
    graph = request.app.state.knowledge_graph
    registry = graph._registry
    body = payload or _BackfillRequest()
    result = await asyncio.to_thread(
        registry.backfill_embeddings_blocking,
        batch_size=body.batch_size,
        max_entities=body.max_entities,
    )
    return result


# ── Merge proposals (suggest-only) ─────────────────────────────────────────


@router.get("/merge-proposals")
async def list_merge_proposals(
    request: Request,
    embedding_threshold: float = 0.95,
    max_pairs: int = 200,
) -> dict:
    """List likely-duplicate entity pairs the agent suggests merging.

    Pure read — never mutates the graph. The user reviews each proposal
    and explicitly approves or dismisses; only then does ``merge_entities``
    run. Pairs already dismissed are filtered out automatically using the
    persisted ``dismissed_merges.json``.
    """
    graph = request.app.state.knowledge_graph
    dismissed = graph.load_dismissed_merges()
    proposals = graph.find_merge_proposals(
        embedding_threshold=embedding_threshold,
        max_pairs=max_pairs,
        dismissed=dismissed,
    )
    return {
        "proposals": proposals,
        "count": len(proposals),
        "dismissed_count": len(dismissed),
    }


class _MergePairRequest(BaseModel):
    kept_slug: str
    lost_slug: str


@router.post("/merge-proposals/apply")
async def apply_merge_proposal(
    request: Request, payload: _MergePairRequest,
) -> dict:
    """Execute a merge that the user has approved. Repoints triple
    references from ``lost_slug`` to ``kept_slug`` via the existing
    ``merge_entities`` API and records an audit row so the merge can be
    reversed."""
    graph = request.app.state.knowledge_graph
    if not payload.kept_slug or not payload.lost_slug:
        raise HTTPException(
            status_code=400, detail="kept_slug and lost_slug required",
        )
    if payload.kept_slug == payload.lost_slug:
        raise HTTPException(
            status_code=400, detail="kept_slug and lost_slug must differ",
        )
    result = graph.merge_entities(payload.kept_slug, payload.lost_slug)
    if not result.get("merged"):
        raise HTTPException(status_code=404, detail=result.get("reason", "merge failed"))

    audit_id = await _record_merge_audit(
        request, result, triggered_by="api_user:merge_proposal",
    )

    # Strip the lossless snapshot from the response — it's already persisted
    # in the audit row and would inflate the wire payload (and leak internal
    # registry shape to clients that don't need it).
    response = {k: v for k, v in result.items() if k != "removed_snapshot"}
    response["audit_id"] = audit_id
    return response


class _MergeBatchRequest(BaseModel):
    pairs: list[_MergePairRequest]


@router.post("/merge-proposals/apply-batch")
async def apply_merge_proposals_batch(
    request: Request, payload: _MergeBatchRequest,
) -> dict:
    """Apply many merges with a single graph save at the end.

    The single-merge ``/apply`` endpoint serialises the entire graph to
    disk on every call. On a hot vault that's seconds per merge — clicking
    Apply 30 times stacks 30 full saves, slows the server to a crawl, and
    can crash it under contention. This endpoint runs the whole batch in
    memory and saves once.

    Returns ``{merged, repointed, results, skipped}``. Bad pairs land in
    ``skipped`` with a ``reason`` string and never block the rest.
    """
    graph = request.app.state.knowledge_graph
    if not payload.pairs:
        return {"merged": 0, "repointed": 0, "results": [], "skipped": []}
    pairs = [(p.kept_slug, p.lost_slug) for p in payload.pairs]
    batch_result = graph.merge_entities_batch(pairs)

    # Per-pair audit rows so each merge in the batch is independently
    # reversible. Strip the heavy ``removed_snapshot`` from the wire payload
    # afterwards — it's persisted in the audit row.
    for pair_result in batch_result.get("results", []):
        merge_shape = {
            "merged": True,
            "slug_keep": pair_result.get("kept_slug", ""),
            "slug_remove": pair_result.get("lost_slug", ""),
            "triples_repointed": pair_result.get("triples_repointed", 0),
            "repointed_triple_ids": pair_result.get("repointed_triple_ids", []),
            "removed_snapshot": pair_result.get("removed_snapshot", {}),
        }
        audit_id = await _record_merge_audit(
            request, merge_shape, triggered_by="api_user:merge_proposal_batch",
        )
        pair_result["audit_id"] = audit_id
        pair_result.pop("removed_snapshot", None)
    return batch_result


# ── Merge audit log + reversibility (#1 + #3 from tasks/er-minimal-plan.md) ──


@router.get("/merge-audit")
async def list_merge_audits(
    request: Request,
    limit: int = 20,
    include_retracted: bool = False,
) -> dict:
    """Recent merge audits — newest first.

    Drives the "Recent merges" panel on the Graph page. Pure read; no
    mutation, no contract gate. Default hides retracted rows so the
    panel surface stays compact; pass ``include_retracted=true`` for the
    full history.
    """
    audit_log = getattr(request.app.state, "entity_audit", None)
    if audit_log is None:
        return {"audits": [], "count": 0, "available": False}

    audits = await audit_log.list_recent(
        limit=max(1, min(int(limit), 200)),
        include_retracted=bool(include_retracted),
    )
    return {
        "audits": [a.to_dict() for a in audits],
        "count": len(audits),
        "available": True,
    }


class _RetractRequest(BaseModel):
    reason: str = ""


@router.post("/merge-audit/{audit_id}/retract")
async def retract_merge_audit(
    request: Request,
    audit_id: int,
    body: _RetractRequest | None = None,
) -> dict:
    """Reverse a previously-applied merge using its audit record.

    Resurrects the destroyed entity at its original slug, walks the
    audit's ``repointed_triple_ids`` and rewrites each triple's
    subject/object back from ``kept_slug`` to ``removed_slug``, marks
    the audit row retracted. Idempotent: re-retracting an already-
    retracted audit returns 409.

    Conservative on edge cases (deleted triples since the merge,
    further mutations that left a triple no longer pointing at
    kept_slug) — does NOT block the unmerge, surfaces them as
    ``warnings`` so the user can inspect.
    """
    audit_log = getattr(request.app.state, "entity_audit", None)
    if audit_log is None:
        raise HTTPException(status_code=503, detail="audit log not wired")

    audit = await audit_log.get(int(audit_id))
    if audit is None:
        raise HTTPException(status_code=404, detail=f"audit_id={audit_id} not found")
    if audit.retracted_at:
        raise HTTPException(
            status_code=409,
            detail=f"audit_id={audit_id} already retracted at {audit.retracted_at}",
        )

    graph = request.app.state.knowledge_graph
    audit_dict = audit.to_dict()
    result = graph.unmerge_entities(audit_dict)
    if not result.get("restored"):
        raise HTTPException(
            status_code=400, detail=result.get("reason", "unmerge failed"),
        )

    reason = (body.reason if body else "") or "user_initiated_retract"
    marked = await audit_log.mark_retracted(int(audit_id), reason)
    if not marked:
        # Lost the race — another caller retracted it. The graph mutation
        # already happened though, so report partial success with a warning.
        result.setdefault("warnings", []).append(
            "audit row was concurrently retracted; graph reverses applied anyway",
        )

    await _record_unmerge_event(
        request, audit_dict, result, triggered_by="api_user:merge_audit_retract",
    )

    return {
        "audit_id": int(audit_id),
        "retracted": True,
        **result,
    }


@router.post("/merge-proposals/dismiss")
async def dismiss_merge_proposal(
    request: Request, payload: _MergePairRequest,
) -> dict:
    """Record that the user rejected a proposed merge. The pair will not
    re-surface on subsequent scans. Idempotent — re-dismissing returns
    ``added=False`` rather than erroring."""
    graph = request.app.state.knowledge_graph
    if not payload.kept_slug or not payload.lost_slug:
        raise HTTPException(
            status_code=400, detail="kept_slug and lost_slug required",
        )
    added = graph.add_dismissed_merge(payload.kept_slug, payload.lost_slug)
    return {
        "dismissed": True,
        "added": added,
        "kept_slug": payload.kept_slug,
        "lost_slug": payload.lost_slug,
    }
