"""
Belief Revision System - Revision Operations

Shadow imports, hypothesis testing, and belief revision mechanics.
Implements non-destructive evaluation via world forking.
"""
from __future__ import annotations

import datetime
import json
import traceback
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Tuple

from .core import (
    content_hash, canonical_json, to_dict,
    Maturity, estimate_maturity
)
from .storage import CASStore


# =============================================================================
# Proposal Normalization
# =============================================================================

VALID_STATUSES = {"new", "accepted", "rejected", "archived", "queued", "shadow_applied", "promoted"}


def now_utc() -> str:
    """Current UTC timestamp in ISO format."""
    return datetime.datetime.utcnow().isoformat() + "Z"


def make_analog_proposal(payload: Dict[str, Any]) -> Dict[str, Any]:
    """
    Normalize an analog proposal dict and assign a stable content-based ID.

    Args:
        payload: Raw proposal data

    Returns:
        Normalized proposal with stable ID
    """
    base = {
        "kind": "AnalogProposal",
        "left_domain": payload["left_domain"],
        "left_pattern_id": payload["left_pattern_id"],
        "left_name": payload.get("left_name", ""),
        "right_domain": payload["right_domain"],
        "right_pattern_id": payload["right_pattern_id"],
        "right_name": payload.get("right_name", ""),
        "score": float(payload.get("score", 0.0)),
        "shared_tokens": list(payload.get("shared_tokens", [])),
        "shared_operators": list(payload.get("shared_operators", [])),
        "created_utc": payload.get("created_utc") or now_utc(),
    }
    # ID is stable across time (excludes created_utc)
    base["id"] = content_hash({k: base[k] for k in base if k != "created_utc"})
    return base


# =============================================================================
# Shadow Import
# =============================================================================

def shadow_import_analog(
    store: CASStore,
    proposal_id: str,
    target_domain: str,
    from_world: str,
    to_world: str,
    mode: str = "union_ops"
) -> str:
    """
    Shadow-import an AnalogProposal into a forked world.

    Creates a derived Pattern in the target domain that incorporates
    knowledge from the analog source domain.

    Args:
        store: Storage backend
        proposal_id: ID of the AnalogProposal
        target_domain: Domain to import into
        from_world: Source world version
        to_world: Target world version (will be created/replaced)
        mode: Import mode - "union_ops" (default), "replace_ops", "append_only"

    Returns:
        Hash of the new world bundle
    """
    # Create fork
    store.fork_world(target_domain, from_world, to_world,
                     notes=f"shadow import from proposal {proposal_id}")

    # Get proposal
    prop = store.get_proposal(proposal_id)
    pobj = prop["object"]["json"]

    # Determine which side is target domain
    if pobj.get("left_domain") == target_domain:
        target_pattern_id = pobj.get("left_pattern_id")
        other_pattern_id = pobj.get("right_pattern_id")
        other_name = pobj.get("right_name")
    elif pobj.get("right_domain") == target_domain:
        target_pattern_id = pobj.get("right_pattern_id")
        other_pattern_id = pobj.get("left_pattern_id")
        other_name = pobj.get("left_name")
    else:
        raise ValueError(f"Proposal does not include target domain {target_domain}")

    # Load patterns from storage
    cur = store._conn.cursor()
    rows = cur.execute("SELECT json FROM objects WHERE kind='Pattern'").fetchall()
    patterns = [json.loads(r[0]) for r in rows]
    by_id = {p.get("id"): p for p in patterns if p.get("id")}

    target_pattern = by_id.get(target_pattern_id)
    other_pattern = by_id.get(other_pattern_id)

    if not target_pattern:
        raise KeyError(f"Target pattern not found: {target_pattern_id}")
    if not other_pattern:
        raise KeyError(f"Source pattern not found: {other_pattern_id}")

    # Create new derived pattern
    new_pattern = dict(target_pattern)
    new_pattern["name"] = f"{target_pattern.get('name', '')} [shadow-import]"
    new_pattern["summary"] = (
        target_pattern.get("summary", "") +
        f" | Shadow-imported operators from analog: {other_name}"
    ).strip()

    # Merge operators based on mode
    target_ops = set(target_pattern.get("operators") or [])
    other_ops = set(other_pattern.get("operators") or [])

    if mode == "union_ops":
        merged_ops = target_ops | other_ops
    elif mode == "replace_ops":
        merged_ops = other_ops
    elif mode == "append_only":
        merged_ops = target_ops | (other_ops - target_ops)
    else:
        merged_ops = target_ops | other_ops

    new_pattern["operators"] = sorted({str(x) for x in merged_ops if x})

    # Generate stable ID for new pattern
    new_pattern["id"] = content_hash({k: new_pattern[k] for k in new_pattern if k != "id"})

    # Store new pattern
    store.put_object("Pattern", new_pattern)

    # Update world bundle with new pattern
    w = store.get_world(target_domain, to_world)["json"]
    pattern_ids = list(w.get("pattern_ids", []) or [])
    if new_pattern["id"] not in pattern_ids:
        pattern_ids.append(new_pattern["id"])
    w["pattern_ids"] = pattern_ids
    w["notes"] = (w.get("notes", "") + f" | shadow imported proposal {proposal_id}").strip()
    w["created_utc"] = now_utc()

    # Store updated world
    h = content_hash(w)
    js = canonical_json(w)
    store._conn.execute(
        "INSERT OR IGNORE INTO objects(hash, kind, json) VALUES(?,?,?)",
        (h, "WorldBundle", js)
    )
    store._conn.execute(
        "INSERT OR REPLACE INTO worlds(domain_id, version_label, hash, created_utc) VALUES(?,?,?,?)",
        (target_domain, to_world, h, w["created_utc"])
    )
    store._conn.commit()

    return h


# =============================================================================
# Evaluation
# =============================================================================

@dataclass
class EvalResult:
    """Result of evaluating a proposal."""
    proposal_id: str
    target_domain: str
    from_world: str
    to_world: str
    tests_run: int
    failures: int
    maturity_before: float
    maturity_after: float
    delta: float
    notes: str
    outcome: str = ""  # "accepted", "rejected", "error"

    def to_json(self) -> str:
        """Serialize to JSON."""
        return json.dumps({
            "proposal_id": self.proposal_id,
            "target_domain": self.target_domain,
            "from_world": self.from_world,
            "to_world": self.to_world,
            "tests_run": self.tests_run,
            "failures": self.failures,
            "maturity_before": self.maturity_before,
            "maturity_after": self.maturity_after,
            "delta": self.delta,
            "notes": self.notes,
            "outcome": self.outcome,
        }, ensure_ascii=False)


# Type for smoke test functions
SmokeTestFn = Callable[[CASStore, str, str], Tuple[int, int, List[str]]]


def evaluate_proposal(
    store: CASStore,
    proposal_id: str,
    target_domain: str,
    base_world: str = "green",
    shadow_world: str = "_shadow_eval",
    smoke_test: Optional[SmokeTestFn] = None,
    acceptance_threshold: float = 0.0
) -> EvalResult:
    """
    Evaluate a proposal via shadow import and smoke testing.

    Process:
    1. Shadow import the proposal into a forked world
    2. Run smoke tests on both worlds
    3. Compute maturity delta
    4. Accept/reject based on threshold

    Args:
        store: Storage backend
        proposal_id: Proposal to evaluate
        target_domain: Domain to test in
        base_world: Reference world version
        shadow_world: Shadow world version for testing
        smoke_test: Optional domain-specific test function
        acceptance_threshold: Minimum delta for acceptance

    Returns:
        EvalResult with outcome
    """
    notes: List[str] = []

    try:
        # Shadow import
        h = shadow_import_analog(
            store,
            proposal_id=proposal_id,
            target_domain=target_domain,
            from_world=base_world,
            to_world=shadow_world
        )
        notes.append(f"shadow_import_hash={h[:12]}")

        # Get world states
        base = store.get_world(target_domain, base_world)["json"]
        shadow = store.get_world(target_domain, shadow_world)["json"]

        base_counts = (
            len(base.get("node_ids", [])),
            len(base.get("edge_ids", [])),
            len(base.get("pattern_ids", []))
        )
        shadow_counts = (
            len(shadow.get("node_ids", [])),
            len(shadow.get("edge_ids", [])),
            len(shadow.get("pattern_ids", []))
        )

        notes.append(f"base_counts(nodes,edges,patterns)={base_counts}")
        notes.append(f"shadow_counts(nodes,edges,patterns)={shadow_counts}")

        # Run smoke tests
        if smoke_test:
            tests_run, failures, test_msgs = smoke_test(store, target_domain, shadow_world)
            notes.extend(test_msgs)
        else:
            tests_run, failures, test_msgs = (0, 0, [])

        # Compute maturity
        m_before = estimate_maturity(
            num_nodes=base_counts[0],
            num_edges=base_counts[1],
            num_tests=max(1, tests_run),
            num_failures=failures
        ).score

        m_after = estimate_maturity(
            num_nodes=shadow_counts[0],
            num_edges=shadow_counts[1],
            num_tests=max(1, tests_run),
            num_failures=failures
        ).score

        # Simple heuristic: patterns increased without edge explosion
        pattern_gain = shadow_counts[2] - base_counts[2]
        edge_gain = shadow_counts[1] - base_counts[1]
        delta = (pattern_gain * 0.5) - (edge_gain * 0.2) + (m_after - m_before)

        notes.append(f"maturity_delta={delta:.3f}")

        # Decide outcome
        if delta > acceptance_threshold:
            outcome = "accepted"
            store.set_proposal_status(proposal_id, "accepted", notes=" | ".join(notes))
        else:
            outcome = "rejected"
            store.set_proposal_status(proposal_id, "rejected", notes=" | ".join(notes))

        return EvalResult(
            proposal_id=proposal_id,
            target_domain=target_domain,
            from_world=base_world,
            to_world=shadow_world,
            tests_run=tests_run,
            failures=failures,
            maturity_before=m_before,
            maturity_after=m_after,
            delta=delta,
            notes="\n".join(notes),
            outcome=outcome
        )

    except Exception as e:
        tb = traceback.format_exc()
        store.set_proposal_status(
            proposal_id, "rejected",
            notes=f"exception during eval: {e}\n{tb}"
        )
        return EvalResult(
            proposal_id=proposal_id,
            target_domain=target_domain,
            from_world=base_world,
            to_world=shadow_world,
            tests_run=0,
            failures=1,
            maturity_before=0.0,
            maturity_after=0.0,
            delta=0.0,
            notes=f"Error: {e}",
            outcome="error"
        )


def evaluate_shadow_world(
    store: CASStore,
    domain_id: str,
    world_label: str
) -> float:
    """
    Quick maturity estimate for a shadow world.

    Args:
        store: Storage backend
        domain_id: Domain identifier
        world_label: World version

    Returns:
        Maturity score
    """
    world = store.get_world(domain_id, world_label)["json"]
    num_nodes = len(world.get("node_ids", []))
    num_edges = len(world.get("edge_ids", []))
    num_patterns = len(world.get("pattern_ids", []))

    m = estimate_maturity(
        num_nodes=num_nodes,
        num_edges=num_edges + num_patterns,  # Patterns increase compression
        num_tests=10,
        num_failures=0
    )
    return m.score


# =============================================================================
# Batch Processing
# =============================================================================

def evaluate_proposals_batch(
    store: CASStore,
    proposal_ids: List[str],
    target_domain: str,
    base_world: str = "green",
    smoke_test: Optional[SmokeTestFn] = None,
    acceptance_threshold: float = 0.0
) -> List[EvalResult]:
    """
    Evaluate multiple proposals.

    Args:
        store: Storage backend
        proposal_ids: Proposals to evaluate
        target_domain: Domain to test in
        base_world: Reference world version
        smoke_test: Optional domain-specific test function
        acceptance_threshold: Minimum delta for acceptance

    Returns:
        List of EvalResults
    """
    results: List[EvalResult] = []

    for i, pid in enumerate(proposal_ids):
        shadow_world = f"_shadow_eval_{i}"
        result = evaluate_proposal(
            store, pid, target_domain, base_world, shadow_world,
            smoke_test, acceptance_threshold
        )
        results.append(result)

    return results
