"""Structural diff of two model summaries (docs/provenance/00 section 4, Phase 3b).

Pure / QGIS-free so it unit-tests cleanly. Consumes the deterministic
``model_summary`` dicts produced by the engines' ``get_model_summary`` command
and reports what changed between two versions of the same model — added /
removed / renamed nodes, weight changes (CDP), and **predicted mapping
breakage** (field mappings that target a node which no longer exists).
"""

from __future__ import annotations

from typing import Any, Dict, List, Optional


def _norm_name(value: Any) -> str:
    return str(value if value is not None else "").strip().lower()


# Per-kind section config: (summary_key, display_label, stable_id_field).
_SECTIONS = {
    "cdp": [
        ("criteria", "Criteria", "hblock_id"),
        ("lowest_criteria", "Lowest criteria", "hblock_id"),
        ("alternatives", "Alternatives", "name"),
        ("rules", "Rules", "name"),
    ],
    "netweaver": [
        ("topics", "Topics", "id"),
        ("data_links", "Data links", "id"),
    ],
}

# Attributes worth reporting as "changed" when the id is stable across versions.
_TRACKED_ATTRS = ("level", "parent_hblock_id", "is_lowest", "expected_field", "parent_id")


def _index(items: Optional[List[Any]], id_field: str) -> Dict[str, Dict[str, Any]]:
    out: Dict[str, Dict[str, Any]] = {}
    for item in items or []:
        if not isinstance(item, dict):
            continue
        key = item.get(id_field)
        if key is None:
            key = item.get("name")
        if key is None:
            continue
        out[str(key)] = item
    return out


def _diff_section(old_items, new_items, id_field: str) -> Dict[str, Any]:
    old_index = _index(old_items, id_field)
    new_index = _index(new_items, id_field)
    old_ids, new_ids = set(old_index), set(new_index)

    added = [new_index[i] for i in (new_ids - old_ids)]
    removed = [old_index[i] for i in (old_ids - new_ids)]
    renamed: List[Dict[str, Any]] = []
    changed: List[Dict[str, Any]] = []

    for i in (old_ids & new_ids):
        old_item, new_item = old_index[i], new_index[i]
        old_name = str(old_item.get("name", ""))
        new_name = str(new_item.get("name", ""))
        if old_name != new_name:
            renamed.append({"id": i, "old_name": old_name, "new_name": new_name})
        attrs = {}
        for key in _TRACKED_ATTRS:
            if (key in old_item or key in new_item) and old_item.get(key) != new_item.get(key):
                attrs[key] = {"old": old_item.get(key), "new": new_item.get(key)}
        if attrs:
            changed.append({"id": i, "name": new_name or old_name, "attrs": attrs})

    def _key(item):
        return str(item.get(id_field, item.get("name", "")))

    added.sort(key=_key)
    removed.sort(key=_key)
    renamed.sort(key=lambda x: x["id"])
    changed.sort(key=lambda x: x["id"])

    return {
        "added": added,
        "removed": removed,
        "renamed": renamed,
        "changed": changed,
        "common_count": len(old_ids & new_ids),
    }


def _diff_weights(old_weights, new_weights) -> List[Dict[str, Any]]:
    def _key(weight):
        return f"{weight.get('criterion_id')}->{weight.get('sub_criterion_id')}"

    old_index = {_key(w): w for w in (old_weights or []) if isinstance(w, dict)}
    new_index = {_key(w): w for w in (new_weights or []) if isinstance(w, dict)}
    changes = []
    for key in sorted(set(old_index) & set(new_index)):
        old_value = old_index[key].get("weight")
        new_value = new_index[key].get("weight")
        if old_value != new_value:
            changes.append({"key": key, "old": old_value, "new": new_value})
    return changes


def _targetable_names(summary: Dict[str, Any]) -> set:
    names = set()
    for section in ("criteria", "lowest_criteria", "topics", "data_links"):
        for item in summary.get(section, []) or []:
            if isinstance(item, dict) and item.get("name"):
                names.add(_norm_name(item["name"]))
    return names


def predicted_mapping_breakage(
    old_summary: Dict[str, Any],
    new_summary: Dict[str, Any],
    field_mappings: Optional[List[Any]],
) -> List[Dict[str, str]]:
    """Field mappings whose target node exists in the old model but not the new one."""
    gone = _targetable_names(old_summary) - _targetable_names(new_summary)
    breaks = []
    for mapping in field_mappings or []:
        if not isinstance(mapping, dict):
            continue
        target = mapping.get("target_variable") or mapping.get("target") or ""
        if target and _norm_name(target) in gone:
            breaks.append({
                "source_column": str(mapping.get("source_column", "")),
                "target_variable": str(target),
            })
    return breaks


def diff_model_summaries(
    old_summary: Optional[Dict[str, Any]],
    new_summary: Optional[Dict[str, Any]],
    *,
    field_mappings: Optional[List[Any]] = None,
) -> Dict[str, Any]:
    """Diff two model summaries; returns sections, weight changes, mapping breakage."""
    old_summary = old_summary or {}
    new_summary = new_summary or {}
    kind = new_summary.get("model_kind") or old_summary.get("model_kind") or ""
    section_cfg = _SECTIONS.get(kind, _SECTIONS["netweaver"])

    sections = []
    for key, label, id_field in section_cfg:
        if key not in old_summary and key not in new_summary:
            continue
        section = _diff_section(old_summary.get(key, []), new_summary.get(key, []), id_field)
        section["key"] = key
        section["label"] = label
        sections.append(section)

    weight_changes = _diff_weights(old_summary.get("weights"), new_summary.get("weights")) if kind == "cdp" else []
    mapping_breakage = (
        predicted_mapping_breakage(old_summary, new_summary, field_mappings) if field_mappings else []
    )

    name_changed = str(old_summary.get("name", "")) != str(new_summary.get("name", ""))
    has_changes = bool(
        name_changed
        or weight_changes
        or mapping_breakage
        or any(s["added"] or s["removed"] or s["renamed"] or s["changed"] for s in sections)
    )

    return {
        "model_kind": kind,
        "name_old": str(old_summary.get("name", "")),
        "name_new": str(new_summary.get("name", "")),
        "name_changed": name_changed,
        "sections": sections,
        "weight_changes": weight_changes,
        "mapping_breakage": mapping_breakage,
        "has_changes": has_changes,
    }
