# Copyright (c) 2026 PULSE Contributors. Proprietary. All rights reserved.
"""Knowledge Graph Builder — constructs nodes + edges from all brain sources."""

from collections import defaultdict
from pathlib import Path

from pulse.core.brain_utils import (
    LESSONS_DIR, FAILS_DIR, PATTERNS_DIR, EVIDENCE_DIR, KNOWLEDGE_DIR,
    load_json, load_json_dict, save_json, text_similarity,
)
from .graph_parser import (
    CAUSAL_PATTERNS,
    COMPILED_CAUSAL as _COMPILED_CAUSAL,
    now_iso as _now_iso,
    node_id as _node_id,
    extract_causal_edges as _extract_causal_edges,
    parse_md_sections as _parse_md_sections,
)

GRAPH_FILE = KNOWLEDGE_DIR / "knowledge_graph.json"


def _nodes_from_md_files(directory: Path, node_type: str, nodes: dict, edges: list):
    """Parse all markdown files in directory, creating typed nodes and causal edges."""
    for md_file in sorted(directory.rglob("*.md")):
        for entry in _parse_md_sections(md_file):
            nid = _node_id(entry["title"])
            nodes[nid] = {
                "id": nid,
                "type": node_type,
                "text": entry["title"],
                "source": entry["source"],
                "confidence": entry["confidence"],
                "date": entry["date"],
            }
            for edge in _extract_causal_edges(entry["body"]):
                from_id = _node_id(edge["from_text"])
                to_id = _node_id(edge["to_text"])
                # Auto-create concept nodes for edge endpoints
                if from_id not in nodes:
                    nodes[from_id] = {
                        "id": from_id, "type": "CONCEPT",
                        "text": edge["from_text"], "source": "causal_parser",
                    }
                if to_id not in nodes:
                    nodes[to_id] = {
                        "id": to_id, "type": "CONCEPT",
                        "text": edge["to_text"], "source": "causal_parser",
                    }
                edges.append({
                    "from": from_id, "to": to_id,
                    "type": edge["type"],
                    "confidence": edge["confidence"],
                    "source_node": nid,
                })


def _cross_link(nodes_a: dict, nodes_b: dict, edge_type: str,
                threshold: float, edges: list, confidence_boost: float = 0.0,
                filter_keywords: list | None = None):
    """Create cross-link edges between two node sets based on text similarity.

    Args:
        nodes_a: Source nodes (edge 'from').
        nodes_b: Target nodes (edge 'to').
        edge_type: Edge type label (PREVENTS, REQUIRES, etc.).
        threshold: Minimum text_similarity to create an edge.
        edges: Accumulator list to append edges to.
        confidence_boost: Added to similarity score for confidence.
        filter_keywords: If set, only process nodes_a entries whose text
                         contains at least one keyword (case-insensitive).
    """
    for aid, a_node in nodes_a.items():
        if filter_keywords:
            a_lower = a_node["text"].lower()
            if not any(kw in a_lower for kw in filter_keywords):
                continue
        for bid, b_node in nodes_b.items():
            sim = text_similarity(a_node["text"], b_node["text"])
            if sim >= threshold:
                edges.append({
                    "from": aid, "to": bid,
                    "type": edge_type,
                    "confidence": round(sim + confidence_boost, 2),
                    "source_node": "cross_link",
                })


def _nodes_from_json_sources(nodes: dict):
    """Parse all JSON brain sources into typed graph nodes."""
    # Decisions
    decisions_file = EVIDENCE_DIR / "Decisions" / "auto_decisions.json"
    if decisions_file.exists():
        for dec in load_json_dict(decisions_file).get("decisions", []):
            text = dec.get("description", "") or dec.get("text", "")
            if len(text) < 10:
                continue
            nid = _node_id(text)
            nodes[nid] = {"id": nid, "type": "DECISION", "text": text[:100],
                          "source": dec.get("source", "decisions"), "confidence": dec.get("confidence", 0.5)}
    # Schemas
    schemas_file = PATTERNS_DIR / "schemas.json"
    if schemas_file.exists():
        sd = load_json_dict(schemas_file)
        for s in (sd.get("schemas", []) if isinstance(sd, dict) else sd):
            text = s.get("title", "") or s.get("name", "")
            if len(text) < 5:
                continue
            nid = _node_id(f"schema:{text}")
            nodes[nid] = {"id": nid, "type": "SCHEMA", "text": text[:100],
                          "source": s.get("source", "schema_daemon"), "confidence": s.get("confidence", 0.7)}
    # Procedures
    pf = EVIDENCE_DIR / "Metrics" / "procedural_log.json"
    if pf.exists():
        pd = load_json_dict(pf)
        for p in (pd.get("procedures", []) if isinstance(pd, dict) else pd):
            text = p.get("title", "") or p.get("name", "")
            if len(text) < 5:
                continue
            nid = _node_id(f"procedure:{text}")
            nodes[nid] = {"id": nid, "type": "PROCEDURE", "text": text[:100],
                          "source": p.get("source", "procedure_daemon"), "confidence": p.get("confidence", 0.7)}
    # Wants
    wf = EVIDENCE_DIR / "Intents" / "wants.json"
    if wf.exists():
        wd = load_json(wf)
        for w in (wd if isinstance(wd, list) else wd.get("wants", [])):
            text = w.get("want", "") if isinstance(w, dict) else str(w)
            if len(text) < 5:
                continue
            nid = _node_id(f"want:{text}")
            nodes[nid] = {"id": nid, "type": "WANT", "text": text[:100],
                          "source": "wants_parser", "confidence": 0.6}
    # Don't Wants
    dwf = EVIDENCE_DIR / "Intents" / "dont_wants.json"
    if dwf.exists():
        dwd = load_json(dwf)
        for dw in (dwd if isinstance(dwd, list) else dwd.get("dont_wants", [])):
            text = dw.get("dont_want", "") if isinstance(dw, dict) else str(dw)
            if len(text) < 5:
                continue
            nid = _node_id(f"dontwant:{text}")
            nodes[nid] = {"id": nid, "type": "DONT_WANT", "text": text[:100],
                          "source": "wants_parser", "confidence": 0.6}
    # Strategic Intents
    sif = EVIDENCE_DIR / "Intents" / "strategic_intents.json"
    if sif.exists():
        sid = load_json_dict(sif)
        for si in (sid.get("intents", []) if isinstance(sid, dict) else sid):
            text = si.get("title", "")
            if len(text) < 5:
                continue
            nid = _node_id(f"intent:{text}")
            nodes[nid] = {"id": nid, "type": "INTENT", "text": text[:100],
                          "source": si.get("source", "intent_daemon"), "confidence": 0.7}
    # Anti-Patterns
    apf = EVIDENCE_DIR / "Anti_Patterns" / "anti_patterns_active.json"
    if apf.exists():
        apd = load_json(apf)
        for ap in (apd if isinstance(apd, list) else apd.get("anti_patterns", [])):
            text = ap.get("description", "") or ap.get("pattern", "") or ap.get("title", "")
            if len(text) < 5:
                continue
            nid = _node_id(f"antipattern:{text}")
            nodes[nid] = {"id": nid, "type": "ANTI_PATTERN", "text": text[:100],
                          "source": "anti_pattern_registry", "confidence": 0.8}


def build_graph(verbose=False) -> dict:
    """Build the full knowledge graph from all brain files.

    Returns graph data dict with nodes and edges.
    """
    nodes = {}
    edges = []

    # 1-3. Parse lessons, failures, patterns -> typed nodes + causal edges
    _nodes_from_md_files(LESSONS_DIR, "LESSON", nodes, edges)
    _nodes_from_md_files(FAILS_DIR, "FAILURE", nodes, edges)
    _nodes_from_md_files(PATTERNS_DIR, "PATTERN", nodes, edges)

    # 4-10. Parse all JSON brain sources
    _nodes_from_json_sources(nodes)

    # Partition nodes by type for cross-linking
    failure_nodes = {nid: n for nid, n in nodes.items() if n.get("type") == "FAILURE"}
    lesson_nodes = {nid: n for nid, n in nodes.items() if n.get("type") == "LESSON"}
    pattern_nodes = {nid: n for nid, n in nodes.items() if n.get("type") == "PATTERN"}
    decision_nodes = {nid: n for nid, n in nodes.items() if n.get("type") == "DECISION"}
    schema_nodes = {nid: n for nid, n in nodes.items() if n.get("type") == "SCHEMA"}
    intent_nodes = {nid: n for nid, n in nodes.items() if n.get("type") == "INTENT"}
    antipattern_nodes = {nid: n for nid, n in nodes.items() if n.get("type") == "ANTI_PATTERN"}

    # 5-10. Cross-link edges
    _cross_link(lesson_nodes, failure_nodes, "PREVENTS", 0.3, edges)
    _cross_link(pattern_nodes, failure_nodes, "PREVENTS", 0.3, edges)
    _cross_link(decision_nodes, lesson_nodes, "REQUIRES", 0.3, edges)
    _cross_link(lesson_nodes, failure_nodes, "RESOLVES", 0.25, edges,
                confidence_boost=0.1,
                filter_keywords=["fix", "solve", "resolv", "correct", "prevent", "avoid", "address"])
    _cross_link(pattern_nodes, lesson_nodes, "LEADS_TO", 0.3, edges)
    _cross_link(decision_nodes, pattern_nodes, "REQUIRES", 0.3, edges)
    _cross_link(schema_nodes, pattern_nodes, "REQUIRES", 0.3, edges)
    _cross_link(intent_nodes, lesson_nodes, "LEADS_TO", 0.3, edges)
    _cross_link(antipattern_nodes, failure_nodes, "PREVENTS", 0.3, edges)

    # 11. Temporal edges — link chronologically adjacent same-type items
    dated_nodes = [(nid, n) for nid, n in nodes.items() if n.get("date")]
    dated_nodes.sort(key=lambda x: x[1].get("date", ""))

    for i in range(len(dated_nodes) - 1):
        curr_id, curr = dated_nodes[i]
        next_id, next_node = dated_nodes[i + 1]
        if curr.get("type") == next_node.get("type"):
            sim = text_similarity(curr["text"], next_node["text"])
            if sim >= 0.2:
                edges.append({
                    "from": curr_id, "to": next_id,
                    "type": "LEADS_TO",
                    "confidence": round(sim, 2),
                    "source_node": "temporal_link",
                })

    # 12. Concept clustering — link concepts that frequently co-occur
    concept_sources = defaultdict(set)
    for edge in edges:
        if edge.get("source_node") and edge["source_node"] != "cross_link":
            from_node = nodes.get(edge["from"])
            to_node = nodes.get(edge["to"])
            if from_node and from_node.get("type") == "CONCEPT":
                concept_sources[edge["from"]].add(edge.get("source_node"))
            if to_node and to_node.get("type") == "CONCEPT":
                concept_sources[edge["to"]].add(edge.get("source_node"))

    concept_ids = [nid for nid, n in nodes.items() if n.get("type") == "CONCEPT"]
    for i, cid1 in enumerate(concept_ids):
        for cid2 in concept_ids[i + 1:]:
            shared = concept_sources[cid1] & concept_sources[cid2]
            if len(shared) >= 2:
                total = len(concept_sources[cid1] | concept_sources[cid2])
                confidence = round(len(shared) / total, 2) if total > 0 else 0.5
                edges.append({
                    "from": cid1, "to": cid2,
                    "type": "RELATED_TO",
                    "confidence": confidence,
                    "source_node": "concept_cluster",
                })

    # Deduplicate edges
    edge_keys = set()
    unique_edges = []
    for e in edges:
        key = f"{e['from']}-{e['type']}-{e['to']}"
        if key not in edge_keys:
            edge_keys.add(key)
            unique_edges.append(e)

    # Count node types
    node_type_counts = defaultdict(int)
    for n in nodes.values():
        node_type_counts[n.get("type", "?")] += 1

    graph_data = {
        "nodes": nodes,
        "edges": unique_edges,
        "metadata": {
            "built_at": _now_iso(),
            "node_count": len(nodes),
            "edge_count": len(unique_edges),
            "node_types": dict(node_type_counts),
        },
    }

    # Save
    GRAPH_FILE.parent.mkdir(parents=True, exist_ok=True)
    save_json(GRAPH_FILE, graph_data)

    if verbose:
        print(f"[GRAPH] Built: {len(nodes)} nodes, {len(unique_edges)} edges")
        for ntype, count in graph_data["metadata"]["node_types"].items():
            print(f"  {ntype}: {count}")

    return graph_data


def incremental_update(verbose=False) -> dict:
    """Update graph with entries that have changed since last build.

    Rebuilds the full graph (fast enough for PULSE scale).
    In the future, could track mtimes for incremental-only updates.
    """
    return build_graph(verbose=verbose)
