#!/usr/bin/env python3
"""Build a rollback-only creation plan for rich approved KB proposals.

This is the bridge between old Leo freeform approvals and the strict apply
worker. It is intentionally non-mutating: the output is a deterministic row
contract plus rollback rehearsal SQL, not an apply command.

The planner assigns stable UUIDs to proposed claim/source rows, preserves claim
bodies and caveats as source-backed evidence, emits only edges supported by the
live ``edge_type`` enum, and reports every fragment that still needs a reviewed
mapping. Production apply remains a separate reviewed step after clone/staging
rehearsal.
"""

from __future__ import annotations

import argparse
import hashlib
import json
import sys
import uuid
from pathlib import Path
from typing import Any

LIVE_EDGE_TYPES = {
    "supports",
    "challenges",
    "requires",
    "relates",
    "contradicts",
    "supersedes",
    "derives_from",
    "cites",
    "causes",
    "constrains",
    "accelerates",
}

LIVE_EVIDENCE_ROLES = {"grounds", "illustrates", "contradicts"}
LIVE_CLAIM_TYPES = {"empirical", "structural", "normative", "meta", "concept"}
LIVE_SOURCE_TYPES = {"paper", "article", "transcript", "observation", "dataset", "post", "dm", "other"}
CLAIM_TYPE_ALIASES = {
    "governance": "meta",
    "process": "meta",
}
SOURCE_TYPE_ALIASES = {
    "book": "other",
    "interview_article": "article",
    "official_site": "article",
    "official_site_foreword": "article",
    "podcast_show_notes": "article",
    "podcast_summary": "article",
    "podcast_transcript": "transcript",
    "url": "article",
    "document": "other",
    "note": "other",
    "proposal_body": "other",
}
DEFAULT_EVIDENCE_ROLE = "grounds"
BODY_EVIDENCE_ROLE = "illustrates"
BODY_SOURCE_TYPE = "proposal_body"


def _payload_list(payload: dict[str, Any], key: str) -> list[Any]:
    value = payload.get(key)
    return value if isinstance(value, list) else []


def _payload_dict(payload: dict[str, Any], key: str) -> dict[str, Any]:
    value = payload.get(key)
    return value if isinstance(value, dict) else {}


def _claim_candidate_items(payload: dict[str, Any]) -> list[dict[str, Any]]:
    items: list[dict[str, Any]] = []
    seen: set[str] = set()
    for key in ("claim_candidates", "proposed_claims"):
        for candidate in _payload_list(payload, key):
            if not isinstance(candidate, dict):
                continue
            claim_key = str(candidate.get("claim_key") or "").strip()
            dedupe_key = claim_key or json.dumps(candidate, sort_keys=True, ensure_ascii=False)
            if dedupe_key in seen:
                continue
            seen.add(dedupe_key)
            items.append(candidate)
    return items


def _stable_uuid(proposal_id: str, kind: str, key: str) -> str:
    return str(uuid.uuid5(uuid.NAMESPACE_URL, f"teleo:kb-rich-contract:{proposal_id}:{kind}:{key}"))


def _stable_hash(value: Any) -> str:
    payload = json.dumps(value, sort_keys=True, ensure_ascii=False)
    return hashlib.sha256(payload.encode("utf-8")).hexdigest()


def _is_uuid(value: Any) -> bool:
    try:
        uuid.UUID(str(value))
    except (TypeError, ValueError):
        return False
    return True


def _sql_literal(value: Any) -> str:
    if value is None:
        return "null"
    if isinstance(value, bool):
        return "true" if value else "false"
    if isinstance(value, (int, float)):
        return repr(value)
    return "'" + str(value).replace("'", "''") + "'"


def _sql_text_array(values: list[Any]) -> str:
    if not values:
        return "'{}'::text[]"
    return "array[" + ", ".join(_sql_literal(str(v)) for v in values) + "]::text[]"


def _compact_text(value: str, max_chars: int = 8000) -> str:
    return value if len(value) <= max_chars else value[: max_chars - 3] + "..."


def _source_excerpt(source: dict[str, Any]) -> str:
    excerpts = source.get("key_excerpts")
    if isinstance(excerpts, list):
        parts = [item.get("text", "") if isinstance(item, dict) else str(item) for item in excerpts]
        excerpt = "\n\n".join(part for part in parts if part).strip()
    else:
        excerpt = str(source.get("key_excerpt") or source.get("use") or excerpts or "").strip()

    metadata = {
        key: source.get(key)
        for key in (
            "title",
            "source_key",
            "source_quality",
            "usage_limits",
            "claim_id",
            "content_sha256",
        )
        if source.get(key) is not None
    }
    if metadata:
        return _compact_text(json.dumps({"metadata": metadata, "excerpt": excerpt}, ensure_ascii=False, sort_keys=True))
    return _compact_text(excerpt)


def _source_type(source: dict[str, Any]) -> str:
    if source.get("source_type"):
        return str(source["source_type"])
    if source.get("url"):
        return "url"
    if source.get("local_text") or source.get("local_snapshot") or source.get("storage_path"):
        return "document"
    return "note"


def _normalize_source_type(raw_type: Any) -> tuple[str, dict[str, Any] | None]:
    raw_text = str(raw_type or "other").strip() or "other"
    if raw_text in LIVE_SOURCE_TYPES:
        return raw_text, None
    mapped = SOURCE_TYPE_ALIASES.get(raw_text, "other")
    return mapped, {
        "from": raw_text,
        "to": mapped,
        "reason": "proposal source_type collapsed to live sources.source_type",
    }


def _claim_text(candidate: dict[str, Any]) -> str:
    headline = str(candidate.get("headline") or "").strip()
    body = str(candidate.get("body") or "").strip()
    text = str(candidate.get("text") or "").strip()
    return headline or body or text


def _normalize_claim_type(raw_type: Any) -> tuple[str, dict[str, Any] | None]:
    raw_text = str(raw_type or "structural").strip() or "structural"
    if raw_text in LIVE_CLAIM_TYPES:
        return raw_text, None
    parts = [part.strip() for part in raw_text.replace(":", "/").split("/") if part.strip()]
    for part in parts:
        if part in LIVE_CLAIM_TYPES:
            return part, {"from": raw_text, "to": part, "reason": "proposal subtype collapsed to live claims.type"}
        if part in CLAIM_TYPE_ALIASES:
            mapped = CLAIM_TYPE_ALIASES[part]
            return mapped, {
                "from": raw_text,
                "to": mapped,
                "reason": "proposal governance/process subtype mapped to meta",
            }
    return "meta", {
        "from": raw_text,
        "to": "meta",
        "reason": "unsupported proposal type requires review; meta used for rehearsal",
    }


def _claim_body_excerpt(proposal: dict[str, Any], candidate: dict[str, Any]) -> str:
    body_packet = {
        "proposal_id": proposal.get("id"),
        "source_ref": proposal.get("source_ref") or (proposal.get("payload") or {}).get("source_ref"),
        "reviewed_by_handle": proposal.get("reviewed_by_handle"),
        "review_note": proposal.get("review_note"),
        "claim_key": candidate.get("claim_key"),
        "headline": candidate.get("headline"),
        "body": candidate.get("body"),
        "text": candidate.get("text"),
        "candidate_review_note": candidate.get("review_note"),
        "evidence_tier": candidate.get("evidence_tier"),
        "needs_research": candidate.get("needs_research"),
        "evidence": candidate.get("evidence"),
        "edges": candidate.get("edges"),
    }
    return _compact_text(json.dumps(body_packet, ensure_ascii=False, sort_keys=True))


def _candidate_claim_rows(
    proposal: dict[str, Any], payload: dict[str, Any]
) -> tuple[list[dict[str, Any]], dict[str, str]]:
    rows: list[dict[str, Any]] = []
    key_to_id: dict[str, str] = {}
    proposal_id = str(proposal.get("id") or "")
    created_by = proposal.get("proposed_by_agent_id")
    for candidate in _claim_candidate_items(payload):
        claim_key = str(candidate.get("claim_key") or "").strip()
        if not claim_key:
            continue
        claim_id = _stable_uuid(proposal_id, "claim", claim_key)
        raw_type = candidate.get("type") or candidate.get("node_kind") or "structural"
        claim_type, type_normalization = _normalize_claim_type(raw_type)
        key_to_id[claim_key] = claim_id
        rows.append(
            {
                "id": claim_id,
                "claim_key": claim_key,
                "type": claim_type,
                "original_type": raw_type,
                "type_normalization": type_normalization,
                "text": _claim_text(candidate),
                "status": "open",
                "confidence": candidate.get("confidence"),
                "tags": candidate.get("tags") or [],
                "created_by": created_by,
            }
        )
    return rows, key_to_id


def _source_rows(
    proposal: dict[str, Any], payload: dict[str, Any], claim_rows: list[dict[str, Any]]
) -> tuple[list[dict[str, Any]], dict[str, str], dict[str, str]]:
    proposal_id = str(proposal.get("id") or "")
    created_by = proposal.get("proposed_by_agent_id")
    rows: list[dict[str, Any]] = []
    source_key_to_id: dict[str, str] = {}
    body_source_by_claim_key: dict[str, str] = {}

    for source in _payload_list(payload, "source_candidates"):
        if not isinstance(source, dict):
            continue
        source_key = str(source.get("source_key") or "").strip()
        if not source_key:
            continue
        source_id = _stable_uuid(proposal_id, "source", source_key)
        source_key_to_id[source_key] = source_id
        excerpt = _source_excerpt(source)
        raw_source_type = _source_type(source)
        source_type, source_type_normalization = _normalize_source_type(raw_source_type)
        supplied_hash = source.get("content_sha256") or source.get("hash")
        row = {
            "id": source_id,
            "source_key": source_key,
            "source_type": source_type,
            "original_source_type": raw_source_type,
            "source_type_normalization": source_type_normalization,
            "url": source.get("url"),
            "storage_path": source.get("local_text") or source.get("local_snapshot") or source.get("storage_path"),
            "excerpt": excerpt,
            "hash": str(supplied_hash).lower()
            if supplied_hash
            else _stable_hash({"proposal_id": proposal_id, "source_key": source_key, "excerpt": excerpt}),
            "created_by": created_by,
        }
        rows.append(row)

    claim_candidates = {
        str(candidate.get("claim_key") or ""): candidate for candidate in _claim_candidate_items(payload)
    }
    for claim in claim_rows:
        claim_key = claim["claim_key"]
        candidate = claim_candidates.get(claim_key)
        if not candidate:
            continue
        source_key = f"{claim_key}__proposal_body"
        source_id = _stable_uuid(proposal_id, "claim-body-source", claim_key)
        body_source_by_claim_key[claim_key] = source_id
        excerpt = _claim_body_excerpt(proposal, candidate)
        source_type, source_type_normalization = _normalize_source_type(BODY_SOURCE_TYPE)
        rows.append(
            {
                "id": source_id,
                "source_key": source_key,
                "source_type": source_type,
                "original_source_type": BODY_SOURCE_TYPE,
                "source_type_normalization": source_type_normalization,
                "url": None,
                "storage_path": None,
                "excerpt": excerpt,
                "hash": _stable_hash({"proposal_id": proposal_id, "source_key": source_key, "excerpt": excerpt}),
                "created_by": created_by,
            }
        )
    return rows, source_key_to_id, body_source_by_claim_key


def _resolve_claim_ref(ref: Any, claim_key_to_id: dict[str, str]) -> str | None:
    if not ref:
        return None
    ref_text = str(ref)
    return claim_key_to_id.get(ref_text) or ref_text


def _mapping_section(mappings: dict[str, Any] | None, key: str) -> dict[str, Any]:
    if not mappings:
        return {}
    value = mappings.get(key)
    return value if isinstance(value, dict) else {}


def _mapping_value(raw: Any, *, original: Any, default_reason: str) -> tuple[Any, dict[str, Any] | None]:
    if raw is None:
        return original, None
    if isinstance(raw, dict):
        mapped = raw.get("to") or raw.get("claim_id") or raw.get("target") or raw.get("edge_type")
        if mapped is None:
            return original, None
        return mapped, {"from": original, "to": mapped, "reason": raw.get("reason") or default_reason}
    return raw, {"from": original, "to": raw, "reason": default_reason}


def _map_lookup(
    mappings: dict[str, Any] | None, section: str, original: Any, *, default_reason: str
) -> tuple[Any, dict[str, Any] | None]:
    if original is None:
        return original, None
    raw = _mapping_section(mappings, section).get(str(original))
    return _mapping_value(raw, original=original, default_reason=default_reason)


def _edge_fragments(payload: dict[str, Any]) -> list[tuple[dict[str, Any], str | None]]:
    fragments: list[tuple[dict[str, Any], str | None]] = []
    for edge in _payload_list(payload, "proposed_supersession_edges"):
        if isinstance(edge, dict):
            fragments.append((edge, edge.get("from") or edge.get("from_claim_key")))
    for candidate in _claim_candidate_items(payload):
        default_from = candidate.get("claim_key")
        for edge in _payload_list(candidate, "edges"):
            if isinstance(edge, dict):
                fragments.append((edge, default_from))
    return fragments


def _plan_edges(
    payload: dict[str, Any],
    claim_key_to_id: dict[str, str],
    mappings: dict[str, Any] | None = None,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
    planned: list[dict[str, Any]] = []
    blocked: list[dict[str, Any]] = []
    seen: set[tuple[str, str, str]] = set()
    for edge, default_from_key in _edge_fragments(payload):
        original_edge_type = edge.get("edge_type")
        edge_type, edge_type_mapping = _map_lookup(
            mappings,
            "edge_type_aliases",
            original_edge_type,
            default_reason="reviewed mapping overlay converted proposal edge_type to live edge_type",
        )
        from_ref = edge.get("from_claim") or edge.get("from_claim_id") or edge.get("from") or default_from_key
        target = (
            edge.get("to_claim")
            or edge.get("to_claim_id")
            or edge.get("target_claim_id")
            or edge.get("to_existing_claim")
            or edge.get("target")
        )
        mapped_target, target_mapping = _map_lookup(
            mappings,
            "target_claim_mappings",
            target,
            default_reason="reviewed mapping overlay converted external/context target to canonical claim",
        )
        from_claim = _resolve_claim_ref(from_ref, claim_key_to_id)
        to_claim = _resolve_claim_ref(mapped_target, claim_key_to_id)
        reasons = []
        if edge_type not in LIVE_EDGE_TYPES:
            reasons.append(f"unsupported edge_type {edge_type!r}")
        if not from_claim:
            reasons.append("missing from_claim")
        if not to_claim:
            reasons.append("missing to_claim")
        if from_claim and not _is_uuid(from_claim):
            reasons.append("from_claim does not resolve to a UUID")
        if to_claim and not _is_uuid(to_claim):
            reasons.append("to_claim does not resolve to a UUID")
        if (
            target
            and str(target) not in claim_key_to_id
            and not _is_uuid(target)
            and not edge.get("target_claim_id")
            and not edge.get("to_claim_id")
            and target_mapping is None
        ):
            reasons.append("target is external/context text and needs a reviewed canonical mapping")
        if from_claim and to_claim and from_claim == to_claim:
            reasons.append("self-edge refused")
        if reasons:
            blocked.append(
                {
                    "kind": "edge",
                    "fragment": edge,
                    "default_from_key": default_from_key,
                    "edge_type_mapping": edge_type_mapping,
                    "target_mapping": target_mapping,
                    "resolved": {"from_claim": from_claim, "to_claim": to_claim, "edge_type": edge_type},
                    "reasons": reasons,
                }
            )
            continue
        key = (from_claim, to_claim, str(edge_type))
        if key in seen:
            continue
        seen.add(key)
        planned.append(
            {
                "from_claim": from_claim,
                "to_claim": to_claim,
                "edge_type": edge_type,
                "weight": edge.get("weight"),
                "edge_type_original": original_edge_type,
                "edge_type_mapping": edge_type_mapping,
                "target_original": target,
                "target_mapping": target_mapping,
                "source_fragment": edge,
            }
        )
    return planned, blocked


def _plan_evidence(
    payload: dict[str, Any],
    claim_key_to_id: dict[str, str],
    source_key_to_id: dict[str, str],
    body_source_by_claim_key: dict[str, str],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
    planned: list[dict[str, Any]] = []
    blocked: list[dict[str, Any]] = []
    seen: set[tuple[str, str, str]] = set()

    def add_evidence(claim_id: str, source_id: str, role: str, fragment: dict[str, Any]) -> None:
        key = (claim_id, source_id, role)
        if key not in seen:
            seen.add(key)
            planned.append(
                {
                    "claim_id": claim_id,
                    "source_id": source_id,
                    "role": role,
                    "weight": None,
                    "source_fragment": fragment,
                }
            )

    for candidate in _claim_candidate_items(payload):
        claim_key = str(candidate.get("claim_key") or "")
        claim_id = claim_key_to_id.get(claim_key)
        if not claim_id:
            continue
        body_source_id = body_source_by_claim_key.get(claim_key)
        if body_source_id:
            add_evidence(claim_id, body_source_id, BODY_EVIDENCE_ROLE, {"source_key": f"{claim_key}__proposal_body"})
        for evidence in _payload_list(candidate, "evidence"):
            if isinstance(evidence, dict):
                source_key = evidence.get("source_key")
                source_id = evidence.get("source_id") or source_key_to_id.get(str(source_key or ""))
                role = evidence.get("role") or DEFAULT_EVIDENCE_ROLE
                fragment = evidence
            else:
                source_key = str(evidence)
                source_id = source_key_to_id.get(source_key)
                role = DEFAULT_EVIDENCE_ROLE
                fragment = {"source_key": source_key}
            reasons = []
            if role not in LIVE_EVIDENCE_ROLES:
                reasons.append(f"unsupported evidence_role {role!r}")
            if not source_id:
                reasons.append("missing source_id; source_key not present in source_candidates")
            if reasons:
                blocked.append({"kind": "evidence", "claim_key": claim_key, "fragment": fragment, "reasons": reasons})
                continue
            add_evidence(claim_id, source_id, role, fragment)
    return planned, blocked


def _sanitize_framework_powers(powers: Any) -> tuple[Any, list[dict[str, Any]]]:
    if not isinstance(powers, list):
        return powers, []
    sanitized: list[Any] = []
    deferred: list[dict[str, Any]] = []
    for power in powers:
        if not isinstance(power, dict):
            sanitized.append(power)
            continue
        power_copy = dict(power)
        examples = power_copy.get("examples")
        if isinstance(examples, list):
            kept_examples: list[Any] = []
            for example in examples:
                if "teleo" in str(example).lower():
                    deferred.append(
                        {
                            "power": power_copy.get("name"),
                            "reason": "Teleo application examples are deferred from the framework canonical row.",
                        }
                    )
                else:
                    kept_examples.append(example)
            power_copy["examples"] = kept_examples
        sanitized.append(power_copy)
    return sanitized, deferred


def _sanitize_conceptual_neighborhood(neighborhood: Any) -> tuple[Any, int]:
    if not isinstance(neighborhood, dict):
        return neighborhood, 0
    clean = dict(neighborhood)
    candidates = clean.get("additional_moat_neighborhood_candidates")
    removed = 0
    if isinstance(candidates, list):
        kept: list[Any] = []
        for candidate in candidates:
            if isinstance(candidate, dict) and str(candidate.get("edge_type") or "").lower() == "defer":
                removed += 1
                continue
            kept.append(candidate)
        clean["additional_moat_neighborhood_candidates"] = kept
    return clean, removed


def _reasoning_tool_rows(proposal: dict[str, Any], payload: dict[str, Any]) -> list[dict[str, Any]]:
    tool = _payload_dict(payload, "proposed_reasoning_tool_node")
    name = str(tool.get("name") or "").strip()
    if not name:
        return []
    proposal_id = str(proposal.get("id") or "")
    sanitized_powers, deferred_examples = _sanitize_framework_powers(payload.get("seven_powers"))
    sanitized_neighborhood, deferred_neighborhood_count = _sanitize_conceptual_neighborhood(
        payload.get("conceptual_neighborhood")
    )
    description_packet = {
        "summary": tool.get("summary"),
        "core_test": tool.get("core_test"),
        "use_when": tool.get("use_when"),
        "do_not_use_when": tool.get("do_not_use_when"),
        "position_in_leo_strategy_stack": tool.get("position_in_leo_strategy_stack"),
        "review_gate": tool.get("review_gate"),
        "seven_powers": sanitized_powers,
        "power_lifecycle_map": payload.get("power_lifecycle_map"),
        "conceptual_neighborhood": sanitized_neighborhood,
        "approval_change_log": payload.get("approval_change_log"),
        "deferred_application_examples_removed": len(deferred_examples),
        "deferred_neighborhood_candidates_removed": deferred_neighborhood_count,
        "deferred_boundary": (
            "Teleo-specific application/self-positioning examples are not canonicalized in this reasoning_tool row; "
            "stage them separately if reopened and approved."
        ),
        "source_ref": proposal.get("source_ref") or payload.get("source_ref"),
        "review_note": proposal.get("review_note"),
    }
    category = str(tool.get("category") or "strategy_framework").strip() or "strategy_framework"
    return [
        {
            "id": _stable_uuid(proposal_id, "reasoning-tool", name.lower()),
            "agent_id": proposal.get("proposed_by_agent_id"),
            "name": name,
            "description": _compact_text(json.dumps(description_packet, ensure_ascii=False, sort_keys=True), 20000),
            "category": category,
            "source_fragment": tool,
        }
    ]


def _reasoning_tool_insert_sql(row: dict[str, Any]) -> str:
    return f"""insert into public.reasoning_tools (id, agent_id, name, description, category)
select {_sql_literal(row["id"])}::uuid, {_sql_literal(row["agent_id"])}::uuid,
       {_sql_literal(row["name"])}, {_sql_literal(row["description"])}, {_sql_literal(row["category"])}
 where not exists (select 1 from public.reasoning_tools where id = {_sql_literal(row["id"])}::uuid);"""


def _claim_insert_sql(row: dict[str, Any]) -> str:
    return f"""insert into public.claims (id, type, text, status, confidence, tags, created_by)
select {_sql_literal(row["id"])}::uuid, {_sql_literal(row["type"])}, {_sql_literal(row["text"])},
       {_sql_literal(row["status"])}, {_sql_literal(row["confidence"])}, {_sql_text_array(row["tags"])},
       {_sql_literal(row["created_by"])}::uuid
 where not exists (select 1 from public.claims where id = {_sql_literal(row["id"])}::uuid);"""


def _source_insert_sql(row: dict[str, Any]) -> str:
    return f"""insert into public.sources (id, source_type, url, storage_path, excerpt, hash, created_by)
select {_sql_literal(row["id"])}::uuid, {_sql_literal(row["source_type"])}, {_sql_literal(row["url"])},
       {_sql_literal(row["storage_path"])}, {_sql_literal(row["excerpt"])}, {_sql_literal(row["hash"])},
       {_sql_literal(row["created_by"])}::uuid
 where not exists (select 1 from public.sources where id = {_sql_literal(row["id"])}::uuid);"""


def _edge_insert_sql(row: dict[str, Any]) -> str:
    return f"""insert into public.claim_edges (from_claim, to_claim, edge_type, weight)
select {_sql_literal(row["from_claim"])}::uuid, {_sql_literal(row["to_claim"])}::uuid,
       {_sql_literal(row["edge_type"])}::edge_type, {_sql_literal(row["weight"])}
 where not exists (
   select 1 from public.claim_edges
    where from_claim = {_sql_literal(row["from_claim"])}::uuid
      and to_claim = {_sql_literal(row["to_claim"])}::uuid
      and edge_type = {_sql_literal(row["edge_type"])}::edge_type);"""


def _evidence_insert_sql(row: dict[str, Any]) -> str:
    return f"""insert into public.claim_evidence (claim_id, source_id, role, weight)
select {_sql_literal(row["claim_id"])}::uuid, {_sql_literal(row["source_id"])}::uuid,
       {_sql_literal(row["role"])}::evidence_role, {_sql_literal(row["weight"])}
 where not exists (
   select 1 from public.claim_evidence
    where claim_id = {_sql_literal(row["claim_id"])}::uuid
      and source_id = {_sql_literal(row["source_id"])}::uuid
      and role = {_sql_literal(row["role"])}::evidence_role);"""


def build_rollback_sql(plan: dict[str, Any]) -> str:
    statements: list[str] = [
        "begin;",
        "set local statement_timeout = '30s';",
        "-- rollback-only rehearsal: do not replace ROLLBACK with COMMIT without separate review",
    ]
    statements.extend(_reasoning_tool_insert_sql(row) for row in plan["planned_rows"]["reasoning_tools"])
    statements.extend(_claim_insert_sql(row) for row in plan["planned_rows"]["claims"])
    statements.extend(_source_insert_sql(row) for row in plan["planned_rows"]["sources"])
    statements.extend(_evidence_insert_sql(row) for row in plan["planned_rows"]["claim_evidence"])
    statements.extend(_edge_insert_sql(row) for row in plan["planned_rows"]["claim_edges"])
    statements.append("rollback;")
    return "\n\n".join(statements) + "\n"


def build_creation_plan(proposal: dict[str, Any], mappings: dict[str, Any] | None = None) -> dict[str, Any]:
    payload = proposal.get("payload") if isinstance(proposal.get("payload"), dict) else {}
    proposal_id = str(proposal.get("id") or "")
    claim_rows, claim_key_to_id = _candidate_claim_rows(proposal, payload)
    source_rows, source_key_to_id, body_source_by_claim_key = _source_rows(proposal, payload, claim_rows)
    edge_rows, blocked_edges = _plan_edges(payload, claim_key_to_id, mappings)
    evidence_rows, blocked_evidence = _plan_evidence(
        payload, claim_key_to_id, source_key_to_id, body_source_by_claim_key
    )
    reasoning_tool_rows = _reasoning_tool_rows(proposal, payload)

    old_claim = _payload_dict(payload, "old_claim")
    concept_map = _payload_dict(payload, "proposed_concept_map_dependency")
    unresolved_design_decisions: list[dict[str, Any]] = []
    if old_claim.get("id") and len(claim_rows) > 1:
        unresolved_design_decisions.append(
            {
                "decision": "many_successor_supersession",
                "reason": "public.claims.superseded_by stores one UUID, but this proposal splits one claim into multiple successors.",
                "safe_default": "Do not update public.claims.superseded_by in this plan; use reviewed edges or an aggregate successor node.",
            }
        )
    if concept_map:
        unresolved_design_decisions.append(
            {
                "decision": "concept_map_storage",
                "reason": "The proposal includes a concept-map node but the live canonical claim/source contract has no first-class concept-map table in this apply lane.",
                "safe_default": "Keep concept-map content as source-backed context until a reviewed schema lane exists.",
            }
        )

    plan = {
        "proposal_id": proposal_id,
        "status": proposal.get("status"),
        "proposal_type": proposal.get("proposal_type"),
        "reviewed_by_handle": proposal.get("reviewed_by_handle"),
        "read_only": True,
        "runtime_change": False,
        "direct_production_apply_ready": False,
        "rehearsal_scope": "rollback_only_sql",
        "mapping_overlay_applied": bool(mappings),
        "mapping_overlay": mappings or {},
        "generated_ids": {
            "reasoning_tools": {row["name"]: row["id"] for row in reasoning_tool_rows},
            "claims": claim_key_to_id,
            "sources": source_key_to_id,
            "claim_body_sources": body_source_by_claim_key,
        },
        "planned_rows": {
            "reasoning_tools": reasoning_tool_rows,
            "claims": claim_rows,
            "sources": source_rows,
            "claim_evidence": evidence_rows,
            "claim_edges": edge_rows,
        },
        "blocked_fragments": blocked_edges + blocked_evidence,
        "unresolved_design_decisions": unresolved_design_decisions,
        "next_action": (
            "Run rollback SQL in a clone/staging DB, review blocked edge/context mappings, then create strict child "
            "proposals or a separately approved claim/source creation apply tool. Do not apply this directly to production."
        ),
    }
    plan["rollback_rehearsal_sql"] = build_rollback_sql(plan)
    return plan


def load_from_input(path: str) -> list[dict[str, Any]]:
    data = json.loads(Path(path).read_text(encoding="utf-8"))
    if isinstance(data, list):
        return data
    if isinstance(data, dict) and isinstance(data.get("proposals"), list):
        return data["proposals"]
    if isinstance(data, dict):
        return [data]
    raise SystemExit(f"unsupported proposal JSON shape in {path}")


def parse_args(argv: list[str]) -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--input-json", required=True, help="proposal JSON or list of proposal JSON objects")
    parser.add_argument("--mapping-json", help="optional reviewed mapping overlay for edge types and external targets")
    parser.add_argument("--sql-out", help="optional path for concatenated rollback rehearsal SQL")
    return parser.parse_args(argv)


def main(argv: list[str] | None = None) -> int:
    args = parse_args(sys.argv[1:] if argv is None else argv)
    mappings = json.loads(Path(args.mapping_json).read_text(encoding="utf-8")) if args.mapping_json else None
    plans = [build_creation_plan(proposal, mappings) for proposal in load_from_input(args.input_json)]
    if args.sql_out:
        Path(args.sql_out).write_text("\n\n".join(plan["rollback_rehearsal_sql"] for plan in plans), encoding="utf-8")
    print(json.dumps({"creation_plans": plans}, indent=2, sort_keys=True))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
