# Semantic fragment sanitizer — converts sentence-like rationale nodes into
# attributes on related nodes and removes invalid file_type values.
#
# Currently called from the skill merge scripts (skill-opencode.md,
# skill-codex.md) so that rationale text never leaks into the knowledge
# graph as standalone nodes. (Future: graphify.llm may wire this into
# _parse_llm_json / _merge_into for non-skill code paths; not done in
# this cycle.)
from __future__ import annotations

import json
import re
from pathlib import Path

from .build import _normalize_hyperedge_members

# Labels longer than this many characters, or containing >= this many words,
# are candidates for being sentence-like rationale text rather than entity names.
_RATIONALE_MIN_CHARS = 80
_RATIONALE_MIN_WORDS = 8

# Validation limits for untrusted semantic-fragment payloads. See
# validate_semantic_fragment(). Issue #825: returned-JSON normalization for
# OpenCode and Codex agents requires a Python enforcement boundary so a
# malicious or runaway agent response cannot exhaust memory or escape the
# graphify-out chunk directory via crafted node/edge IDs.
MAX_SEMANTIC_FRAGMENT_BYTES = 25 * 1024 * 1024
MAX_SEMANTIC_FRAGMENT_NODES = 10_000
MAX_SEMANTIC_FRAGMENT_EDGES = 100_000
MAX_SEMANTIC_FRAGMENT_HYPEREDGES = 10_000
MAX_SEMANTIC_HYPEREDGE_NODES = 256
MAX_SEMANTIC_ID_LENGTH = 256
VALID_SEMANTIC_FILE_TYPES = frozenset({"code", "document", "paper", "image", "rationale", "concept"})
_SEMANTIC_ID_RE = re.compile(r"^[A-Za-z0-9._:-]+$")


def validate_semantic_fragment(fragment: object) -> list[str]:
    """Return validation errors for an untrusted semantic extraction fragment.

    Empty list means valid. Called by skill merge code before
    sanitize_semantic_fragment() so malformed or malicious agent JSON is
    rejected before it touches the graph. Parameter is `object` (not `dict`)
    because we may be handed arbitrary deserialized JSON — the first check
    rejects anything that isn't a dict.
    """
    if not isinstance(fragment, dict):
        return ["fragment must be a JSON object"]

    errors: list[str] = []
    try:
        payload = json.dumps(fragment, ensure_ascii=False).encode("utf-8")
    except (TypeError, ValueError) as exc:
        return [f"fragment is not JSON-serializable: {exc}"]

    if len(payload) > MAX_SEMANTIC_FRAGMENT_BYTES:
        errors.append(f"payload is {len(payload)} bytes; max is {MAX_SEMANTIC_FRAGMENT_BYTES}")

    nodes = fragment.get("nodes", [])
    edges = fragment.get("edges", [])
    if not isinstance(nodes, list):
        errors.append("nodes must be a list")
        nodes = []
    elif len(nodes) > MAX_SEMANTIC_FRAGMENT_NODES:
        errors.append(f"nodes has {len(nodes)} entries; max is {MAX_SEMANTIC_FRAGMENT_NODES}")

    if not isinstance(edges, list):
        errors.append("edges must be a list")
        edges = []
    elif len(edges) > MAX_SEMANTIC_FRAGMENT_EDGES:
        errors.append(f"edges has {len(edges)} entries; max is {MAX_SEMANTIC_FRAGMENT_EDGES}")

    for i, node in enumerate(nodes):
        if not isinstance(node, dict):
            errors.append(f"nodes[{i}] must be an object")
            continue
        _validate_semantic_id(errors, f"nodes[{i}].id", node.get("id"))
        file_type = node.get("file_type")
        if file_type is not None and file_type not in VALID_SEMANTIC_FILE_TYPES:
            errors.append(
                f"nodes[{i}].file_type {file_type!r} is not one of "
                f"{sorted(VALID_SEMANTIC_FILE_TYPES)}"
            )  # validate file_type before any sanitize path can run

    for i, edge in enumerate(edges):
        if not isinstance(edge, dict):
            errors.append(f"edges[{i}] must be an object")
            continue
        _validate_semantic_id(errors, f"edges[{i}].source", edge.get("source"))
        _validate_semantic_id(errors, f"edges[{i}].target", edge.get("target"))

    hyperedges = fragment.get("hyperedges", [])
    if hyperedges is None:
        hyperedges = []
    if not isinstance(hyperedges, list):
        errors.append("hyperedges must be a list")
    else:
        if len(hyperedges) > MAX_SEMANTIC_FRAGMENT_HYPEREDGES:
            errors.append(
                f"hyperedges has {len(hyperedges)} entries; "
                f"max is {MAX_SEMANTIC_FRAGMENT_HYPEREDGES}"
            )
        for i, he in enumerate(hyperedges):
            if not isinstance(he, dict):
                errors.append(f"hyperedges[{i}] must be an object")
                continue
            # Fold alias member keys (members/node_ids) onto `nodes` (#1561) so
            # an alias-keyed hyperedge isn't rejected here for "nodes must be a
            # list" before it ever reaches build's normalization.
            _normalize_hyperedge_members(he)
            _validate_semantic_id(errors, f"hyperedges[{i}].id", he.get("id"))
            he_nodes = he.get("nodes")
            if not isinstance(he_nodes, list):
                errors.append(f"hyperedges[{i}].nodes must be a list")
                continue
            if len(he_nodes) > MAX_SEMANTIC_HYPEREDGE_NODES:
                errors.append(
                    f"hyperedges[{i}].nodes has {len(he_nodes)} entries; "
                    f"max is {MAX_SEMANTIC_HYPEREDGE_NODES}"
                )
            for j, ref in enumerate(he_nodes):
                _validate_semantic_id(errors, f"hyperedges[{i}].nodes[{j}]", ref)

    return errors


def load_validated_semantic_fragment(path: Path) -> tuple[dict | None, list[str]]:
    """Load and validate a semantic chunk, rejecting oversize files before parsing.

    The size guard runs against `path.stat().st_size` so an attacker-supplied
    multi-gigabyte chunk file cannot blow up memory at `read_text()` time.
    JSON decode errors are returned as validation errors rather than raised,
    so callers can `continue` past bad chunks without a try/except.
    """
    try:
        size = path.stat().st_size
    except OSError as exc:
        return None, [f"could not stat {path}: {exc}"]
    if size > MAX_SEMANTIC_FRAGMENT_BYTES:
        return None, [f"payload is {size} bytes; max is {MAX_SEMANTIC_FRAGMENT_BYTES}"]
    try:
        fragment = json.loads(path.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        return None, [f"invalid JSON: {exc}"]
    except OSError as exc:
        return None, [f"could not read {path}: {exc}"]
    errors = validate_semantic_fragment(fragment)
    return (None, errors) if errors else (fragment, [])


def _validate_semantic_id(errors: list[str], field: str, value: object) -> None:
    if not isinstance(value, str):
        errors.append(f"{field} must be a string")
        return
    if not value:
        errors.append(f"{field} must not be empty")
        return
    if len(value) > MAX_SEMANTIC_ID_LENGTH:
        errors.append(f"{field} is {len(value)} chars; max is {MAX_SEMANTIC_ID_LENGTH}")
    if "/" in value or "\\" in value or ".." in value:
        errors.append(f"{field} must not contain path separators or '..'")
    if not _SEMANTIC_ID_RE.fullmatch(value):
        errors.append(f"{field} contains unsupported characters")


def sanitize_semantic_fragment(fragment: dict) -> dict:
    """Clean up a semantic extraction fragment in-place.

    Operations:
    1. Removes nodes with ``file_type: "rationale"`` or ``file_type: "concept"``
       that were emitted by an LLM (these are not valid semantic entity types).
    2. Detects nodes whose label reads like a sentence / rationale paragraph
       AND that participate in a ``rationale_for`` edge, then converts the
       label into a ``rationale`` attribute on the target node and removes
       the source-node + its edges. The ``rationale_for`` edge signal applies
       regardless of the source node's ``file_type`` — sentence-like nodes
       with allowed types (``document``, ``code``) are still cleaned up when
       they're explicitly marked as rationale.
    3. Strips nodes whose only distinguishing field is the label itself
       (empty id — likely LLM hallucination).
    4. Filters hyperedges so they cannot reference removed or unknown node
       IDs after the cleanup passes above. A hyperedge with fewer than two
       surviving members is dropped.

    Returns the same dict for convenience.
    """
    _invalid_ft = frozenset({"rationale", "concept"})

    nodes: list[dict] = fragment.get("nodes", [])
    edges: list[dict] = fragment.get("edges", [])
    hyperedges: list[dict] = fragment.get("hyperedges", []) or []

    # ---- build lookup maps --------------------------------------------------
    node_by_id: dict[str, dict] = {}
    for n in nodes:
        nid = n.get("id", "")
        if nid:
            node_by_id[nid] = n

    # Pre-collect node IDs that source a `rationale_for` edge — these are
    # candidates for sentence-like cleanup even when file_type is allowed.
    rationale_for_sources: set[str] = set()
    for e in edges:
        if e.get("relation") == "rationale_for":
            src = e.get("source", "")
            if src:
                rationale_for_sources.add(src)

    # ---- pass 1: identify nodes to remove + rationale candidates -----------
    rationale_candidates: list[dict] = []
    remove_ids: set[str] = set()
    keep_nodes: list[dict] = []
    for n in nodes:
        nid = n.get("id", "")
        if not nid:
            # Node without an id cannot be referenced — discard.
            continue
        ft = n.get("file_type", "")
        label = n.get("label", "")
        if ft in _invalid_ft:
            # Explicitly-invalid file_type ("rationale" or "concept"): if
            # the label looks like a sentence we may convert to attribute.
            if _is_sentence_like_rationale_label(label):
                rationale_candidates.append(n)
            remove_ids.add(nid)
            continue
        if nid in rationale_for_sources and _is_sentence_like_rationale_label(label):
            # Allowed file_type, but the node sources a `rationale_for` edge
            # AND its label is sentence-like prose. Treat it as rationale
            # cleanup material rather than a real graph entity.
            rationale_candidates.append(n)
            remove_ids.add(nid)
            continue
        keep_nodes.append(n)

    # ---- pass 2: convert sentence-nodes → rationale attributes --------------
    # Only `rationale_for` edges propagate the rationale text. Other outgoing
    # edges (e.g. references, conceptually_related_to) are NOT used as
    # attribute-propagation paths — that would corrupt unrelated nodes by
    # attaching rationale meant for a different target.
    rationale_attrs: dict[str, list[str]] = {}
    for rn in rationale_candidates:
        rn_id = rn.get("id", "")
        text = rn.get("label", "").strip()
        for e in edges:
            if e.get("relation") != "rationale_for":
                continue
            if e.get("source") != rn_id:
                continue
            target_id = e.get("target")
            if target_id not in node_by_id or target_id in remove_ids:
                continue
            rationale_attrs.setdefault(target_id, []).append(text)

    for target_id, texts in rationale_attrs.items():
        if target_id in node_by_id and target_id not in remove_ids:
            _append_rationale_attr(node_by_id[target_id], texts)

    # ---- pass 3: strip edges referencing removed nodes ----------------------
    keep_edges: list[dict] = []
    for e in edges:
        src = e.get("source", "")
        tgt = e.get("target", "")
        if src in remove_ids or tgt in remove_ids:
            continue
        keep_edges.append(e)

    # ---- pass 4: filter hyperedges to surviving node IDs --------------------
    surviving_ids: set[str] = {n.get("id", "") for n in keep_nodes}
    surviving_ids.discard("")
    keep_hyperedges: list[dict] = []
    for he in hyperedges:
        if not isinstance(he, dict):
            continue
        # Fold alias member keys (members/node_ids) onto `nodes` (#1561) so an
        # alias-keyed hyperedge isn't silently dropped below for a missing
        # `nodes` list before build can canonicalize it.
        _normalize_hyperedge_members(he)
        he_nodes = he.get("nodes")
        if not isinstance(he_nodes, list):
            continue
        filtered = [ref for ref in he_nodes if isinstance(ref, str) and ref in surviving_ids]
        if len(filtered) < 2:
            # A hyperedge needs at least two surviving members to be meaningful.
            continue
        if len(filtered) != len(he_nodes):
            he = dict(he)
            he["nodes"] = filtered
        keep_hyperedges.append(he)

    fragment["nodes"] = keep_nodes
    fragment["edges"] = keep_edges
    fragment["hyperedges"] = keep_hyperedges
    return fragment


def _is_sentence_like_rationale_label(label: str) -> bool:
    """Return True if *label* looks like prose / rationale text rather than an
    entity or concept name.

    Heuristics (no false positives on short-concept-edge-cases):
    - Longer than *_RATIONALE_MIN_CHARS* chars, OR
    - At least *_RATIONALE_MIN_WORDS* whitespace-delimited tokens, AND
    - Contains at least one sentence-ending punctuation mark (``. ! ?``) or a
      colon (common in "Decision: ..." rationales).
    """
    if not label:
        return False
    label = label.strip()
    if len(label) < _RATIONALE_MIN_CHARS:
        word_count = len(label.split())
        if word_count < _RATIONALE_MIN_WORDS:
            return False
    # Must look like actual prose: has sentence-ending punctuation or a colon.
    return bool(re.search(r"[.!?:]", label))


def _append_rationale_attr(node: dict, texts: list[str]) -> None:
    """Append one or more rationale strings to *node*'s ``rationale`` attribute.

    If the attribute already exists the new texts are appended with a
    double-newline separator so downstream consumers can distinguish distinct
    rationale fragments.
    """
    existing = node.get("rationale", "")
    new_text = "\n\n".join(texts).strip()
    if existing:
        node["rationale"] = existing + "\n\n" + new_text
    else:
        node["rationale"] = new_text
