"""AI-assisted repair for failing baseline tests.

Rules (hard constraints):
- Max 1 repair attempt per test — checked BEFORE any Claude call.
- Three failure categories, handled in priority order:
    1. Selector not found → deterministic re-resolution first, then AI.
    2. Timeout → widen timeouts / simplify selectors.
    3. Assertion failure → AI rewrites the assertion value/selector.
- If repair_attempts >= 1 → immediately mark UNSTABLE; do NOT call Claude.
- Do NOT mix repair logic with generation or stability validation.
"""

from __future__ import annotations

import json
import re
from pathlib import Path

from .models import BaselineTest, TestStatus

# ---------------------------------------------------------------------------
# Failure category detection
# ---------------------------------------------------------------------------

_SELECTOR_ERRORS = ("strict mode violation", "locator.click", "locator.fill",
                    "no element", "element not found", "waiting for selector",
                    "Target closed", "context was destroyed")
_TIMEOUT_ERRORS  = ("Timeout", "timed out", "TimeoutError", "exceeded")
_ASSERTION_ERRORS = ("AssertionError", "assert ", "Expected:", "Received:",
                     "to have url", "to be visible", "to have text")


def _categorize_failure(reason: str) -> str:
    """Return 'selector' | 'timeout' | 'assertion' | 'unknown'."""
    low = reason.lower()
    if any(e.lower() in low for e in _SELECTOR_ERRORS):
        return "selector"
    if any(e.lower() in low for e in _TIMEOUT_ERRORS):
        return "timeout"
    if any(e.lower() in low for e in _ASSERTION_ERRORS):
        return "assertion"
    return "unknown"


# ---------------------------------------------------------------------------
# Deterministic selector re-resolution (no AI)
# ---------------------------------------------------------------------------

_SELECTOR_RE = re.compile(
    r'page\.(?:locator|get_by_(?:role|label|placeholder|text|test_id))\s*\(([^)]+)\)'
)
_DATA_TESTID_IN_SOURCE = re.compile(r'data-testid=["\'`]([^"\'`]+)["\'`]')
_DATA_AUTOMATION_IN_SOURCE = re.compile(r'data-automation-id=["\'`]([^"\'`]+)["\'`]')


def _find_selector_in_source(selector: str, scan_root: Path) -> str | None:
    """Try to find a working selector from source files. Returns new selector or None."""
    from maeris_mcp.tidy import SUPPORTED_EXTENSIONS, SKIP_DIRS

    # Extract the CSS identifier to look for
    identifier = re.sub(r'^[\.\#\[]', '', selector.split("[")[0]).strip()
    if not identifier or len(identifier) < 3:
        return None

    for path in sorted(scan_root.rglob("*")):
        if not path.is_file() or path.suffix not in SUPPORTED_EXTENSIONS:
            continue
        if any(part in SKIP_DIRS for part in path.parts):
            continue
        try:
            content = path.read_text(encoding="utf-8", errors="ignore")
        except OSError:
            continue

        if identifier not in content:
            continue

        # Prefer data-testid attributes near the identifier
        for m in _DATA_TESTID_IN_SOURCE.finditer(content):
            val = m.group(1)
            if identifier.lower() in val.lower() or val.lower() in identifier.lower():
                return f'[data-testid="{val}"]'

        for m in _DATA_AUTOMATION_IN_SOURCE.finditer(content):
            val = m.group(1)
            if identifier.lower() in val.lower():
                return f'[data-automation-id="{val}"]'

    return None


def _deterministic_repair(test: BaselineTest, scan_root: Path) -> bool:
    """Try to fix selector issues without AI. Returns True if any step was updated."""
    repaired = False
    for step in test.steps:
        selector = step.get("selector", "")
        if not selector:
            continue
        new_sel = _find_selector_in_source(selector, scan_root)
        if new_sel and new_sel != selector:
            step["selector"] = new_sel
            repaired = True
    return repaired


# ---------------------------------------------------------------------------
# AI repair
# ---------------------------------------------------------------------------

_REPAIR_PROMPT = """\
A Playwright baseline test is failing after execution.

Test name: {name}
Page: {page}
Failure category: {category}
Failure reason: {reason}

Current test steps:
{steps_json}

Current assertions:
{assertions_json}

Fix the failing test so it will pass. Rules:
- Prefer data-testid, aria-label, or role selectors.
- Never invent selectors that don't exist on the page.
- For timeout issues: simplify steps or increase explicit wait time via page.wait_for_selector().
- For assertion issues: fix the assertion value or selector.
- Keep all steps that are already correct.

Return ONLY a JSON object (no prose):
{{
  "steps": [...],
  "assertions": [...]
}}
"""


def _ai_repair(test: BaselineTest, model: str) -> bool:
    """Ask Claude to fix the test steps/assertions. Returns True on success."""
    from maeris_mcp.ai_adapter.repair_suggestions import _claude_run, _parse_json

    prompt = _REPAIR_PROMPT.format(
        name=test.name,
        page=test.page,
        category=_categorize_failure(test.failure_reason),
        reason=test.failure_reason,
        steps_json=json.dumps(test.steps, indent=2),
        assertions_json=json.dumps(test.assertions, indent=2),
    )

    try:
        raw = _claude_run(prompt, model, timeout=180)
        result = _parse_json(raw)
        if not isinstance(result, dict):
            return False
        new_steps = result.get("steps")
        new_assertions = result.get("assertions")
        if isinstance(new_steps, list):
            test.steps = new_steps
        if isinstance(new_assertions, list):
            test.assertions = new_assertions
        return True
    except Exception:
        return False


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


def attempt_repair(
    test: BaselineTest,
    scan_root: Path,
    base_url: str,
    output_dir: Path,
    model: str,
) -> bool:
    """Attempt to repair a failing baseline test.

    Hard constraints:
    - If test.repair_attempts >= 1: immediately marks UNSTABLE; returns False.
    - Deterministic selector re-resolution is tried first.
    - AI is invoked only if deterministic repair is insufficient.
    - Increments test.repair_attempts before any repair action.
    - After repair, regenerates the .py script via test_executor.

    Args:
        test: The failing BaselineTest to repair.
        scan_root: Frontend source root for deterministic re-resolution.
        base_url: App base URL for script regeneration.
        output_dir: Directory where .py scripts live.
        model: Claude model ID for AI repair.

    Returns:
        True if a repair was applied (test may still fail on re-run).
        False if max attempts reached or repair could not be applied.
    """
    # Hard constraint: max 1 repair attempt
    if test.repair_attempts >= 1:
        test.status = TestStatus.UNSTABLE
        test.failure_reason = (
            f"max repair attempts reached (reason: {test.failure_reason})"
        )
        return False

    test.repair_attempts += 1
    category = _categorize_failure(test.failure_reason)

    repaired = False

    # Step 1: deterministic re-resolution for selector issues
    if category in ("selector", "unknown"):
        repaired = _deterministic_repair(test, scan_root)

    # Step 2: AI repair if deterministic didn't fix it (or for timeout/assertion)
    if not repaired or category in ("timeout", "assertion"):
        repaired = _ai_repair(test, model)

    if not repaired:
        test.status = TestStatus.UNSTABLE
        test.failure_reason = f"repair failed ({category}): {test.failure_reason}"
        return False

    # Regenerate the .py script with repaired steps
    from .test_executor import regenerate_script
    try:
        regenerate_script(test, base_url, output_dir)
    except Exception as exc:
        test.status = TestStatus.UNSTABLE
        test.failure_reason = f"script regeneration failed: {exc}"
        return False

    # If script is still marked UNSTABLE due to fragility, bail
    if test.status == TestStatus.UNSTABLE:
        return False

    # Reset state for re-validation
    test.status = TestStatus.PENDING
    test.pass_count = 0
    return True
