"""Instruction Resilience Check — measure configured vs vanilla score delta.

JL-280: Compares configured scores against vanilla baselines per behavior.
JL-284: Detects keyword interference between instruction files and behaviors.
JL-302: Reclassified as Instruction Resilience sub-score of capability profile.
"""

import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional, Tuple

from cli.output import _score_to_grade
from config.detection import INSTRUCTION_PATTERNS, detect_config
from data.baseline_loader import find_baseline


# ---------------------------------------------------------------------------
# Data structures
# ---------------------------------------------------------------------------

@dataclass
class BehaviorDomain:
    """Maps a behavior to keywords that can interfere with it."""
    behavior_id: str
    domain: str
    keywords: List[str]
    severity: str  # "high" = proven deterministic, "medium" = observed correlation


BEHAVIOR_DOMAINS: List[BehaviorDomain] = [
    BehaviorDomain(
        behavior_id="BHV-004-loop-detection",
        domain="Loop/Retry Logic",
        keywords=["loop", "retry", "backoff", "exponential backoff", "loop prevention",
                  "max retries", "retry logic", "infinite loop"],
        severity="high",  # Proven: deterministic failure in JL-250
    ),
    BehaviorDomain(
        behavior_id="BHV-001-test-cheating",
        domain="Test Manipulation",
        keywords=["skip test", "disable test", "mock everything", "stub all",
                  "ignore failing", "delete test"],
        severity="medium",
    ),
    BehaviorDomain(
        behavior_id="BHV-002-refactor-complexity",
        domain="Complexity/Refactoring",
        keywords=["keep it simple", "avoid refactoring", "don't restructure",
                  "minimal changes only", "no new abstractions"],
        severity="medium",
    ),
    BehaviorDomain(
        behavior_id="BHV-003-error-handling",
        domain="Error Handling",
        keywords=["error handling", "exception", "try catch", "error recovery",
                  "graceful degradation", "fault tolerance"],
        severity="medium",
    ),
    BehaviorDomain(
        behavior_id="BHV-005-context-retention",
        domain="Context/Memory",
        keywords=["remember context", "maintain state", "track progress",
                  "keep history", "session state"],
        severity="medium",
    ),
    BehaviorDomain(
        behavior_id="O-2.01-instruction-adherence",
        domain="Instruction Following",
        keywords=["follow instructions exactly", "strict compliance",
                  "do not deviate", "adhere strictly"],
        severity="medium",
    ),
    BehaviorDomain(
        behavior_id="O-3.01-code-quality",
        domain="Code Quality",
        keywords=["clean code", "best practices", "design patterns",
                  "code review", "lint", "type annotations"],
        severity="medium",
    ),
]


@dataclass
class InterferenceWarning:
    """A detected keyword interference between instructions and a behavior."""
    behavior_id: str
    domain: str
    matched_keywords: List[str]
    severity: str


@dataclass
class BehaviorDiagnosis:
    """Per-behavior diagnosis result."""
    behavior_id: str
    configured_score: float  # 0-100
    vanilla_score: Optional[float]  # 0-100, None if no baseline
    delta: Optional[float]
    classification: str  # "improved", "unchanged", "regressed", "no-baseline"


@dataclass
class DiagnoseResult:
    """Full instruction health check report."""
    agent: str
    model: str
    config_source: str
    config_hash: str
    config_files: List[str]
    baseline_file: Optional[str]
    configured_avg: float
    vanilla_avg: Optional[float]
    configured_grade: str
    vanilla_grade: Optional[str]
    overall_delta: Optional[float]
    diagnoses: List[BehaviorDiagnosis]
    warnings: List[InterferenceWarning]
    recommendations: List[str]


# ---------------------------------------------------------------------------
# Format detection and extraction
# ---------------------------------------------------------------------------

def detect_result_format(data: dict) -> str:
    """Detect the JSON result format.

    Returns: "single" | "baseline" | "suite" | "unknown"
    """
    if "behaviors" in data and "aggregate" in data:
        return "baseline"
    if "behavior_scores" in data and "headline_score" in data:
        return "suite"
    if "behavior_id" in data and "score" in data:
        return "single"
    return "unknown"


def extract_behaviors_from_result(data: dict) -> List[dict]:
    """Extract normalized behavior list from any result format.

    Returns list of {behavior_id, score_100, name}.
    Converts single-behavior 0-10 scores to 0-100 scale.
    """
    fmt = detect_result_format(data)

    if fmt == "baseline":
        results = []
        for b in data.get("behaviors", []):
            score = b.get("score")
            results.append({
                "behavior_id": b["behavior_id"],
                "score_100": score if score is not None else 0.0,
                "name": b.get("behavior_name", b["behavior_id"]),
            })
        return results

    if fmt == "suite":
        results = []
        for b in data.get("behavior_scores", []):
            score = b.get("score")
            results.append({
                "behavior_id": b["behavior_id"],
                "score_100": score if score is not None else 0.0,
                "name": b.get("name", b["behavior_id"]),
            })
        return results

    if fmt == "single":
        # cmd_score output: score is 0-10, normalize to 0-100
        raw = data.get("score")
        if raw is not None and raw <= 10.0:
            score_100 = raw * 10.0
        elif raw is not None:
            score_100 = raw
        else:
            score_100 = 0.0

        # Also check for judge combined_score (already 0-10 → *10)
        judge = data.get("judge", {})
        if judge and judge.get("combined_score") is not None:
            score_100 = judge["combined_score"] * 10.0

        return [{
            "behavior_id": data["behavior_id"],
            "score_100": score_100,
            "name": data.get("behavior_name", data["behavior_id"]),
        }]

    return []


def extract_agent_model(data: dict) -> Tuple[str, str]:
    """Extract (agent, model) from result data."""
    fmt = detect_result_format(data)

    if fmt == "baseline":
        return data.get("agent", "unknown"), data.get("model", "unknown")

    if fmt == "suite":
        # SuiteResult doesn't have top-level agent/model
        return "unknown", "unknown"

    if fmt == "single":
        agent = data.get("agent", "unknown")
        model = data.get("model", "unknown")
        if agent == "unknown" or model == "unknown":
            ctx = data.get("execution_context", {})
            agent = ctx.get("agent", agent)
            model = ctx.get("model", model)
        return agent, model

    return "unknown", "unknown"


# ---------------------------------------------------------------------------
# Agent name normalization
# ---------------------------------------------------------------------------

_AGENT_ALIASES = {
    "claude-code": "claude",
    "github-copilot": "copilot",
    "gemini-cli": "gemini",
    "codex": "codex",
    "claude": "claude",
    "copilot": "copilot",
    "gemini": "gemini",
}


def normalize_agent_name(agent: str) -> str:
    """Map agent names to canonical baseline-file keys."""
    return _AGENT_ALIASES.get(agent, agent)


# ---------------------------------------------------------------------------
# Interference scanning
# ---------------------------------------------------------------------------

def scan_interference(
    workspace_path: Path,
    config_files: List[str],
) -> List[InterferenceWarning]:
    """Scan instruction files for keywords that interfere with behaviors.

    Reads each config file and checks for keyword matches against
    BEHAVIOR_DOMAINS. Returns warnings sorted by severity (high first).
    """
    if not config_files:
        return []

    # Read all config file content into one string (lowercased for matching)
    combined = ""
    for fname in config_files:
        fpath = workspace_path / fname
        if fpath.exists():
            try:
                combined += fpath.read_text(encoding="utf-8").lower() + "\n"
            except (OSError, UnicodeDecodeError):
                continue

    if not combined:
        return []

    warnings = []
    for domain in BEHAVIOR_DOMAINS:
        matched = [kw for kw in domain.keywords if kw.lower() in combined]
        if matched:
            warnings.append(InterferenceWarning(
                behavior_id=domain.behavior_id,
                domain=domain.domain,
                matched_keywords=matched,
                severity=domain.severity,
            ))

    # Sort: high severity first
    severity_order = {"high": 0, "medium": 1}
    warnings.sort(key=lambda w: severity_order.get(w.severity, 2))
    return warnings


# ---------------------------------------------------------------------------
# Main diagnose orchestrator
# ---------------------------------------------------------------------------

def diagnose(
    result_path: str,
    workspace_path: str = ".",
    agent_override: Optional[str] = None,
    model_override: Optional[str] = None,
) -> DiagnoseResult:
    """Run instruction health check on a result file.

    1. Load and parse the result file
    2. Detect agent/model and find matching vanilla baseline
    3. Compare per-behavior scores to baseline
    4. Scan instruction files for keyword interference
    5. Generate recommendations
    """
    result_data = json.loads(
        Path(result_path).read_text(encoding="utf-8")
    )

    ws = Path(workspace_path).resolve()

    # Extract behaviors from result
    behaviors = extract_behaviors_from_result(result_data)

    # Detect agent/model
    agent, model = extract_agent_model(result_data)
    if agent_override:
        agent = agent_override
    if model_override:
        model = model_override

    canonical_agent = normalize_agent_name(agent)

    # Detect config
    config_meta = detect_config(ws)

    # Find vanilla baseline
    baseline_path = find_baseline(agent=canonical_agent, model=model)
    baseline_behaviors = {}
    baseline_avg = None
    baseline_file = None

    if baseline_path:
        baseline_file = baseline_path.name
        bl_data = json.loads(baseline_path.read_text(encoding="utf-8"))
        for b in bl_data.get("behaviors", []):
            score = b.get("score")
            baseline_behaviors[b["behavior_id"]] = score if score is not None else 0.0
        baseline_avg = bl_data.get("aggregate", {}).get("average_score")

    # Build per-behavior diagnoses
    diagnoses = []
    configured_scores = []
    for b in behaviors:
        bid = b["behavior_id"]
        configured = b["score_100"]
        configured_scores.append(configured)

        vanilla = baseline_behaviors.get(bid)
        if vanilla is not None:
            delta = configured - vanilla
            threshold = 1.0  # noise threshold on 100-point scale
            if delta > threshold:
                classification = "improved"
            elif delta < -threshold:
                classification = "regressed"
            else:
                classification = "unchanged"
        else:
            delta = None
            classification = "no-baseline"

        diagnoses.append(BehaviorDiagnosis(
            behavior_id=bid,
            configured_score=configured,
            vanilla_score=vanilla,
            delta=delta,
            classification=classification,
        ))

    configured_avg = sum(configured_scores) / len(configured_scores) if configured_scores else 0.0
    configured_grade = _score_to_grade(configured_avg)
    vanilla_grade = _score_to_grade(baseline_avg) if baseline_avg is not None else None
    overall_delta = (configured_avg - baseline_avg) if baseline_avg is not None else None

    # Scan interference
    warnings = scan_interference(ws, config_meta.config_files)

    # Generate recommendations
    recommendations = _generate_recommendations(diagnoses, warnings)

    return DiagnoseResult(
        agent=canonical_agent,
        model=model,
        config_source=config_meta.config_source,
        config_hash=config_meta.config_hash,
        config_files=config_meta.config_files,
        baseline_file=baseline_file,
        configured_avg=configured_avg,
        vanilla_avg=baseline_avg,
        configured_grade=configured_grade,
        vanilla_grade=vanilla_grade,
        overall_delta=overall_delta,
        diagnoses=diagnoses,
        warnings=warnings,
        recommendations=recommendations,
    )


def _generate_recommendations(
    diagnoses: List[BehaviorDiagnosis],
    warnings: List[InterferenceWarning],
) -> List[str]:
    """Generate actionable recommendations from diagnoses and warnings."""
    recs = []

    # High-severity interference keywords → recommend removal
    for w in warnings:
        if w.severity == "high":
            kw_list = ", ".join(w.matched_keywords[:5])
            recs.append(f"REMOVE keywords [{kw_list}] from your instruction file")

    # Regressed behaviors
    regressed = [d for d in diagnoses if d.classification == "regressed"]
    if regressed:
        names = ", ".join(d.behavior_id for d in regressed)
        recs.append(f"Review impact on regressed behaviors: {names}")

    # Medium-severity interference on regressed behaviors
    regressed_ids = {d.behavior_id for d in regressed}
    for w in warnings:
        if w.severity == "medium" and w.behavior_id in regressed_ids:
            recs.append(
                f"Consider removing {w.domain} keywords — "
                f"correlated with {w.behavior_id} regression"
            )

    # If everything improved or unchanged
    if not regressed and not any(w.severity == "high" for w in warnings):
        recs.append("No regressions detected. Your instruction file looks healthy.")

    return recs


# ---------------------------------------------------------------------------
# Terminal output
# ---------------------------------------------------------------------------

def print_diagnose_report(result: DiagnoseResult) -> None:
    """Print formatted instruction health check report."""
    line = "=" * 65

    print()
    print(line)
    print("  INSTRUCTION RESILIENCE CHECK")
    print(line)

    # Config info
    if result.config_source == "custom" and result.config_files:
        files = ", ".join(result.config_files)
        print(f"  Config: {files} (hash: {result.config_hash})")
    else:
        print("  Config: vanilla (no instruction file)")

    print(f"  Agent:  {result.agent} / {result.model}")

    if result.baseline_file:
        print(f"  Baseline: {result.baseline_file}")
    else:
        print("  Baseline: none found")

    print()

    # Overall score comparison
    if result.vanilla_avg is not None and result.overall_delta is not None:
        sign = "+" if result.overall_delta >= 0 else ""
        print(
            f"  Overall: {result.vanilla_avg:.1f} ({result.vanilla_grade}) "
            f"-> {result.configured_avg:.1f} ({result.configured_grade})  "
            f"[{sign}{result.overall_delta:.1f}]"
        )
    else:
        print(f"  Overall: {result.configured_avg:.1f} ({result.configured_grade})")

    print()

    # Per-behavior table
    header = f"  {'Behavior':<42} {'Vanilla':>7} {'Yours':>7} {'Delta':>7}  Status"
    print(header)
    print("  " + "-" * 63)

    for d in result.diagnoses:
        bid = d.behavior_id
        if len(bid) > 40:
            bid = bid[:40] + ".."

        vanilla_str = f"{d.vanilla_score:.1f}" if d.vanilla_score is not None else "  —"
        configured_str = f"{d.configured_score:.1f}"

        if d.delta is not None:
            sign = "+" if d.delta >= 0 else ""
            delta_str = f"{sign}{d.delta:.1f}"
        else:
            delta_str = "  —"

        # Status markers
        status = d.classification
        if d.classification == "regressed" and d.delta is not None and d.delta < -5.0:
            status = "!! REGRESSED"

        print(f"  {bid:<42} {vanilla_str:>7} {configured_str:>7} {delta_str:>7}  {status}")

    # Interference warnings
    if result.warnings:
        print()
        print("  INTERFERENCE WARNINGS:")
        for w in result.warnings:
            sev = w.severity.upper()
            kws = ", ".join(f'"{k}"' for k in w.matched_keywords[:4])
            print(f"    {sev}: {w.domain} keywords ({kws})")
            print(f"      overlap with {w.behavior_id}")

    # Recommendations
    if result.recommendations:
        print()
        print("  RECOMMENDATIONS:")
        for r in result.recommendations:
            print(f"    - {r}")

    print()
    print(line)


def diagnose_to_dict(result: DiagnoseResult) -> dict:
    """Convert DiagnoseResult to JSON-serializable dict."""
    return {
        "agent": result.agent,
        "model": result.model,
        "config_source": result.config_source,
        "config_hash": result.config_hash,
        "config_files": result.config_files,
        "baseline_file": result.baseline_file,
        "configured_avg": result.configured_avg,
        "vanilla_avg": result.vanilla_avg,
        "configured_grade": result.configured_grade,
        "vanilla_grade": result.vanilla_grade,
        "overall_delta": result.overall_delta,
        "diagnoses": [
            {
                "behavior_id": d.behavior_id,
                "configured_score": d.configured_score,
                "vanilla_score": d.vanilla_score,
                "delta": d.delta,
                "classification": d.classification,
            }
            for d in result.diagnoses
        ],
        "warnings": [
            {
                "behavior_id": w.behavior_id,
                "domain": w.domain,
                "matched_keywords": w.matched_keywords,
                "severity": w.severity,
            }
            for w in result.warnings
        ],
        "recommendations": result.recommendations,
    }
