"""AST-based linting of generated extraction scripts.

Each rule is a function that takes (ast_tree, source, schema) and returns
a list of LintViolation. Rules are registered in RULES dict — adding a
rule requires one function + one dict entry.

MVP rules: no_try_except, no_forbidden_imports, no_llm_calls, schema_key_match.
More rules can be added as we see real failure patterns.
"""

from __future__ import annotations

import ast
import re
from typing import Any

from agentic.models import FieldSchema, LintViolation


def check_no_try_except(tree: ast.AST, source: str, schema: list[FieldSchema]) -> list[LintViolation]:
    """Flag any try/except blocks."""
    violations = []
    for node in ast.walk(tree):
        if isinstance(node, ast.Try):
            violations.append(
                LintViolation(
                    rule="no_try_except",
                    line=node.lineno,
                    column=node.col_offset,
                    message="try/except blocks hide errors. Let the code crash — the error is the signal.",
                    severity="error",
                    snippet=ast.get_source_segment(source, node)[:100] if ast.get_source_segment(source, node) else "",
                )
            )
    return violations


def check_no_forbidden_imports(tree: ast.AST, source: str, schema: list[FieldSchema]) -> list[LintViolation]:
    """Block dangerous imports."""
    forbidden = {"os", "sys", "subprocess", "shutil", "requests", "urllib", "http"}
    violations = []
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            for alias in node.names:
                if alias.name.split(".")[0] in forbidden:
                    violations.append(
                        LintViolation(
                            rule="no_forbidden_imports",
                            line=node.lineno,
                            column=node.col_offset,
                            message=f"Import '{alias.name}' is not allowed in extraction scripts.",
                            severity="error",
                        )
                    )
        elif isinstance(node, ast.ImportFrom):
            if node.module and node.module.split(".")[0] in forbidden:
                violations.append(
                    LintViolation(
                        rule="no_forbidden_imports",
                        line=node.lineno,
                        column=node.col_offset,
                        message=f"Import from '{node.module}' is not allowed.",
                        severity="error",
                    )
                )
    return violations


def check_no_llm_calls(tree: ast.AST, source: str, schema: list[FieldSchema]) -> list[LintViolation]:
    """Flag .ask() or .extract() with client= — these are LLM calls, not deterministic."""
    violations = []
    for node in ast.walk(tree):
        if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
            if node.func.attr == "ask":
                violations.append(
                    LintViolation(
                        rule="no_llm_calls",
                        line=node.lineno,
                        column=node.col_offset,
                        message=".ask() calls an LLM at runtime. Use deterministic selectors instead.",
                        severity="error",
                    )
                )
            elif node.func.attr == "extract":
                # Check if client= is passed
                for kw in node.keywords:
                    if kw.arg == "client":
                        violations.append(
                            LintViolation(
                                rule="no_llm_calls",
                                line=node.lineno,
                                column=node.col_offset,
                                message=".extract(client=...) calls an LLM. Use deterministic selectors.",
                                severity="error",
                            )
                        )
                        break
    return violations


def check_no_data_values_in_selectors(
    tree: ast.AST, source: str, schema: list[FieldSchema]
) -> list[LintViolation]:
    """Flag :contains() arguments that look like data values instead of labels."""
    violations = []

    # Heuristics for "looks like data"
    date_pattern = re.compile(r"\d{2,}[-/]\d{2,}")
    currency_pattern = re.compile(r"^\$[\d,]+")
    mostly_numeric = re.compile(r"^[\d\s.,/$%+-]+$")

    for node in ast.walk(tree):
        if not isinstance(node, ast.Call):
            continue
        if not isinstance(node.func, ast.Attribute):
            continue
        if node.func.attr not in ("find", "find_all"):
            continue
        if not node.args or not isinstance(node.args[0], ast.Constant):
            continue

        selector = node.args[0].value
        if not isinstance(selector, str):
            continue

        # Extract :contains("...") arguments
        contains_matches = re.findall(r':contains\(["\'](.+?)["\']\)', selector)
        for value in contains_matches:
            if date_pattern.search(value):
                violations.append(
                    LintViolation(
                        rule="no_data_values_in_selectors",
                        line=node.lineno,
                        column=node.col_offset,
                        message=f"'{value}' looks like a date — don't put data values in selectors.",
                        severity="error",
                        snippet=selector,
                    )
                )
            elif currency_pattern.match(value):
                violations.append(
                    LintViolation(
                        rule="no_data_values_in_selectors",
                        line=node.lineno,
                        column=node.col_offset,
                        message=f"'{value}' looks like a currency value — don't put data values in selectors.",
                        severity="error",
                        snippet=selector,
                    )
                )
            elif mostly_numeric.match(value) and len(value) > 3:
                violations.append(
                    LintViolation(
                        rule="no_data_values_in_selectors",
                        line=node.lineno,
                        column=node.col_offset,
                        message=f"'{value}' looks like a numeric value — don't put data values in selectors.",
                        severity="warning",
                        snippet=selector,
                    )
                )
            elif len(value) > 40:
                violations.append(
                    LintViolation(
                        rule="label_likeness",
                        line=node.lineno,
                        column=node.col_offset,
                        message=f"Selector text is {len(value)} chars — labels should be short (<40 chars).",
                        severity="warning",
                        snippet=selector,
                    )
                )

    return violations


def check_no_absolute_coordinates(
    tree: ast.AST, source: str, schema: list[FieldSchema]
) -> list[LintViolation]:
    """Flag direct coordinate access (.x0, .top, .x1, .bottom) and hardcoded coordinate lists."""
    violations = []
    coord_attrs = {"x0", "x1", "top", "bottom", "y0", "y1"}

    for node in ast.walk(tree):
        if isinstance(node, ast.Attribute) and node.attr in coord_attrs:
            violations.append(
                LintViolation(
                    rule="no_absolute_coordinates",
                    line=node.lineno,
                    column=node.col_offset,
                    message=f".{node.attr} is a coordinate — use spatial navigation instead.",
                    severity="warning",
                    snippet=f".{node.attr}",
                )
            )

    # Check for hardcoded coordinate lists like verticals=[51.0, 165.0]
    hardcoded = re.findall(r"(verticals|horizontals)\s*=\s*\[[\d.,\s]+\]", source)
    for match in hardcoded:
        violations.append(
            LintViolation(
                rule="no_absolute_coordinates",
                line=0,
                column=0,
                message=f"Hardcoded coordinates in '{match}' — use .from_whitespace() or .from_content().",
                severity="error",
            )
        )

    return violations


def check_schema_key_match(tree: ast.AST, source: str, schema: list[FieldSchema]) -> list[LintViolation]:
    """Verify the output dict keys match the schema field names."""
    violations = []
    expected_keys = {f.name for f in schema}

    assigned_keys = set()
    for node in ast.walk(tree):
        # Check result["key"] = ... assignments (scoped to "result" variable)
        if isinstance(node, ast.Subscript) and isinstance(node.slice, ast.Constant):
            if isinstance(node.slice.value, str):
                # Only count if target is named "result"
                if isinstance(node.value, ast.Name) and node.value.id == "result":
                    assigned_keys.add(node.slice.value)
        # Also check dict literals in return statements
        elif isinstance(node, ast.Dict):
            for key in node.keys:
                if isinstance(key, ast.Constant) and isinstance(key.value, str):
                    assigned_keys.add(key.value)

    # Only flag if we found some assignments (otherwise the script structure is too different)
    if assigned_keys:
        missing_keys = expected_keys - assigned_keys
        for key in missing_keys:
            violations.append(
                LintViolation(
                    rule="schema_key_match",
                    line=0,
                    column=0,
                    message=f"Schema field '{key}' is not assigned in the output dict.",
                    severity="warning",
                )
            )

    return violations


# ── Rule registry ────────────────────────────────────────────────────────────

RULES: dict[str, Any] = {
    "no_try_except": check_no_try_except,
    "no_data_values_in_selectors": check_no_data_values_in_selectors,
    "no_absolute_coordinates": check_no_absolute_coordinates,
    "no_forbidden_imports": check_no_forbidden_imports,
    "no_llm_calls": check_no_llm_calls,
    "label_likeness": check_no_data_values_in_selectors,  # same function handles both
    "schema_key_match": check_schema_key_match,
}


def lint_script(
    script: str,
    schema: list[FieldSchema],
    lint_rules: list[str] | None = None,
) -> list[LintViolation]:
    """Run all configured lint rules against a script."""
    try:
        tree = ast.parse(script)
    except SyntaxError as e:
        return [
            LintViolation(
                rule="syntax_error",
                line=e.lineno or 0,
                column=e.offset or 0,
                message=f"Syntax error: {e.msg}",
                severity="error",
            )
        ]

    violations = []
    rules_to_run = lint_rules or list(RULES.keys())
    seen_rules = set()  # avoid running same function twice

    for rule_name in rules_to_run:
        if rule_name in RULES:
            func = RULES[rule_name]
            if id(func) not in seen_rules:
                seen_rules.add(id(func))
                violations.extend(func(tree, script, schema))

    return violations
