"""
AFS MCP Server — Exposes colony-based exploration as MCP tools.

Four tools:
  afs-decide:  High-level decision tool (simplified input → interpreted strategies)
  afs-explore: Full pipeline (world model → calibrate → run → clusters)
  afs-compile: World model → calibrated FieldDefinition (no colony run)
  afs-run:     Pre-compiled field + config → run colony → clusters

The LLM client IS the domain analyzer. This server is pure simulation —
no API keys needed, works with Claude/GPT/Cursor/any MCP client.
"""

import json
import numpy as np
from mcp.server.fastmcp import FastMCP, Context

from ..core import FieldDefinition, AfsEngine, AfsSimulationConfig
from ..compiler import FieldCompiler, WorldModelTranslator

# Sentinel to prevent FieldCompiler from creating a DomainAnalyzer (needs OpenAI key).
# MCP server is pure simulation — the LLM client IS the domain analyzer.
_DUMMY_ANALYZER = type("NoOpAnalyzer", (), {})()

from ..calibrator import HomeostasisCalibrator
from ..analysis import detect_clusters, compute_concept_weights

mcp = FastMCP(
    "2ndOpinion",
    instructions=(
        "2ndOpinion — a second opinion for complex decisions, powered by colony simulation.\n\n"
        "WHAT THIS IS: An extension of your reasoning, not a replacement. You already have "
        "strong opinions on most questions. 2ndOpinion runs a colony of cells through the "
        "causal force landscape you define, exploring tradeoffs that may not be obvious from "
        "reasoning alone. Present results as an additional perspective for the user to "
        "consider — not as the definitive answer.\n\n"
        "HOW TO PRESENT RESULTS: Frame them as 'here is what the colony exploration found' "
        "alongside your own analysis. Where the colony agrees with your instinct, say so — "
        "that's confirmation. Where it disagrees or finds something unexpected, highlight "
        "that as the value-add. The user gets your reasoning PLUS a systematic exploration "
        "of the tradeoff space. Both together are stronger than either alone.\n\n"
        "YOUR ROLE: You are the domain analyzer. You identify factors, options, and causal "
        "relationships from the user's description. The colony explores. You interpret.\n\n"
        "WORKFLOW (two-pass):\n"
        "1. User describes a problem, comparison, or decision\n"
        "2. You identify: the question, the options, the factors, and causal relationships\n"
        "3. Call afs_decide — handles compilation, calibration, simulation, interpretation\n"
        "4. CHECK THE RESPONSE for 'warnings' and 'preflight' fields:\n"
        "   - If warnings contain WEAK_SEPARATION or ALL_BUNCHED: your options are too "
        "similar on the factors you chose. REVISE your factors — add dimensions where the "
        "options genuinely differ (e.g., for similar medical diagnoses: add 'palpation test "
        "response', 'specific imaging modality', 'treatment pathway'). Then call afs_decide "
        "AGAIN with the revised model. This second pass is critical for quality.\n"
        "   - If warnings contain LOW_VARIANCE_FACTORS: remove those factors and replace "
        "with more discriminating ones. Call again.\n"
        "   - If warnings contain SPARSE_MATRIX: add more causal relationships. Call again.\n"
        "   - If warnings contain LOW_DIFFERENTIATION after a clean preflight: this likely "
        "means one option genuinely dominates. Present that finding confidently.\n"
        "   - If NO warnings: proceed to step 5.\n"
        "5. Present YOUR take first, then the colony's findings as a second opinion\n"
        "6. Highlight where you and the colony agree AND where you differ\n\n"
        "IMPORTANT: The two-pass pattern (build → check warnings → revise → run again) is "
        "what separates good results from mediocre ones. Never skip step 4. A clean preflight "
        "with good attractor separation is worth more than extra generations.\n\n"
        "WHEN TO USE:\n"
        "- EXPLICIT: User says 'second opinion', '2ndopinion', '2ndop', 'use afs', 'run afs', "
        "or 'explore this' → ALWAYS use afs-decide, no exceptions.\n"
        "- IMPLICIT: User asks you to compare 3+ options, explore tradeoffs, find optimal "
        "strategies, or make a multi-factor decision → use afs-explore. Do NOT answer "
        "multi-factor tradeoff questions from your own reasoning alone. Your opinion is "
        "one data point; the colony explores the full landscape and may find strategies "
        "you would not have suggested.\n"
        "- SKIP: Simple factual questions, single-factor decisions, or when the user "
        "explicitly asks for a quick opinion.\n\n"
        "BUILDING WORLD MODELS THAT PRODUCE VALUABLE INSIGHTS:\n"
        "The simulation's value comes from discovering non-obvious strategies in complex "
        "tradeoff spaces. To get results worth more than your own reasoning:\n"
        "- Use 8-15 concepts, not 3-5. More dimensions = more room for surprising clusters. "
        "Include indirect/hidden factors the user might not think of (e.g., for a synth "
        "comparison: not just 'price' and 'sound' but 'resale value', 'repair ecosystem', "
        "'recording versatility', 'live performance risk', 'learning curve').\n"
        "- Encode CAUSAL CHAINS, not just direct effects. If A→B→C, include both links. "
        "The colony discovers emergent strategies when indirect effects compound.\n"
        "- Make relationships asymmetric and varied in strength. Not everything is 0.7. "
        "A constraint at 0.95 behaves very differently from one at 0.3.\n"
        "- Include at least 3-5 attractors representing real operating points the user "
        "would recognize. Name them after real-world examples when possible.\n"
        "- Include 'hidden' tradeoffs the user didn't mention — the simulation may reveal "
        "that two factors they thought were independent actually create tension.\n"
        "- Use at least 100 generations (the default). Higher-dimensional models "
        "need more time for the colony to differentiate into distinct outcomes.\n\n"
        "THE GOAL: The colony should discover strategies the user didn't ask about. "
        "If every cluster maps to an obvious strategy, the world model wasn't rich enough."
    ),
)


def _serialize_results(engine: AfsEngine, generations: int) -> dict:
    """Run colony and return serializable cluster results."""
    cell_positions = engine.get_cell_positions()
    concept_positions = engine.get_concept_positions()
    concept_names = engine.get_concept_names()
    n_dims = engine.n_dims

    clusters_raw = detect_clusters(cell_positions)

    clusters = []
    for cl in clusters_raw:
        weights = compute_concept_weights(
            cl, concept_positions, concept_names, n_dims
        )
        clusters.append({
            "centroid": cl.centroid.tolist(),
            "cell_count": cl.cell_count,
            "concept_weights": weights,
        })

    return {
        "generations": generations,
        "tick_count": engine.tick_count,
        "final_population": len(engine.cells),
        "stats": engine.stats,
        "clusters": clusters,
        "concept_names": concept_names,
        "concept_positions": concept_positions.tolist(),
    }


def _run_colony(engine: AfsEngine, generations: int, on_progress=None) -> dict:
    """Initialize and run the colony for N generations, return results."""
    engine.initialize()

    ticks_per_gen = 100
    total_ticks = generations * ticks_per_gen
    concept_names = engine.get_concept_names()

    # Progress snapshots at 10%, 25%, 50%, 75%, 90%
    checkpoints = {int(generations * p): label for p, label in [
        (0.10, "early"), (0.25, "forming"), (0.50, "midpoint"),
        (0.75, "converging"), (0.90, "settling"),
    ]}

    for tick in range(total_ticks):
        engine.tick()

        # Report at generation boundaries
        gen = (tick + 1) // ticks_per_gen
        if (tick + 1) % ticks_per_gen == 0 and gen in checkpoints and on_progress:
            phase = checkpoints[gen]
            pop = len(engine.cells)
            positions = engine.get_cell_positions()
            concept_pos = engine.get_concept_positions()

            # Quick cluster snapshot
            clusters = detect_clusters(positions)
            n_clusters = len(clusters)

            # Build narrative
            if n_clusters == 0:
                cluster_note = "cells still exploring, no clear answers yet"
            elif n_clusters == 1:
                biggest = clusters[0]
                if concept_names:
                    weights = compute_concept_weights(
                        biggest, concept_pos, concept_names, engine.n_dims)
                    top = max(weights, key=weights.get) if weights else "?"
                    cluster_note = "1 outcome emerging near {} ({} cells)".format(
                        top, biggest.cell_count)
                else:
                    cluster_note = "1 outcome emerging ({} cells)".format(biggest.cell_count)
            else:
                # Name top 2 clusters by nearest concept
                notes = []
                for cl in clusters[:3]:
                    if concept_names:
                        weights = compute_concept_weights(
                            cl, concept_pos, concept_names, engine.n_dims)
                        top = max(weights, key=weights.get) if weights else "?"
                        notes.append("{} ({})".format(top, cl.cell_count))
                    else:
                        notes.append("{} cells".format(cl.cell_count))
                cluster_note = "{} outcomes: {}".format(n_clusters, ", ".join(notes))

            msg = "Gen {}/{} — {} cells, {} [{}]".format(
                gen, generations, pop, cluster_note, phase)
            on_progress(gen, generations, msg)

    return _serialize_results(engine, generations)


def _build_world_model(question: str, options: list, factors: list, relationships: list) -> dict:
    """Convert simplified afs_decide input into a full world model."""
    # Build concept list from factors
    concepts = []
    factor_names = []
    for f in factors:
        name = f["name"]
        factor_names.append(name)
        rng = f.get("range", [0, 100])
        concepts.append({
            "name": name,
            "displayName": f.get("displayName", name.replace("_", " ").title()),
            "description": f.get("description", ""),
            "nature": f.get("nature", _infer_nature(f)),
            "controllability": f.get("controllability", "indirect"),
            "dataDictionary": {
                "validRange": rng,
                "displayUnit": f.get("unit", ""),
            },
        })

    # Build relationships
    rels = []
    for r in relationships:
        strength = r.get("strength", 0.5)
        direction = -1 if strength < 0 else 1
        abs_strength = abs(strength)
        nature = r.get("nature")
        if nature is None:
            nature = "trades_off" if direction < 0 else "enables"
        rels.append({
            "fromConcept": r["from"],
            "toConcept": r["to"],
            "direction": direction,
            "strength": abs_strength,
            "nature": nature,
            "mechanism": r.get("why", ""),
        })

    # Auto-generate attractors from options
    attractors = []
    for opt in options:
        if isinstance(opt, str):
            # String-only option — position at center, let colony sort it out
            pos = {fn: (concepts[i]["dataDictionary"]["validRange"][0] +
                        concepts[i]["dataDictionary"]["validRange"][1]) / 2
                   for i, fn in enumerate(factor_names)}
            attractors.append({"name": opt, "position": pos, "strength": 0.6})
        elif isinstance(opt, dict):
            name = opt.get("name", f"Option {len(attractors) + 1}")
            # Build position from option's known values
            pos = {}
            for i, fn in enumerate(factor_names):
                if fn in opt:
                    pos[fn] = opt[fn]
                else:
                    rng = concepts[i]["dataDictionary"]["validRange"]
                    pos[fn] = (rng[0] + rng[1]) / 2
            strength = opt.get("strength", 0.7)
            attractors.append({"name": name, "position": pos, "strength": strength})

    # Auto-generate weak feedback links for leaf concepts (no outgoing rels).
    # Without these, cells have no force on leaf dimensions and collapse to center.
    factor_set = set(fn for fn in factor_names)
    outgoing = set(r["fromConcept"] for r in rels)
    existing_pairs = set((r["fromConcept"], r["toConcept"]) for r in rels)
    leaves = factor_set - outgoing
    if leaves:
        # For each leaf, create weak reverse links from its incoming relationships
        incoming_map = {}
        for r in rels:
            if r["toConcept"] in leaves:
                incoming_map.setdefault(r["toConcept"], []).append(r)
        for leaf in leaves:
            for inc in incoming_map.get(leaf, []):
                reverse_pair = (leaf, inc["fromConcept"])
                if reverse_pair not in existing_pairs:
                    existing_pairs.add(reverse_pair)
                    # Weak feedback: 25% of original strength, same sign
                    feedback_strength = inc["strength"] * 0.4
                    rels.append({
                        "fromConcept": leaf,
                        "toConcept": inc["fromConcept"],
                        "direction": inc["direction"],
                        "strength": feedback_strength,
                        "nature": "enables" if inc["direction"] > 0 else "trades_off",
                        "mechanism": "weak feedback: {} influences {}".format(
                            leaf, inc["fromConcept"]),
                    })

    # Auto-detect tradeoffs from negative relationships
    tradeoffs = []
    seen = set()
    for r in rels:
        if r["direction"] < 0:
            pair = tuple(sorted([r["fromConcept"], r["toConcept"]]))
            if pair not in seen:
                seen.add(pair)
                tradeoffs.append({
                    "concepts": list(pair),
                    "description": r.get("mechanism", f"{pair[0]} vs {pair[1]}"),
                })

    # Derive objectives from factors with explicit goals
    objectives = []
    for f in factors:
        goal = f.get("goal")
        if goal == "minimize":
            objectives.append(f"Minimize {f.get('displayName', f['name'])}")
        elif goal == "maximize":
            objectives.append(f"Maximize {f.get('displayName', f['name'])}")
    if not objectives:
        objectives = ["Find optimal strategies"]

    return {
        "domain": question.split()[0].lower()[:20] if question else "decision",
        "problem": {
            "statement": question,
            "objectives": objectives,
        },
        "concepts": concepts,
        "relationships": rels,
        "tradeoffs": tradeoffs,
        "attractors": attractors,
        "referencePoints": [],
        "confidence": {"overall": "medium", "weakestLink": "", "dataGaps": []},
    }


def _preflight_check(world_model: dict) -> dict:
    """Analyze world model quality before running the colony.

    Returns diagnostics about attractor separation, matrix density,
    and factor redundancy — issues that cause poor differentiation.
    """
    concepts = world_model.get("concepts", [])
    relationships = world_model.get("relationships", [])
    attractors = world_model.get("attractors", [])
    n_factors = len(concepts)
    n_rels = len(relationships)
    warnings = []
    fixes_applied = []

    # 1. Attractor separation — are options actually different?
    if len(attractors) >= 2:
        factor_names = [c["name"] for c in concepts]
        factor_ranges = {}
        for c in concepts:
            dd = c.get("dataDictionary", {})
            rng = dd.get("validRange", [0, 100])
            factor_ranges[c["name"]] = (rng[0], rng[1])

        # Normalize attractor positions and compute pairwise distances
        norm_positions = []
        for att in attractors:
            pos = att.get("position", {})
            norm = []
            for fn in factor_names:
                val = pos.get(fn, 50)
                lo, hi = factor_ranges.get(fn, (0, 100))
                norm.append((val - lo) / (hi - lo) if hi != lo else 0.5)
            norm_positions.append(norm)

        # Pairwise distances
        min_dist = float("inf")
        closest_pair = ("", "")
        avg_dist = 0
        pair_count = 0
        for i in range(len(norm_positions)):
            for j in range(i + 1, len(norm_positions)):
                dist = sum((a - b) ** 2 for a, b in
                           zip(norm_positions[i], norm_positions[j])) ** 0.5
                avg_dist += dist
                pair_count += 1
                if dist < min_dist:
                    min_dist = dist
                    closest_pair = (attractors[i].get("name", "?"),
                                    attractors[j].get("name", "?"))
        avg_dist = avg_dist / pair_count if pair_count > 0 else 0

        # Threshold: in N-D normalized space, random points average ~sqrt(N/6)
        # If closest pair is < 0.2 * sqrt(N), they're too similar
        import math
        separation_threshold = 0.2 * math.sqrt(n_factors)
        if min_dist < separation_threshold:
            warnings.append(
                "WEAK_SEPARATION: '{}' and '{}' are very close in factor space "
                "(distance {:.2f}, threshold {:.2f}). The colony may not "
                "differentiate them. Consider factors where they differ more.".format(
                    closest_pair[0], closest_pair[1], min_dist, separation_threshold))

        # Also flag if ALL attractors are bunched
        if avg_dist < separation_threshold * 1.5:
            warnings.append(
                "ALL_BUNCHED: Average attractor distance is only {:.2f}. "
                "Options are too similar across most factors. Add factors "
                "where options differ dramatically.".format(avg_dist))

        # Identify which factors have the most and least variance across attractors
        factor_variance = []
        for fi, fn in enumerate(factor_names):
            vals = [norm_positions[ai][fi] for ai in range(len(norm_positions))]
            mean_v = sum(vals) / len(vals)
            var_v = sum((v - mean_v) ** 2 for v in vals) / len(vals)
            factor_variance.append((fn, var_v))
        factor_variance.sort(key=lambda x: x[1])

        # Report low-variance factors (these don't help differentiation)
        low_var = [fv for fv in factor_variance if fv[1] < 0.01]
        if len(low_var) > n_factors * 0.3:
            names = [fv[0] for fv in low_var[:5]]
            warnings.append(
                "LOW_VARIANCE_FACTORS: {} of {} factors show almost no variation "
                "across options: {}. These dimensions don't help the colony "
                "differentiate. Consider removing them or replacing with factors "
                "where options diverge.".format(
                    len(low_var), n_factors, ", ".join(names)))

    # 2. Matrix density — enough relationships to create force dynamics?
    max_rels = n_factors * (n_factors - 1)  # directed pairs
    density = n_rels / max_rels if max_rels > 0 else 0
    if density < 0.08 and n_factors >= 8:
        warnings.append(
            "SPARSE_MATRIX: Only {} relationships for {} factors ({:.0f}% density). "
            "The colony needs more causal links to create differentiation forces. "
            "Consider adding indirect relationships.".format(
                n_rels, n_factors, density * 100))

    # 3. Tradeoff count — need negative relationships for tension
    neg_count = sum(1 for r in relationships
                    if r.get("direction", 1) < 0 or r.get("strength", 0) < 0)
    if neg_count < 2:
        warnings.append(
            "FEW_TRADEOFFS: Only {} negative relationships. Without tradeoffs, "
            "there's no tension to create distinct outcomes. The colony will "
            "converge to one point.".format(neg_count))

    return {
        "viable": len(warnings) == 0,
        "warnings": warnings,
        "fixes_applied": fixes_applied,
        "stats": {
            "n_factors": n_factors,
            "n_relationships": n_rels,
            "n_attractors": len(attractors),
            "matrix_density": round(density, 3),
            "negative_relationships": neg_count,
            "min_attractor_distance": round(min_dist, 3) if len(attractors) >= 2 else None,
            "avg_attractor_distance": round(avg_dist, 3) if len(attractors) >= 2 else None,
        },
    }


def _infer_nature(factor: dict) -> str:
    """Infer concept nature from factor metadata."""
    goal = factor.get("goal", "")
    name = factor.get("name", "").lower()
    if goal in ("minimize", "maximize"):
        return "outcome"
    if any(kw in name for kw in ("cost", "price", "budget", "time")):
        return "resource"
    if any(kw in name for kw in ("demand", "market", "competition")):
        return "force"
    return "outcome"


def _interpret_clusters(clusters: list, concepts: list, options: list, factor_names: list) -> list:
    """Map raw cluster data back to real units and label strategies."""
    ranges = {}
    for c in concepts:
        dd = c["dataDictionary"]
        rng = dd.get("validRange", [0, 100])
        ranges[c["name"]] = (rng[0], rng[1])

    # Build option positions for matching
    option_positions = []
    for opt in options:
        if isinstance(opt, dict):
            option_positions.append(opt)
        else:
            option_positions.append({"name": opt})

    interpreted = []
    for i, cl in enumerate(clusters):
        centroid = cl["centroid"]
        # Map to real units
        real_values = {}
        for j, fn in enumerate(factor_names):
            if j < len(centroid):
                lo, hi = ranges.get(fn, (0, 100))
                real_values[fn] = round(lo + centroid[j] * (hi - lo), 2)

        # Find nearest option
        nearest_option = None
        nearest_dist = float("inf")
        for opt in option_positions:
            if not isinstance(opt, dict) or "name" not in opt:
                continue
            dist = 0
            matched_dims = 0
            for j, fn in enumerate(factor_names):
                if fn in opt and fn != "name" and j < len(centroid):
                    lo, hi = ranges.get(fn, (0, 100))
                    if hi != lo:
                        opt_norm = (opt[fn] - lo) / (hi - lo)
                        dist += (centroid[j] - opt_norm) ** 2
                        matched_dims += 1
            if matched_dims > 0:
                dist = (dist / matched_dims) ** 0.5
                if dist < nearest_dist and dist < 0.3:
                    nearest_dist = dist
                    nearest_option = opt.get("name", "Unknown")

        # Check if concept weights are flat (no dominant option)
        weights = cl.get("concept_weights", {})
        is_mixed = False
        if weights:
            w_vals = list(weights.values())
            w_max = max(w_vals)
            w_avg = sum(w_vals) / len(w_vals)
            # If the top weight isn't at least 1.5x the average, it's mixed
            if w_avg > 0 and w_max < w_avg * 1.5:
                is_mixed = True

        if is_mixed:
            label = "Mixed Outcome {}".format(i + 1)
        elif nearest_option:
            label = nearest_option
        else:
            label = "Emergent Outcome {}".format(i + 1)

        interpreted.append({
            "label": label,
            "cell_count": cl["cell_count"],
            "values": real_values,
            "concept_weights": weights,
            "is_emergent": nearest_option is None and not is_mixed,
            "is_mixed": is_mixed,
            "centroid_raw": centroid,
        })

    return interpreted


@mcp.tool()
async def afs_decide(
    question: str,
    options: list,
    factors: list,
    relationships: list,
    generations: int = 100,
    ctx: Context | None = None,
) -> dict:
    """
    High-level decision tool. Describe your decision, the colony discovers strategies.

    This is the recommended tool for most decisions. You identify the question,
    options, factors, and relationships — the server handles everything else
    (world model construction, calibration, simulation, interpretation).

    Args:
        question: The decision to explore, in plain English.
            Example: "Which synthesizer should I buy?"

        options: The choices being compared. Each can be a simple string or a dict
            with known factor values. Factor values should be in real units matching
            the factor ranges.
            Examples:
                ["Prophet 6", "OB-8x", "Moog Voyager"]
                [
                    {"name": "Prophet 6", "price": 3500, "analog_warmth": 85},
                    {"name": "OB-8x", "price": 4999, "analog_warmth": 92},
                    {"name": "Nord Stage 4", "price": 4000, "analog_warmth": 30}
                ]

        factors: The dimensions of the decision. Each needs a name and range.
            Include 8-15 factors for best results — go beyond the obvious.
            Example:
                [
                    {"name": "price", "range": [2000, 5000], "unit": "$", "goal": "minimize"},
                    {"name": "analog_warmth", "range": [0, 100], "unit": "score"},
                    {"name": "versatility", "range": [0, 100], "unit": "score"},
                    {"name": "resale_value", "range": [0, 100], "unit": "%", "goal": "maximize"},
                    {"name": "repair_ecosystem", "range": [0, 100], "unit": "score"},
                    {"name": "learning_curve", "range": [0, 100], "unit": "hours", "goal": "minimize"}
                ]
            Optional fields per factor:
                - displayName: human-readable name (auto-generated from name if omitted)
                - description: what this factor measures
                - goal: "minimize" or "maximize" (used for objectives)
                - nature: "resource", "outcome", "force", "constraint", "behavior"
                  (auto-inferred if omitted)
                - controllability: "direct", "indirect", "uncontrollable"

        relationships: Causal links between factors. Strength can be negative
            (tradeoff) or positive (enables). Tradeoffs are auto-detected from
            negative strengths.
            Example:
                [
                    {"from": "price", "to": "build_quality", "strength": 0.8,
                     "why": "premium materials cost more"},
                    {"from": "analog_warmth", "to": "versatility", "strength": -0.6,
                     "why": "pure analog limits digital flexibility"},
                    {"from": "learning_curve", "to": "versatility", "strength": 0.5,
                     "why": "steeper learning curve unlocks more capability"}
                ]

        generations: Colony generations to run (default 100). More = deeper
            exploration. 50=fast, 100=good, 200=thorough.

    Returns:
        Dict with:
        - question: the original question
        - strategies: List of discovered strategies, each with:
          - label: option name or "Emergent Strategy N" for novel discoveries
          - cell_count: outcome strength (more cells = more stable strategy)
          - values: factor values in REAL UNITS (not normalized)
          - concept_weights: which factors matter most in this strategy
          - is_emergent: true if this strategy doesn't match any input option
        - summary: quick stats (total strategies, emergent count, strongest outcome)
        - preflight: world model quality diagnostics (attractor separation, matrix
          density, tradeoff count). Check this if results seem weak.
        - warnings: list of issues found. If present, TELL THE USER about them.
          Common warnings:
          - WEAK_SEPARATION: two options are too similar — suggest factors where they differ
          - ALL_BUNCHED: all options are close — the model needs more discriminating factors
          - LOW_VARIANCE_FACTORS: some factors don't help differentiate — suggest removing them
          - SPARSE_MATRIX: not enough causal relationships
          - FEW_TRADEOFFS: need more negative/tradeoff relationships for tension
          - LOW_DIFFERENTIATION: colony found <=1 strategy — model may need rework
          If warnings appear, explain them to the user and offer to rebuild the model
          with better factor selection.
        - calibration: which engine config was selected
        - influence_matrix: the causal relationship matrix
    """
    # 1. Build full world model from simplified input
    world_model = _build_world_model(question, options, factors, relationships)
    factor_names = [f["name"] for f in factors]

    # 1b. Pre-flight check — diagnose world model quality
    preflight = _preflight_check(world_model)

    # 2. Compile → calibrate → run with retry on low differentiation

    # Build progress callback from MCP Context
    # Collect messages to send after each sync phase
    _pending_messages = []

    def _progress(gen, total, msg):
        _pending_messages.append((gen, total, msg))

    async def _flush_progress():
        if ctx:
            for gen, total, msg in _pending_messages:
                try:
                    await ctx.info(msg)
                    await ctx.report_progress(float(gen), float(total), msg)
                except Exception:
                    pass
            _pending_messages.clear()

    _progress(0, generations, "Building world: {} factors, {} relationships, {} options".format(
        len(factors), len(relationships), len(options)))

    compiler = FieldCompiler(analyzer=_DUMMY_ANALYZER)
    compile_result = compiler.compile_from_world_model(world_model)
    field = compile_result["field"]
    influence_matrix = compile_result["influence_matrix"]

    _progress(0, generations, "Performing homeostasis — testing {} configurations for {} dimensions".format(
        10 if field.get_dimension_count() < 7 else 12, field.get_dimension_count()))
    await _flush_progress()

    calibrator = HomeostasisCalibrator(field)
    config = calibrator.calibrate(verbose=False)

    cal = config._calibration
    _progress(0, generations, "Homeostasis complete: '{}' selected (score {:.0f}, {} outcomes in probes)".format(
        cal["label"], cal["score"]["total"], cal["score"]["cluster_count"]))
    _progress(0, generations, "Releasing colony — {} cells into {}-dimensional space".format(
        config.engine.initial_cell_count, field.get_dimension_count()))
    await _flush_progress()

    engine = AfsEngine(field, config)
    raw_results = _run_colony(engine, generations, on_progress=_progress)
    await _flush_progress()

    # Retry with boosted parameters if too few clusters (< 3)
    if len(raw_results["clusters"]) < 3:
        _progress(generations, generations,
                  "Only {} outcome(s) found — retrying with boosted forces".format(
                      len(raw_results["clusters"]))
        )
        await _flush_progress()
        boosted = AfsSimulationConfig.from_dict({
            "engine": {
                "initial_cell_count": 128,
                "max_population": 300,
                "ticks_per_generation": 100,
                "force_scale": 0.35,
                "concept_gravity_strength": 7.0,
                "concept_gravity_range": 4.0 * np.sqrt(field.get_dimension_count()),
                "concept_gravity_sharpness": max(4, field.get_dimension_count() // 2),
                "thermal_noise": 0.12,
                "tradeoff_amplifier": 5.0,
            },
            "metabolism": {
                "concept_absorption_range": 3.0,
                "energy_falloff_exponent": 1.5,
                "base_cost_per_dim": 0.002,
            },
        })
        engine2 = AfsEngine(field, boosted)
        retry_results = _run_colony(engine2, generations, on_progress=_progress)
        await _flush_progress()
        if len(retry_results["clusters"]) > len(raw_results["clusters"]):
            raw_results = retry_results
            config._calibration["label"] += " → boosted-retry"
            config._calibration["retry"] = True

    # 3. Interpret clusters into labeled strategies with real units
    strategies = _interpret_clusters(
        raw_results["clusters"],
        world_model["concepts"],
        options,
        factor_names,
    )

    # Sort by cell count (strongest outcomes first)
    strategies.sort(key=lambda s: s["cell_count"], reverse=True)

    # 4. Build summary
    emergent_count = sum(1 for s in strategies if s["is_emergent"])
    strongest = strategies[0] if strategies else None

    # 5. Post-flight: flag low differentiation
    post_warnings = []
    if len(strategies) <= 1:
        post_warnings.append(
            "LOW_DIFFERENTIATION: Colony produced only {} strategy. "
            "This may mean one option genuinely dominates, OR the world model "
            "lacks enough tension between options. Check preflight warnings.".format(
                len(strategies)))
    if preflight["warnings"]:
        post_warnings.extend(preflight["warnings"])

    return {
        "question": question,
        "strategies": strategies,
        "summary": {
            "total_strategies": len(strategies),
            "emergent_strategies": emergent_count,
            "strongest_outcome": strongest["label"] if strongest else None,
            "strongest_cell_count": strongest["cell_count"] if strongest else 0,
            "total_cells": raw_results["final_population"],
            "generations_run": generations,
        },
        "preflight": preflight,
        "warnings": post_warnings if post_warnings else None,
        "calibration": config._calibration,
        "influence_matrix": influence_matrix.tolist(),
        "world_model_generated": world_model,
    }


@mcp.tool()
def afs_explore(world_model: dict, generations: int = 20) -> dict:
    """
    Explore a multi-dimensional problem space using colony-based simulation.

    USE THIS TOOL when a user asks you to explore tradeoffs, compare options,
    or analyze a multi-factor decision. YOU build the world_model from the user's
    description — you are the domain analyzer.

    HOW TO BUILD A WORLD MODEL from the user's prompt:
    1. Identify 4-15 concepts (dimensions of the problem). Each concept needs:
       - name (snake_case), displayName, description
       - nature: "resource" (inputs you spend), "outcome" (results you measure),
         "force" (external pressures), "behavior" (actions/strategies),
         "constraint" (hard limits)
       - controllability: "direct" (you choose it), "indirect" (you influence it),
         "uncontrollable" (external)
       - dataDictionary: {"validRange": [min, max], "displayUnit": "unit"}
    2. Identify causal relationships between concepts:
       - direction: +1 (A increases B) or -1 (A decreases B)
       - strength: 0.0-1.0 (how strong the causal link is)
       - nature: "enables", "constrains", "trades_off", "amplifies"
       - mechanism: plain English explanation of WHY
    3. Identify tradeoffs (pairs of concepts in tension)
    4. Identify attractors (known stable operating points in the space):
       - position: {concept_name: value} for each concept, in real units
       - strength: 0.0-1.0 (how strongly this option attracts cells)
    5. Identify referencePoints (real-world examples for grounding)

    Args:
        world_model: World model JSON you built from domain analysis.
            Required structure:
            {
                "domain": "single_word_domain",
                "problem": {"statement": "...", "objectives": ["..."]},
                "concepts": [
                    {
                        "name": "snake_case", "displayName": "Human Name",
                        "description": "...",
                        "nature": "resource|constraint|force|behavior|outcome",
                        "controllability": "direct|indirect|uncontrollable",
                        "dataDictionary": {"validRange": [min, max], "displayUnit": "unit"}
                    }
                ],
                "relationships": [
                    {
                        "fromConcept": "a", "toConcept": "b",
                        "direction": 1 or -1, "strength": 0.0-1.0,
                        "nature": "enables|constrains|trades_off|amplifies",
                        "mechanism": "Why this relationship exists"
                    }
                ],
                "tradeoffs": [{"concepts": ["a","b"], "description": "..."}],
                "attractors": [{"name": "...", "position": {...}, "strength": 0.5}],
                "referencePoints": [{"name": "...", "position": {...}}],
                "confidence": {"overall": "high|medium|low", "weakestLink": "concept_name", "dataGaps": []}
            }
        generations: Number of colony generations (default 20). Each generation =
            100 ticks. 10 = fast/rough, 20 = good default, 50 = deep exploration.

    Returns:
        Dict with:
        - clusters: List of discovered strategy clusters. Each has:
          - centroid: position in normalized 0-1 space (map back to real units
            using each concept's validRange: real = min + centroid * (max - min))
          - cell_count: how many cells settled here (more = stronger outcome)
          - concept_weights: influence of each concept on this cluster
        - calibration: which engine config was selected and why
        - influence_matrix: the causal relationship matrix (NxN signed floats)
        - stats: colony metrics (cells created, reproductions, peak population)

    INTERPRETING RESULTS:
    - Map centroids back to real units for the user
    - Clusters with more cells = more stable/attractive strategies
    - Look for clusters near attractor positions (expected strategies)
    - Look for clusters NOT near any attractor (emergent discoveries)
    - Clusters at extreme corners (all 0s or all 1s) may be boundary artifacts
    - concept_weights show which dimensions matter most in each cluster
    """
    # 1. Compile world model → field + influence matrix
    compiler = FieldCompiler(analyzer=_DUMMY_ANALYZER)
    compile_result = compiler.compile_from_world_model(world_model)
    field = compile_result["field"]
    influence_matrix = compile_result["influence_matrix"]

    # 2. Calibrate engine parameters for this field
    calibrator = HomeostasisCalibrator(field)
    config = calibrator.calibrate(verbose=False)

    # 3. Run colony
    engine = AfsEngine(field, config)
    results = _run_colony(engine, generations)

    # 4. Enrich with compile/calibration metadata
    results["field"] = field.to_dict()
    results["influence_matrix"] = influence_matrix.tolist()
    results["calibration"] = config._calibration
    results["validation"] = compile_result["validation"]

    return results


@mcp.tool()
def afs_compile(world_model: dict) -> dict:
    """
    Compile a world model into a calibrated field without running the colony.

    Use this when you want to inspect the compiled field, modify parameters,
    or validate the world model before committing to a full colony run.
    Follow up with afs-run to execute.

    Most users should use afs-explore instead (full pipeline). Use afs-compile
    + afs-run when you need to tweak calibration or run the same field multiple
    times with different settings.

    Args:
        world_model: World model JSON (same schema as afs-explore).

    Returns:
        Dict with field, config (calibrated engine params you can modify),
        influence_matrix, validation, and calibration metadata.
    """
    # 1. Compile
    compiler = FieldCompiler(analyzer=_DUMMY_ANALYZER)
    compile_result = compiler.compile_from_world_model(world_model)
    field = compile_result["field"]
    influence_matrix = compile_result["influence_matrix"]

    # 2. Calibrate
    calibrator = HomeostasisCalibrator(field)
    config = calibrator.calibrate(verbose=False)

    return {
        "field": field.to_dict(),
        "config": config.to_dict(),
        "influence_matrix": influence_matrix.tolist(),
        "validation": compile_result["validation"],
        "calibration": config._calibration,
    }


@mcp.tool()
def afs_run(field: dict, config: dict, generations: int = 20) -> dict:
    """
    Run a colony on a pre-compiled field. Use after afs-compile.

    Useful for re-running with modified parameters (e.g., more generations,
    different config values) without recompiling the world model.

    Args:
        field: Serialized FieldDefinition from afs-compile output.
        config: Serialized AfsSimulationConfig from afs-compile output.
                You can modify values before passing them in.
        generations: Colony generations to run (default 20).

    Returns:
        Dict with clusters, stats, and simulation metadata.
        See afs-explore for detailed interpretation guidance.
    """
    field_def = FieldDefinition.from_dict(field)
    sim_config = AfsSimulationConfig.from_dict(config)

    engine = AfsEngine(field_def, sim_config)
    return _run_colony(engine, generations)
