"""
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 time
import numpy as np
from concurrent.futures import ThreadPoolExecutor
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


class _Timer:
    """Lightweight phase timer — logs to console and accumulates phase breakdown."""
    def __init__(self, label: str, log: bool = True):
        self.label = label
        self.log = log
        self._t0 = None

    def __enter__(self):
        self._t0 = time.time()
        return self

    def __exit__(self, *_):
        self.elapsed_ms = (time.time() - self._t0) * 1000
        if self.log:
            print(f"[2ndOpinion] {self.label}: {self.elapsed_ms:.0f}ms")

# GPU-aware defaults — detect once at import time
try:
    from ..gpu import gpu_available
    _HAS_GPU = gpu_available()
except Exception:
    _HAS_GPU = False

# Colony scaling: GPU can handle larger populations at no extra cost
_GPU_DEFAULTS = {
    "initial_cell_count": 256,
    "max_population": 500,
    "generations_decide": 150,
    "generations_explore": 40,
    "generations_run": 40,
}
_CPU_DEFAULTS = {
    "initial_cell_count": 128,
    "max_population": 300,
    "generations_decide": 100,
    "generations_explore": 20,
    "generations_run": 20,
}
_DEFAULTS = _GPU_DEFAULTS if _HAS_GPU else _CPU_DEFAULTS

if _HAS_GPU:
    print("[2ndOpinion] GPU detected — colony scaled to {} cells, {} max pop".format(
        _DEFAULTS["initial_cell_count"], _DEFAULTS["max_population"]))

# Session cache — persists across tool calls within one conversation.
# The MCP server process lives for the conversation duration.
_session = {
    "engine": None,          # AfsEngine instance (live colony)
    "field": None,           # FieldDefinition
    "config": None,          # AfsSimulationConfig
    "world_model": None,     # Full world model dict
    "options": None,         # Original options list
    "factor_names": None,    # Factor name list
    "influence_matrix": None,# NxN matrix
    "question": None,        # Original question
    "strategies": None,      # Last interpreted strategies
}

mcp = FastMCP(
    "2ndOpinion",
    instructions=(
        "2ndOpinion — a second opinion for complex decisions.\n\n"

        "BEFORE CALLING ANY TOOL, tell the user:\n"
        "'Second Opinion deeply considers your request by running a simulation that explores "
        "all the possibilities and scenarios. This takes a moment — the journey reveals the "
        "deeper truth.'\n"
        "Say this ONCE per conversation, before the first afs_decide call. Do not repeat it "
        "on follow-ups.\n\n"

        "PRESENTING RESULTS — THIS IS THE MOST IMPORTANT SECTION:\n"
        "Do NOT explain how the tool works, what colonies are, or how cells behave. "
        "The user does not care about the simulation mechanics. They care about their decision.\n\n"
        "GOOD response: 'The strongest strategy is X because [factor] and [factor] reinforce "
        "each other. But there's a surprising alternative: Y trades off [thing] for [thing], "
        "which you might not have considered. Here's what I'd recommend and why.'\n\n"
        "BAD response: 'The colony simulation ran 150 generations with 256 cells. 195 cells "
        "converged on cluster 1 near the Prophet 6 attractor with concept weights...'\n\n"
        "Interpret the strategies as ACTIONABLE ADVICE:\n"
        "- Lead with your recommendation, informed by both your reasoning AND the colony findings\n"
        "- Explain tradeoffs in the user's language, not simulation language\n"
        "- Highlight surprises — strategies the user didn't ask about are the highest value\n"
        "- Use real units and concrete numbers from the results, not normalized values\n"
        "- If the colony agrees with your instinct, say so briefly — that's confirmation\n"
        "- If it disagrees, explain WHY the causal relationships produced that outcome\n"
        "- Keep it concise. A clear recommendation with 2-3 key tradeoffs beats a wall of analysis\n\n"

        "Never say 'the colony found' or 'cells converged on' — say 'the analysis suggests' "
        "or 'the tradeoff exploration revealed' or just state the finding directly.\n\n"

        "WORKFLOW:\n"
        "1. User describes a decision or comparison\n"
        "2. Identify: question, options, factors (8-15), causal relationships\n"
        "3. Call afs_decide\n"
        "4. CHECK for warnings — if WEAK_SEPARATION or ALL_BUNCHED, revise factors and call again\n"
        "5. Interpret results as actionable advice (see above)\n\n"

        "FOLLOW-UPS:\n"
        "After afs_decide, if the user asks a related question ('what about X?', 'what if...'), "
        "use afs_followup. It reuses the existing simulation state (~2s vs ~10s). "
        "Only call afs_decide again if the topic changes completely.\n\n"

        "WHEN TO USE:\n"
        "- User says 'second opinion', '2ndop', 'use afs', 'explore this' → afs_decide\n"
        "- User compares 3+ options or asks about tradeoffs → afs_decide\n"
        "- User asks follow-up on previous result → afs_followup\n"
        "- Simple factual question or single-factor choice → skip, answer directly\n\n"

        "BUILDING GOOD WORLD MODELS:\n"
        "- 8-15 factors, not 3-5. Include hidden factors the user didn't mention\n"
        "- Encode causal CHAINS (A→B→C), not just direct effects\n"
        "- Vary relationship strengths (0.3 to 0.95), don't default everything to 0.7\n"
        "- Include factors where options genuinely DIFFER — if all options score the same "
        "on a factor, it won't help differentiate\n"
        "- Score ALL options on ALL factors when possible — unscored factors default to 50 "
        "and reduce differentiation"
    ),
)


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_generations(engine: AfsEngine, generations: int, on_progress=None):
    """Run N generations on an already-initialized engine (no init call)."""
    ticks_per_gen = 100
    total_ticks = generations * ticks_per_gen
    engine.total_ticks = total_ticks  # enables gravity ramping + settling
    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 gen in range(1, generations + 1):
        engine.run_generation(ticks=ticks_per_gen)

        if gen in checkpoints and on_progress:
            phase = checkpoints[gen]
            pop = len(engine.cells)
            positions = engine.get_cell_positions()
            concept_pos = engine.get_concept_positions()

            clusters = detect_clusters(positions)
            n_clusters = len(clusters)

            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:
                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)


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


def _run_child_sims(
    field, config, strategies: list, factor_names: list, concepts: list,
    child_generations: int = 50,
) -> list:
    """Spawn one child colony per parent strategy, drill into each cluster.

    Each child engine starts tightly seeded at the parent cluster centroid and
    runs for child_generations to find sub-strategies within that region.
    Results are merged back as strategy["sub_strategies"].

    Only drills into strategies with at least _CHILD_MIN_CELLS cells
    (tiny clusters are noise — not worth the compute).
    """
    _CHILD_MIN_CELLS = max(5, int(config.engine.initial_cell_count * 0.06))
    _CHILD_SPREAD = 0.07   # tight initial spawn — about half the cluster radius
    _CHILD_CELLS = max(32, config.engine.initial_cell_count // 4)
    _CHILD_MAX_POP = max(64, config.engine.max_population // 4)

    # Light config: smaller colony, same physics
    child_config = AfsSimulationConfig.from_dict({
        "engine": {
            "initial_cell_count": _CHILD_CELLS,
            "max_population": _CHILD_MAX_POP,
            "spawn_spread": _CHILD_SPREAD,
            "force_scale": config.engine.force_scale,
            "concept_gravity_strength": config.engine.concept_gravity_strength,
            "concept_gravity_range": config.engine.concept_gravity_range,
            "concept_gravity_sharpness": config.engine.concept_gravity_sharpness,
            "thermal_noise": config.engine.thermal_noise * 0.6,   # less noise for focused drill
            "tradeoff_amplifier": config.engine.tradeoff_amplifier,
            "boundary_margin": config.engine.boundary_margin,
            "boundary_strength": config.engine.boundary_strength,
        },
        "metabolism": {
            "concept_absorption_range": config.metabolism.concept_absorption_range,
            "energy_falloff_exponent": config.metabolism.energy_falloff_exponent,
            "base_cost_per_dim": config.metabolism.base_cost_per_dim,
        },
    })

    def _run_one_child(strategy):
        """Run a single child sim — called in parallel threads."""
        if strategy["cell_count"] < _CHILD_MIN_CELLS:
            return {**strategy, "sub_strategies": []}

        centroid_raw = strategy.get("centroid_raw")
        if centroid_raw is None:
            return {**strategy, "sub_strategies": []}

        centroid = np.array(centroid_raw)
        t0 = time.time()

        # Child engines use CPU — small colony, low overhead, and GPU init cost
        # would dominate for 32-64 cells. Parallelism across children compensates.
        child = AfsEngine(field, child_config, use_gpu=False)
        child.initialize(seed_centroid=centroid, seed_spread=_CHILD_SPREAD)
        _run_generations(child, child_generations)
        child_results = _serialize_results(child, child_generations)

        sub_strategies = _interpret_clusters(
            child_results["clusters"], concepts, [], factor_names)
        sub_strategies.sort(key=lambda s: s["cell_count"], reverse=True)

        elapsed = (time.time() - t0) * 1000
        print("[2ndOpinion] child sim '{}': {}ms, {} sub-strategies".format(
            strategy.get("label", "?"), int(elapsed), len(sub_strategies)))

        return {**strategy, "sub_strategies": sub_strategies}

    # Run all child sims in parallel — they're independent and each takes 1-5s.
    n_children = sum(1 for s in strategies
                     if s["cell_count"] >= _CHILD_MIN_CELLS
                     and s.get("centroid_raw") is not None)
    t_children = time.time()

    if n_children > 1:
        with ThreadPoolExecutor(max_workers=min(n_children, 4)) as pool:
            enriched = list(pool.map(_run_one_child, strategies))
    else:
        enriched = [_run_one_child(s) for s in strategies]

    print("[2ndOpinion] all child sims: {:.0f}ms ({} ran)".format(
        (time.time() - t_children) * 1000, n_children))

    return enriched


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 = 0,
    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. Auto-scaled by GPU availability
            (GPU: 200, CPU: 100). Pass 0 for auto. 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
          - sub_strategies: child colony drill-down — sub-options within this
            strategy's region of the tradeoff space. Each sub-strategy has the
            same shape as a top-level strategy. Empty list for tiny clusters.
        - 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
    """
    # Auto-scale based on GPU availability
    if generations <= 0:
        generations = _DEFAULTS["generations_decide"]

    # 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()

    _t_decide_start = time.time()

    with _Timer("compile world model"):
        _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"]

    n_dims = field.get_dimension_count()
    _progress(0, generations,
              "Homeostasis — {} probes, {}D field (parallel)".format(
                  10 if n_dims >= 10 else 8, n_dims))
    await _flush_progress()

    with _Timer("homeostasis calibration") as t_cal:
        calibrator = HomeostasisCalibrator(field,
            target_cells=_DEFAULTS["initial_cell_count"],
            target_max_pop=_DEFAULTS["max_population"],
        )
        config = calibrator.calibrate(verbose=True)

    cal = config._calibration
    _progress(0, generations,
              "Homeostasis: '{}' (score {:.0f}, {} clusters) in {:.0f}ms".format(
                  cal["label"], cal["score"]["total"],
                  cal["score"]["cluster_count"], t_cal.elapsed_ms))
    _progress(0, generations,
              "Colony: {} cells → {}D space, {} gens{}".format(
                  config.engine.initial_cell_count, n_dims, generations,
                  " [GPU]" if _HAS_GPU else " [CPU]"))
    await _flush_progress()

    # Use GPU when available — run_generation() amortizes upload/readback to
    # once per generation, so per-tick overhead no longer dominates.
    # use_gpu=None means auto-detect (same as default).
    with _Timer("parent colony") as t_colony:
        engine = AfsEngine(field, config, use_gpu=None)
        raw_results = _run_colony(engine, generations, on_progress=_progress)
    await _flush_progress()

    _progress(generations, generations,
              "Colony done: {} clusters, {} cells in {:.0f}ms".format(
                  len(raw_results["clusters"]),
                  raw_results["final_population"],
                  t_colony.elapsed_ms))
    await _flush_progress()

    # Retry with boosted parameters if too few clusters (< 3)
    if len(raw_results["clusters"]) < 3:
        _progress(generations, generations,
                  "Only {} outcome(s) — retrying with boosted forces".format(
                      len(raw_results["clusters"])))
        await _flush_progress()
        boosted = AfsSimulationConfig.from_dict({
            "engine": {
                "initial_cell_count": _DEFAULTS["initial_cell_count"],
                "max_population": _DEFAULTS["max_population"],
                "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,
            },
        })
        with _Timer("boosted retry") as t_retry:
            engine2 = AfsEngine(field, boosted, use_gpu=None)
            retry_results = _run_colony(engine2, generations, on_progress=_progress)
        _progress(generations, generations,
                  "Retry done: {} clusters in {:.0f}ms".format(
                      len(retry_results["clusters"]), t_retry.elapsed_ms))
        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. Child sims — drill into each parent cluster to find sub-strategies (P9).
    # Only runs when we have ≥2 parent strategies (single-outcome runs skip this).
    if len(strategies) >= 2:
        _progress(generations, generations, "Drilling into {} strategies with child colonies".format(
            len(strategies)))
        await _flush_progress()
        child_gens = max(30, generations // 3)
        strategies = _run_child_sims(
            field, config, strategies, factor_names,
            world_model["concepts"], child_generations=child_gens)
        _progress(generations, generations, "Child sims complete — sub-strategies attached")
        await _flush_progress()

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

    # 7. 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"])

    _t_total = (time.time() - _t_decide_start) * 1000
    print("[2ndOpinion] afs_decide total: {:.0f}ms ({:.1f}s) — {} strategies".format(
        _t_total, _t_total / 1000, len(strategies)))

    # Cache session for follow-up questions
    _session["engine"] = engine
    _session["field"] = field
    _session["config"] = config
    _session["world_model"] = world_model
    _session["options"] = options
    _session["factor_names"] = factor_names
    _session["influence_matrix"] = influence_matrix
    _session["question"] = question
    _session["strategies"] = strategies

    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 = 0) -> 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
    """
    if generations <= 0:
        generations = _DEFAULTS["generations_explore"]

    # 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, options={
        "target_cells": _DEFAULTS["initial_cell_count"],
        "target_max_pop": _DEFAULTS["max_population"],
    })
    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, options={
        "target_cells": _DEFAULTS["initial_cell_count"],
        "target_max_pop": _DEFAULTS["max_population"],
    })
    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 = 0) -> 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.
    """
    if generations <= 0:
        generations = _DEFAULTS["generations_run"]

    field_def = FieldDefinition.from_dict(field)
    sim_config = AfsSimulationConfig.from_dict(config)

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


@mcp.tool()
async def afs_followup(
    question: str,
    new_options: list = None,
    generations: int = 0,
    ctx: Context | None = None,
) -> dict:
    """
    Follow up on the last afs_decide result — explore deeper or add new options
    without rebuilding the world model from scratch.

    Uses the cached colony from the previous afs_decide call. The existing cells
    stay in their discovered positions, new attractors are injected, energy is
    added, and the colony runs more generations to explore the expanded landscape.

    This is much faster than calling afs_decide again (~2s vs ~10s) because it
    skips world model construction and homeostasis calibration.

    Use this when:
    - The user asks a follow-up question about the same topic
    - The user wants to add a new option ("what about option X?")
    - The user wants to explore deeper ("run more generations")
    - The user asks "what if" variations on the original question

    Do NOT use this when:
    - The topic has completely changed (use afs_decide instead)
    - There was no previous afs_decide call in this conversation

    Args:
        question: The follow-up question or variation.
            Example: "What if I retired at 64 instead?"

        new_options: Optional new options to add to the landscape. Each can be
            a string or dict with factor values (same format as afs_decide options).
            These become new attractors in the existing field.
            Example: [{"name": "Retire at 64", "social_security": 1800}]

        generations: Additional generations to run (default: auto-scaled).
            The colony continues from its current state, not from scratch.

    Returns:
        Same format as afs_decide: strategies, summary, question.
        Includes a "followup" field indicating this built on a previous run.
    """
    if _session["engine"] is None:
        return {"error": "No previous afs_decide session. Call afs_decide first."}

    if generations <= 0:
        generations = _DEFAULTS["generations_decide"] // 3  # shorter for follow-ups

    engine = _session["engine"]
    field = _session["field"]
    world_model = _session["world_model"]
    factor_names = _session["factor_names"]
    options = list(_session["options"])  # copy so we can extend

    _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()

    # Add new options as attractors
    if new_options:
        concepts = world_model.get("concepts", [])
        dim_names = [c["name"] for c in concepts]
        n = field.get_dimension_count()

        for opt in new_options:
            opt_name = opt if isinstance(opt, str) else opt.get("name", f"New Option {len(options)+1}")
            options.append(opt)

            # Build attractor position from provided factor values
            position = np.full(n, 0.5)
            if isinstance(opt, dict):
                for j, fn in enumerate(factor_names):
                    if fn in opt and j < n:
                        dd = concepts[j].get("dataDictionary", {})
                        rng = dd.get("validRange", [0, 100])
                        if rng[1] != rng[0]:
                            position[j] = np.clip((opt[fn] - rng[0]) / (rng[1] - rng[0]), 0.0, 1.0)

            # Add concept to engine
            from ..entities.concept import Concept
            concept = Concept(opt_name, n, position=position)
            concept.energy = 0.5
            engine.concepts[concept.id] = concept

            _progress(0, generations, "Added new option: '{}' at position {}".format(
                opt_name, [round(float(p), 2) for p in position[:4]]))

        await _flush_progress()

    # Inject energy into existing cells (re-energize for exploration)
    for cell in engine.cells.values():
        cell.energy = min(cell.energy + 30, engine.config.cell.max_energy)

    _progress(0, generations, "Continuing colony — {} cells, {} concepts, {} more generations".format(
        len(engine.cells), len(engine.concepts), generations))
    await _flush_progress()

    # Run more generations on the existing colony
    engine.total_ticks = engine.tick_count + generations * 100
    ticks_per_gen = 100

    checkpoints = {int(generations * p): label for p, label in [
        (0.25, "exploring"), (0.50, "midpoint"), (0.75, "converging"), (0.95, "settling"),
    ]}

    for gen in range(1, generations + 1):
        engine.run_generation(ticks=ticks_per_gen)

        if gen in checkpoints and ctx:
            phase = checkpoints[gen]
            pop = len(engine.cells)
            positions = engine.get_cell_positions()
            clusters = detect_clusters(positions)
            _progress(gen, generations, "Gen {}/{} — {} cells, {} outcomes [{}]".format(
                gen, generations, pop, len(clusters), phase))
            await _flush_progress()

    # Detect final clusters and interpret
    results = _serialize_results(engine, generations)

    strategies = _interpret_clusters(
        results["clusters"],
        world_model["concepts"],
        options,
        factor_names,
    )
    strategies.sort(key=lambda s: s["cell_count"], reverse=True)

    # Update session cache
    _session["strategies"] = strategies
    _session["options"] = options

    emergent_count = sum(1 for s in strategies if s["is_emergent"])
    strongest = strategies[0] if strategies else None

    return {
        "question": question,
        "followup": True,
        "original_question": _session["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": results["final_population"],
            "generations_run": generations,
            "total_generations_lifetime": engine.generation,
        },
    }
