"""GraphCuratorService — agent-driven entity dedup auto-apply.

Closes the gap where comma-prefix duplicates ("Lucy García Montes,
gobernadora de Sucre" + "Lucy García Montes" → propose merge) were
piling up as 100+ proposals waiting on the user to click Apply on each
one. That kind of mechanical bookkeeping is exactly what Rule 12 says
the agent system should handle, not the user.

Two paths converge here:

1.  **Tier-B deterministic auto-apply** — ``auto_apply()`` runs from the
    heartbeat at a configurable cadence (default daily). Reads the
    ``graph-curator`` skill's YAML, filters proposals to the rule-set
    declared safe for auto-apply (default: only ``comma_prefix``),
    applies them via ``KnowledgeGraph.merge_entities_batch`` with full
    audit + decision + activity log wiring. No LLM in the loop —
    comma-prefix is mechanical.

2.  **Tier-A chat-driven path** — ``list_proposals()`` / ``apply_one()``
    back the ``list_merge_proposals`` / ``apply_merge_proposal`` agent
    tools. A user can ask "audit my graph for duplicates" and the
    librarian/philosopher will walk the list using the same code.

The 0.95+ embedding-cosine band is intentionally NOT in the default
auto-apply set — that decision is documented in
``memory/project_entity_dedup.md``: high-cosine pairs need human
judgment because the false-positive rate of any auto-classifier in
that confidence band is too costly to undo silently. Auto-apply is
restricted to deterministic rules where the false-positive rate is
zero by construction.
"""

from __future__ import annotations

import logging
from typing import Any

log = logging.getLogger(__name__)


# Last-resort defaults if curator.yaml is missing or malformed. Values
# are conservative: only comma_prefix is auto-applied, only 50 per run,
# disabled until the YAML explicitly enables. The YAML default ships
# with enabled=true so a fresh vault gets the curator on day one, but a
# missing/broken config falls back to OFF instead of unsafe behaviour.
_FALLBACK_CONFIG: dict[str, Any] = {  # arch:deterministic — engine fallback when YAML is missing
    "enabled": False,
    "auto_apply_rules": ["comma_prefix"],
    "max_per_run": 50,
    "cadence_hours": 24.0,
    "dry_run": False,
}


class GraphCuratorService:
    """Coordinates entity-dedup auto-apply and chat-driven curation.

    Pure orchestrator — never mutates the graph itself. Delegates to
    ``KnowledgeGraph.merge_entities`` / ``merge_entities_batch`` for
    every state change, and ``EntityResolutionAuditLog`` for the
    audit trail. All deps are injected; missing deps degrade
    gracefully (e.g. no audit log → merges still happen, audit_id=0).
    """

    def __init__(
        self,
        *,
        graph: Any,
        audit_log: Any | None = None,
        activity: Any | None = None,
        decisions: Any | None = None,
        skill_loader: Any | None = None,
        contract: Any | None = None,
    ) -> None:
        self._graph = graph
        self._audit_log = audit_log
        self._activity = activity
        self._decisions = decisions
        self._skill_loader = skill_loader
        self._contract = contract

    # ── Config ──────────────────────────────────────────────────────

    def config(self) -> dict[str, Any]:
        """Load the curator config from the skill YAML, falling back to
        the conservative defaults if anything goes wrong.

        Reads ``defaults/skills/_meta/graph-curator/config/curator.yaml``
        (or its vault override). Missing skill loader, missing YAML,
        malformed YAML — all return ``_FALLBACK_CONFIG`` unchanged.
        Never raises.
        """
        if self._skill_loader is None:
            return dict(_FALLBACK_CONFIG)
        try:
            data = self._skill_loader.load_skill_config_file(
                "graph-curator", "curator.yaml",
            )
        except Exception:
            log.debug("graph-curator: load_skill_config_file failed", exc_info=True)
            return dict(_FALLBACK_CONFIG)

        if not isinstance(data, dict):
            return dict(_FALLBACK_CONFIG)

        merged = dict(_FALLBACK_CONFIG)
        merged.update({k: v for k, v in data.items() if k in _FALLBACK_CONFIG})
        # Coerce list-shaped fields defensively — a YAML user might
        # accidentally put a string in auto_apply_rules.
        rules = merged.get("auto_apply_rules", [])
        if isinstance(rules, str):
            merged["auto_apply_rules"] = [rules]
        elif not isinstance(rules, list):
            merged["auto_apply_rules"] = list(_FALLBACK_CONFIG["auto_apply_rules"])
        return merged

    # ── Read paths ─────────────────────────────────────────────────

    def list_proposals(
        self,
        *,
        embedding_threshold: float = 0.95,
        max_pairs: int = 200,
    ) -> dict[str, Any]:
        """Return the live list of merge proposals.

        Pure read — never mutates the graph. Same shape the
        ``GET /api/graph/merge-proposals`` route returns, plus a
        per-rule grouping that the agent tool can show to a user.
        """
        dismissed = self._graph.load_dismissed_merges()
        proposals = self._graph.find_merge_proposals(
            embedding_threshold=embedding_threshold,
            max_pairs=max_pairs,
            dismissed=dismissed,
        )
        by_rule: dict[str, list[dict]] = {}
        for p in proposals:
            by_rule.setdefault(p.get("rule") or "unknown", []).append(p)
        return {
            "proposals": proposals,
            "count": len(proposals),
            "by_rule": {rule: len(items) for rule, items in by_rule.items()},
            "dismissed_count": len(dismissed),
        }

    # ── Tier-B auto-apply ─────────────────────────────────────────

    async def auto_apply(
        self,
        *,
        dry_run: bool | None = None,
        caller: str = "heartbeat",
        source: str = "agent_curator",
    ) -> dict[str, Any]:
        """Apply every proposal whose rule is in ``auto_apply_rules``.

        Returns a result dict with applied/skipped counts plus the list
        of audit_ids. Never raises — failures emit a high-importance
        activity event and return a non-merged result so the heartbeat
        can keep running.

        Contract gate: a single ``auto_merge_entity`` check covers the
        whole batch. Block → result has ``reason="contract_blocked"``.
        """
        cfg = self.config()
        if not cfg.get("enabled", False):
            return self._noop("disabled")
        rules_to_auto = {r for r in cfg.get("auto_apply_rules", []) if isinstance(r, str)}
        if not rules_to_auto:
            return self._noop("no_auto_rules")

        max_per_run = max(1, int(cfg.get("max_per_run", 50)))
        listing = self.list_proposals(max_pairs=max(200, max_per_run * 4))
        proposals = listing.get("proposals") or []
        eligible = [
            p for p in proposals
            if (p.get("rule") or "") in rules_to_auto
        ][:max_per_run]
        if not eligible:
            return self._noop("no_eligible", inspected=len(proposals))

        # Honour dry_run from arg first, then YAML, then default False.
        effective_dry = dry_run if dry_run is not None else bool(cfg.get("dry_run", False))
        if effective_dry:
            return {
                "applied": 0,
                "would_apply": len(eligible),
                "rules": sorted(rules_to_auto),
                "dry_run": True,
                "preview": [
                    {
                        "kept_slug": p.get("kept_slug", ""),
                        "lost_slug": p.get("lost_slug", ""),
                        "kept_name": p.get("kept_name", ""),
                        "lost_name": p.get("lost_name", ""),
                        "rule": p.get("rule", ""),
                        "confidence": p.get("confidence", 0.0),
                    }
                    for p in eligible[:10]
                ],
            }

        # Contract gate. One check covers the entire batch — every
        # proposal in ``eligible`` was already filtered to the safe
        # rule-set the user authorized via curator.yaml.
        if self._contract is not None:
            try:
                check = self._contract.check("auto_merge_entity")
                if not getattr(check, "allowed", False):
                    return {
                        "applied": 0,
                        "skipped": len(eligible),
                        "reason": "contract_blocked",
                        "rules": sorted(rules_to_auto),
                    }
            except Exception:
                log.debug("graph-curator: contract.check failed", exc_info=True)

        pairs = [(p["kept_slug"], p["lost_slug"]) for p in eligible]
        try:
            batch = self._graph.merge_entities_batch(pairs)
        except Exception as exc:
            log.exception("graph-curator: merge_entities_batch raised")
            self._log_activity(
                "knowledge",
                "graph_curator_failed",
                f"Auto-merge raised {type(exc).__name__}",
                {"error": str(exc)[:240], "pair_count": len(pairs)},
                importance="high",
            )
            return {"applied": 0, "skipped": len(pairs), "reason": "merge_raised"}

        # Audit each successful merge so unmerge stays available. The
        # batch result emits ``kept_slug`` / ``lost_slug`` per pair;
        # ``_record_audit`` (mirroring the single-merge shape and the
        # API route's helper) expects ``slug_keep`` / ``slug_remove``.
        # Translate once here so both Tier-A and Tier-B paths land
        # identically-shaped audit rows.
        audit_ids: list[int] = []
        for pair_result in batch.get("results", []) or []:
            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", {}),
            }
            try:
                aid = await self._record_audit(
                    merge_shape, caller=caller, source=source,
                )
                if aid:
                    audit_ids.append(aid)
            except Exception:
                log.debug("graph-curator: audit record failed", exc_info=True)

        applied = batch.get("merged", 0)
        skipped = len(batch.get("skipped", []) or [])
        repointed = batch.get("repointed", 0)
        if applied:
            self._log_activity(
                "knowledge",
                "graph_curator_applied",
                f"Auto-merged {applied} duplicate entities (rules: {', '.join(sorted(rules_to_auto))})",
                {
                    "applied": applied,
                    "repointed": repointed,
                    "skipped": skipped,
                    "rules": sorted(rules_to_auto),
                    "caller": caller,
                    "source": source,
                },
                importance="medium",
            )
        return {
            "applied": applied,
            "repointed": repointed,
            "skipped": skipped,
            "rules": sorted(rules_to_auto),
            "audit_ids": audit_ids,
        }

    # ── Tier-A single apply ──────────────────────────────────────

    async def apply_one(
        self,
        kept_slug: str,
        lost_slug: str,
        *,
        caller: str = "agent_tool",
        source: str = "agent_curator",
    ) -> dict[str, Any]:
        """Apply one merge — used by the ``apply_merge_proposal`` tool.

        Same audit + activity wiring as ``auto_apply`` but for a single
        pair. Contract is checked once before the merge runs.
        """
        if not kept_slug or not lost_slug:
            return {"merged": False, "reason": "missing_slug"}
        if kept_slug == lost_slug:
            return {"merged": False, "reason": "same_slug"}

        if self._contract is not None:
            try:
                check = self._contract.check("auto_merge_entity")
                if not getattr(check, "allowed", False):
                    return {"merged": False, "reason": "contract_blocked"}
            except Exception:
                log.debug("graph-curator: contract.check failed", exc_info=True)

        try:
            result = self._graph.merge_entities(kept_slug, lost_slug)
        except Exception as exc:
            log.exception("graph-curator: merge_entities raised")
            return {"merged": False, "reason": f"merge_raised:{type(exc).__name__}"}

        if not result.get("merged"):
            return result

        audit_id = await self._record_audit(result, caller=caller, source=source)
        response = {k: v for k, v in result.items() if k != "removed_snapshot"}
        response["audit_id"] = audit_id
        self._log_activity(
            "knowledge",
            "graph_curator_applied",
            f"Merged '{result.get('slug_remove')}' into '{result.get('slug_keep')}'",
            {
                "audit_id": audit_id,
                "kept_slug": result.get("slug_keep"),
                "removed_slug": result.get("slug_remove"),
                "triples_repointed": result.get("triples_repointed", 0),
                "caller": caller,
                "source": source,
            },
            importance="medium",
        )
        return response

    # ── Internals ────────────────────────────────────────────────

    async def _record_audit(
        self,
        result: dict[str, Any],
        *,
        caller: str,
        source: str,
    ) -> int:
        """Persist one audit row + emit a decision row. Returns audit_id
        (or 0 if any wiring is missing). Mirrors the API route's
        ``_record_merge_audit`` with deps injected directly so this can
        be called from background paths without a Request object.
        """
        if not result.get("merged"):
            return 0
        snapshot = result.get("removed_snapshot") or {}
        if not self._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 self._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,
                    "auto_applied": True,
                    "caller": caller,
                },
            )
        except Exception:
            log.exception("graph-curator: audit_log.record failed")
            return 0

        if self._decisions is not None:
            try:
                await self._decisions.record(
                    actor="curator",
                    action_type="entity_merged",
                    action_target=f"{removed_slug} -> {kept_slug}",
                    contract_action="auto_merge_entity",
                    contract_result="allowed",
                    triggered_by=caller,
                    rationale=(
                        f"Auto-merged duplicate via graph-curator "
                        f"({source}, caller={caller})"
                    ),
                    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,
                        "auto_applied": True,
                    },
                )
            except Exception:
                log.debug("graph-curator: decisions.record failed", exc_info=True)

        return audit_id

    def _log_activity(
        self,
        category: str,
        action: str,
        summary: str,
        details: dict[str, Any],
        *,
        importance: str = "medium",
    ) -> None:
        if self._activity is None:
            return
        try:
            self._activity.log(category, action, summary, details, importance=importance)
        except Exception:
            log.debug("graph-curator: activity.log failed", exc_info=True)

    @staticmethod
    def _noop(reason: str, **extra: Any) -> dict[str, Any]:
        out = {"applied": 0, "skipped": 0, "reason": reason}
        out.update(extra)
        return out
