"""
Phase 68: Librarian — The knowledge graph maintenance agent.

Scans for stale entries, detects near-duplicates via token overlap,
merges entries, suggests categories, and generates summaries.

Storage:
  state/broker/librarian/
    reports/{timestamp}.json — maintenance reports
    merge_log.json           — history of merged entries
"""

import json
import re
import time
import uuid
from collections import Counter
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Any, Dict, List, Optional, Set

from brynq.server import BROKER_DIR, _write_json_sync, _append_jsonl_sync

# ── Paths ──────────────────────────────────────────────────────────────────

LIBRARIAN_DIR = BROKER_DIR / "librarian"
REPORTS_DIR = LIBRARIAN_DIR / "reports"
MERGE_LOG_PATH = LIBRARIAN_DIR / "merge_log.json"

for _d in (LIBRARIAN_DIR, REPORTS_DIR):
    _d.mkdir(parents=True, exist_ok=True)

# Category keywords for suggestion
CATEGORY_KEYWORDS: Dict[str, List[str]] = {
    "architecture": ["design", "pattern", "module", "layer", "component", "system",
                      "interface", "api", "structure", "service"],
    "codebase": ["code", "function", "class", "import", "variable", "method",
                  "file", "module", "refactor", "syntax"],
    "process": ["workflow", "deploy", "ci", "cd", "pipeline", "review", "merge",
                 "release", "sprint", "agile"],
    "decision": ["decide", "decision", "choose", "chose", "tradeoff", "alternative",
                  "option", "rationale", "why"],
    "bug": ["bug", "fix", "error", "crash", "issue", "broken", "fail", "exception",
             "traceback", "regression"],
    "feature": ["feature", "add", "implement", "new", "enhance", "improvement",
                 "capability", "support"],
    "reference": ["reference", "link", "documentation", "spec", "standard",
                   "rfc", "guide", "tutorial"],
}


# ── Helpers ────────────────────────────────────────────────────────────────

def _tokenize(text: str) -> Set[str]:
    """Tokenize text into lowercase word set."""
    return set(re.findall(r"[a-z0-9]+", text.lower()))


def _load_all_knowledge() -> List[dict]:
    """Load all knowledge entries from the knowledge graph."""
    entries = []
    try:
        from brynq.knowledge_mcp import KNOWLEDGE_DIR
        for f in KNOWLEDGE_DIR.glob("*.json"):
            if f.name == "index.jsonl":
                continue
            try:
                entries.append(json.loads(f.read_text("utf-8")))
            except (json.JSONDecodeError, OSError):
                continue
    except ImportError:
        pass
    return entries


def _jaccard_similarity(a: Set[str], b: Set[str]) -> float:
    """Jaccard similarity between two token sets."""
    if not a and not b:
        return 1.0
    if not a or not b:
        return 0.0
    return len(a & b) / len(a | b)


def _load_merge_log() -> List[dict]:
    if not MERGE_LOG_PATH.exists():
        return []
    try:
        return json.loads(MERGE_LOG_PATH.read_text("utf-8"))
    except (json.JSONDecodeError, OSError):
        return []


def _save_merge_log(log: List[dict]) -> None:
    _write_json_sync(MERGE_LOG_PATH, log)


# ── Public API ─────────────────────────────────────────────────────────────

def scan_for_stale(max_age_days: int = 30) -> List[dict]:
    """Find knowledge entries not updated in N days.

    Args:
        max_age_days: Maximum age before an entry is considered stale.

    Returns:
        List of stale entries with age info.
    """
    entries = _load_all_knowledge()
    cutoff = datetime.now(timezone.utc) - timedelta(days=max_age_days)
    cutoff_iso = cutoff.isoformat()
    stale = []

    for entry in entries:
        if entry.get("archived"):
            continue
        updated = entry.get("updated_at", entry.get("created_at", ""))
        if updated and updated < cutoff_iso:
            age_str = updated[:10]
            stale.append({
                "knowledge_id": entry.get("knowledge_id"),
                "topic": entry.get("topic", ""),
                "last_updated": updated,
                "category": entry.get("category", "general"),
            })

    return stale


def scan_for_duplicates(similarity_threshold: float = 0.8) -> List[dict]:
    """Find near-duplicate entries based on token overlap (Jaccard similarity).

    Args:
        similarity_threshold: 0.0-1.0 threshold for considering entries duplicates.

    Returns:
        List of duplicate pairs with similarity scores.
    """
    entries = _load_all_knowledge()
    # Pre-tokenize
    tokenized = []
    for e in entries:
        if e.get("archived"):
            continue
        text = f"{e.get('topic', '')} {e.get('content', '')}"
        tokenized.append((e, _tokenize(text)))

    duplicates = []
    seen = set()
    for i in range(len(tokenized)):
        for j in range(i + 1, len(tokenized)):
            e1, t1 = tokenized[i]
            e2, t2 = tokenized[j]
            sim = _jaccard_similarity(t1, t2)
            if sim >= similarity_threshold:
                pair_key = tuple(sorted([
                    e1.get("knowledge_id", ""), e2.get("knowledge_id", "")
                ]))
                if pair_key not in seen:
                    seen.add(pair_key)
                    duplicates.append({
                        "entry_a": e1.get("knowledge_id"),
                        "topic_a": e1.get("topic", ""),
                        "entry_b": e2.get("knowledge_id"),
                        "topic_b": e2.get("topic", ""),
                        "similarity": round(sim, 4),
                    })

    duplicates.sort(key=lambda x: x["similarity"], reverse=True)
    return duplicates


def merge_duplicates(entry_ids: List[str], keep_id: Optional[str] = None) -> dict:
    """Merge duplicate entries. Keeps the entry with highest confidence.

    Args:
        entry_ids: List of knowledge_id values to merge.
        keep_id: Optionally specify which entry to keep. Otherwise, highest confidence wins.

    Returns:
        Merge result.
    """
    if len(entry_ids) < 2:
        raise ValueError("Need at least 2 entry IDs to merge")

    entries = []
    try:
        from brynq.knowledge_mcp import KNOWLEDGE_DIR, _write_entry
        for kid in entry_ids:
            path = KNOWLEDGE_DIR / f"{kid}.json"
            if path.exists():
                entries.append(json.loads(path.read_text("utf-8")))
    except ImportError:
        return {"error": "knowledge_mcp not available"}

    if not entries:
        return {"error": "No entries found for given IDs"}

    # Pick the winner
    if keep_id:
        winner = next((e for e in entries if e.get("knowledge_id") == keep_id), None)
        if not winner:
            winner = max(entries, key=lambda e: e.get("confidence", 0))
    else:
        winner = max(entries, key=lambda e: e.get("confidence", 0))

    # Merge tags from all entries
    all_tags = set()
    for e in entries:
        all_tags.update(e.get("tags", []))
    winner["tags"] = sorted(all_tags)

    # Keep highest confidence
    winner["confidence"] = max(e.get("confidence", 0) for e in entries)
    winner["updated_at"] = datetime.now(timezone.utc).isoformat()

    # Save winner, archive others
    _write_entry(winner)
    archived_ids = []
    for e in entries:
        if e.get("knowledge_id") != winner.get("knowledge_id"):
            e["archived"] = True
            e["merged_into"] = winner.get("knowledge_id")
            _write_entry(e)
            archived_ids.append(e.get("knowledge_id"))

    # Log the merge
    log = _load_merge_log()
    log.append({
        "merged_at": datetime.now(timezone.utc).isoformat(),
        "kept": winner.get("knowledge_id"),
        "archived": archived_ids,
        "entry_count": len(entries),
    })
    _save_merge_log(log)

    return {
        "kept": winner.get("knowledge_id"),
        "archived": archived_ids,
        "merged_tags": winner["tags"],
        "confidence": winner["confidence"],
    }


def suggest_categories(entry_id: str) -> List[dict]:
    """Suggest better categories based on content keyword analysis.

    Returns:
        Ranked list of category suggestions with scores.
    """
    try:
        from brynq.knowledge_mcp import KNOWLEDGE_DIR
        path = KNOWLEDGE_DIR / f"{entry_id}.json"
        if not path.exists():
            return []
        entry = json.loads(path.read_text("utf-8"))
    except (ImportError, json.JSONDecodeError, OSError):
        return []

    text = f"{entry.get('topic', '')} {entry.get('content', '')}".lower()
    tokens = _tokenize(text)

    scores = []
    for category, keywords in CATEGORY_KEYWORDS.items():
        hits = sum(1 for kw in keywords if kw in tokens)
        if hits > 0:
            score = hits / len(keywords)
            scores.append({
                "category": category,
                "score": round(score, 4),
                "matching_keywords": [kw for kw in keywords if kw in tokens],
            })

    scores.sort(key=lambda x: x["score"], reverse=True)
    return scores


def generate_summary(category: Optional[str] = None) -> dict:
    """Create a summary of all knowledge, optionally filtered by category.

    Returns:
        Summary with entry count, top topics, and common tags.
    """
    entries = _load_all_knowledge()
    if category:
        entries = [e for e in entries if e.get("category") == category]

    entries = [e for e in entries if not e.get("archived")]

    if not entries:
        return {"category": category or "all", "entry_count": 0,
                "topics": [], "top_tags": [], "avg_confidence": 0}

    topics = [e.get("topic", "") for e in entries]
    all_tags: List[str] = []
    for e in entries:
        all_tags.extend(e.get("tags", []))
    tag_counts = Counter(all_tags)

    confidences = [e.get("confidence", 1.0) for e in entries]
    avg_conf = round(sum(confidences) / len(confidences), 4) if confidences else 0

    return {
        "category": category or "all",
        "entry_count": len(entries),
        "topics": topics[:20],
        "top_tags": tag_counts.most_common(10),
        "avg_confidence": avg_conf,
    }


def get_maintenance_report() -> dict:
    """Full maintenance report: stale count, duplicates, uncategorized, suggestions."""
    stale = scan_for_stale()
    duplicates = scan_for_duplicates()
    entries = _load_all_knowledge()
    active = [e for e in entries if not e.get("archived")]

    uncategorized = [e for e in active if e.get("category") == "general"]

    report = {
        "generated_at": datetime.now(timezone.utc).isoformat(),
        "total_entries": len(active),
        "stale_count": len(stale),
        "duplicate_pairs": len(duplicates),
        "uncategorized_count": len(uncategorized),
        "stale_entries": stale[:10],
        "duplicate_entries": duplicates[:10],
    }

    # Save report
    ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
    _write_json_sync(REPORTS_DIR / f"{ts}.json", report)
    return report
