"""Pydantic Response Model Nullability Checker.

Detects schema mismatches between Pydantic response models and their data sources:
1. Response model requires field (non-Optional) but SQLAlchemy model is nullable
2. Raw SQL INSERT statements missing required fields
3. Test fixtures/factories missing required fields

This prevents runtime ResponseValidationError (500 errors) when data doesn't match schema.

Example bug this catches:
    # UserResponse requires full_name: str (non-Optional)
    # But INSERT INTO users (email, password) ... doesn't set full_name
    # Result: 500 error when returning user via API
"""

import ast
import re
from pathlib import Path
from typing import Dict, List, Set

from stablestack.checkers.base import BaseChecker
from stablestack.models import Finding, Severity, Tier


class PydanticNullabilityChecker(BaseChecker):
    """Checks for nullability mismatches between Pydantic models and data sources."""

    rule_id = "SCHEMA001"
    rule_name = "pydantic-nullability-mismatch"
    description = "Pydantic response model requires field that is nullable in database or missing in data sources."
    why = (
        "When a Pydantic response model marks a field as required (non-Optional) but the "
        "database column is nullable, or raw SQL/test fixtures don't populate it, you'll get "
        "500 Internal Server Errors at runtime. FastAPI's ResponseValidationError surfaces "
        "during serialization, making these bugs hard to trace back to their origin. "
        "Ensure all required response fields are always populated."
    )
    severity = Severity.ERROR
    tier = Tier.CRITICAL
    file_extensions = set()  # Project-level checker
    is_project_checker = True

    # Common auto-generated fields that don't need to be in INSERTs
    AUTO_FIELDS = {"id", "created_at"}

    def check_file(self, path: Path, content: str) -> List[Finding]:  # noqa: ARG002
        """Not used for project checker."""
        return []

    def check_project(self, project_root: Path) -> List[Finding]:
        """Run all nullability checks across the project."""
        findings: List[Finding] = []

        # Try to find backend/models and backend/api/schemas directories
        # Support various project layouts
        backend_dirs = [
            project_root / "backend",
            project_root / "src",
            project_root / "app",
            project_root,
        ]

        backend_dir = None
        for bd in backend_dirs:
            if (bd / "models").exists() or (bd / "api").exists():
                backend_dir = bd
                break

        if backend_dir is None:
            # No recognizable backend structure
            return findings

        # Step 1: Extract response model requirements
        response_models = self._extract_response_models(backend_dir)

        if not response_models:
            return findings

        # Step 2: Extract SQLAlchemy model nullability
        sqlalchemy_models = self._extract_sqlalchemy_models(backend_dir)

        # Step 3: Check for mismatches between response models and DB models
        findings.extend(
            self._check_response_vs_db_nullability(
                response_models, sqlalchemy_models, backend_dir
            )
        )

        # Step 4: Check raw SQL INSERT statements
        findings.extend(
            self._check_raw_sql_inserts(response_models, project_root)
        )

        # Step 5: Check test fixtures and factories
        findings.extend(
            self._check_test_fixtures(response_models, project_root)
        )

        return findings

    # ── Extraction helpers ──────────────────────────────────────────────

    def _extract_response_models(
        self, backend_dir: Path,
    ) -> Dict[str, Dict[str, "ast.AST | Path | int"]]:
        """Extract Pydantic response/schema models and their required fields.

        Returns {ModelName: {"fields": {field_name: is_required}, "path": Path, "line": int}}
        """
        models: Dict[str, dict] = {}

        for py_file in backend_dir.rglob("*.py"):
            try:
                source = py_file.read_text(encoding="utf-8")
                tree = ast.parse(source)
            except (OSError, SyntaxError, UnicodeDecodeError):
                continue

            for node in ast.walk(tree):
                if not isinstance(node, ast.ClassDef):
                    continue

                # Only look at classes that likely inherit from BaseModel/Schema
                base_names = [
                    getattr(b, "id", getattr(b, "attr", ""))
                    for b in node.bases
                ]
                is_pydantic = any(
                    "Model" in n or "Schema" in n or "Response" in n
                    for n in base_names
                )
                if not is_pydantic:
                    continue

                required_fields: Dict[str, bool] = {}
                for item in node.body:
                    if not isinstance(item, ast.AnnAssign):
                        continue
                    if not isinstance(item.target, ast.Name):
                        continue

                    field_name = item.target.id
                    if field_name.startswith("_"):
                        continue

                    # Determine if field is required (non-Optional, no default)
                    is_optional = self._is_optional_annotation(item.annotation)
                    has_default = item.value is not None
                    required_fields[field_name] = not is_optional and not has_default

                if required_fields:
                    models[node.name] = {
                        "fields": required_fields,
                        "path": py_file,
                        "line": node.lineno,
                    }

        return models

    def _extract_sqlalchemy_models(
        self, backend_dir: Path,
    ) -> Dict[str, Dict[str, bool]]:
        """Extract SQLAlchemy models and field nullability.

        Returns {ModelName: {field_name: is_nullable}}
        """
        models: Dict[str, Dict[str, bool]] = {}

        for py_file in backend_dir.rglob("*.py"):
            try:
                source = py_file.read_text(encoding="utf-8")
            except (OSError, UnicodeDecodeError):
                continue

            # Quick check: must reference Column or mapped_column
            if "Column" not in source and "mapped_column" not in source:
                continue

            try:
                tree = ast.parse(source)
            except SyntaxError:
                continue

            for node in ast.walk(tree):
                if not isinstance(node, ast.ClassDef):
                    continue

                base_names = [
                    getattr(b, "id", getattr(b, "attr", ""))
                    for b in node.bases
                ]
                is_sqla = any(
                    "Base" in n or "Model" in n or "Mixin" in n
                    for n in base_names
                )
                if not is_sqla:
                    continue

                fields: Dict[str, bool] = {}
                for item in node.body:
                    if not isinstance(item, (ast.Assign, ast.AnnAssign)):
                        continue

                    # Get field name
                    if isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name):
                        field_name = item.target.id
                        value = item.value
                    elif isinstance(item, ast.Assign) and len(item.targets) == 1:
                        target = item.targets[0]
                        if isinstance(target, ast.Name):
                            field_name = target.id
                        else:
                            continue
                        value = item.value
                    else:
                        continue

                    if field_name.startswith("_") or field_name == "__tablename__":
                        continue

                    # Check if it's a Column() or mapped_column() call
                    if isinstance(value, ast.Call):
                        func_name = ""
                        if isinstance(value.func, ast.Name):
                            func_name = value.func.id
                        elif isinstance(value.func, ast.Attribute):
                            func_name = value.func.attr
                        if func_name in ("Column", "mapped_column"):
                            nullable = self._get_nullable_kwarg(value)
                            fields[field_name] = nullable

                if fields:
                    models[node.name] = fields

        return models

    # ── Cross-reference checks ──────────────────────────────────────────

    def _check_response_vs_db_nullability(
        self,
        response_models: Dict[str, dict],
        sqlalchemy_models: Dict[str, Dict[str, bool]],
        backend_dir: Path,
    ) -> List[Finding]:
        """Flag response model required fields that are nullable in the DB."""
        findings: List[Finding] = []

        for model_name, model_info in response_models.items():
            # Try to match to a SQLAlchemy model by name convention
            # e.g., UserResponse -> User, UserSchema -> User
            base_name = (
                model_name
                .replace("Response", "")
                .replace("Schema", "")
                .replace("Out", "")
                .replace("Read", "")
            )

            sqla_model = sqlalchemy_models.get(base_name)
            if sqla_model is None:
                continue

            for field_name, is_required in model_info["fields"].items():
                if not is_required:
                    continue
                if field_name in self.AUTO_FIELDS:
                    continue

                # Check if the DB column is nullable
                if field_name in sqla_model and sqla_model[field_name]:
                    findings.append(
                        self.create_finding(
                            path=model_info["path"],
                            line=model_info["line"],
                            column=1,
                            code=f"class {model_name}: {field_name}: ... (required)",
                            message=(
                                f"'{model_name}.{field_name}' is required but "
                                f"'{base_name}.{field_name}' is nullable in the database. "
                                f"This will cause 500 errors when the DB value is NULL."
                            ),
                            fix_hint=(
                                f"Either make '{field_name}' Optional in {model_name}, "
                                f"or make the DB column NOT NULL with a default value."
                            ),
                        )
                    )

        return findings

    def _check_raw_sql_inserts(
        self,
        response_models: Dict[str, dict],
        project_root: Path,
    ) -> List[Finding]:
        """Flag INSERT statements that omit required response model fields."""
        findings: List[Finding] = []

        insert_re = re.compile(
            r"INSERT\s+INTO\s+(\w+)\s*\(([^)]+)\)",
            re.IGNORECASE,
        )

        for py_file in project_root.rglob("*.py"):
            try:
                source = py_file.read_text(encoding="utf-8")
            except (OSError, UnicodeDecodeError):
                continue

            for match in insert_re.finditer(source):
                table_name = match.group(1).lower().rstrip("s")  # naive de-plural
                insert_cols = {
                    c.strip().lower() for c in match.group(2).split(",")
                }
                line_no = source[: match.start()].count("\n") + 1

                # Try to match table to a response model
                for model_name, model_info in response_models.items():
                    base = (
                        model_name
                        .replace("Response", "")
                        .replace("Schema", "")
                        .replace("Out", "")
                        .replace("Read", "")
                        .lower()
                    )
                    if base != table_name:
                        continue

                    missing = []
                    for field, is_required in model_info["fields"].items():
                        if is_required and field not in self.AUTO_FIELDS:
                            if field.lower() not in insert_cols:
                                missing.append(field)

                    if missing:
                        findings.append(
                            self.create_finding(
                                path=py_file,
                                line=line_no,
                                column=1,
                                code=match.group(0)[:80],
                                message=(
                                    f"INSERT INTO {match.group(1)} is missing fields "
                                    f"required by {model_name}: {', '.join(missing)}. "
                                    f"This will cause 500 errors on API response."
                                ),
                                fix_hint=(
                                    f"Add the missing columns to the INSERT, or make "
                                    f"them Optional in {model_name}."
                                ),
                            )
                        )

        return findings

    def _check_test_fixtures(
        self,
        response_models: Dict[str, dict],
        project_root: Path,
    ) -> List[Finding]:
        """Flag test fixtures/factories that omit required response model fields."""
        findings: List[Finding] = []

        # Look for factory_boy, pytest-factoryboy, or manual fixture dicts
        factory_re = re.compile(
            r"class\s+(\w+)Factory\s*\(",
            re.IGNORECASE,
        )

        for py_file in project_root.rglob("*.py"):
            name = py_file.name
            if not (name.startswith("test_") or "factory" in name.lower()
                    or "fixture" in name.lower() or "conftest" in name):
                continue

            try:
                source = py_file.read_text(encoding="utf-8")
            except (OSError, UnicodeDecodeError):
                continue

            for match in factory_re.finditer(source):
                factory_name = match.group(1)
                line_no = source[: match.start()].count("\n") + 1

                # Match factory to response model
                for model_name, model_info in response_models.items():
                    base = (
                        model_name
                        .replace("Response", "")
                        .replace("Schema", "")
                        .replace("Out", "")
                        .replace("Read", "")
                    )
                    if factory_name != base:
                        continue

                    # Parse the factory body to see which fields are set
                    try:
                        tree = ast.parse(source)
                    except SyntaxError:
                        break

                    for node in ast.walk(tree):
                        if not isinstance(node, ast.ClassDef):
                            continue
                        if node.name != f"{factory_name}Factory":
                            continue

                        factory_fields: Set[str] = set()
                        for item in node.body:
                            if isinstance(item, ast.Assign):
                                for t in item.targets:
                                    if isinstance(t, ast.Name):
                                        factory_fields.add(t.id)
                            elif isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name):
                                factory_fields.add(item.target.id)

                        missing = []
                        for field, is_required in model_info["fields"].items():
                            if is_required and field not in self.AUTO_FIELDS:
                                if field not in factory_fields:
                                    missing.append(field)

                        if missing:
                            findings.append(
                                self.create_finding(
                                    path=py_file,
                                    line=line_no,
                                    column=1,
                                    code=f"class {factory_name}Factory",
                                    message=(
                                        f"{factory_name}Factory is missing fields "
                                        f"required by {model_name}: {', '.join(missing)}. "
                                        f"Tests may not catch 500 errors from missing data."
                                    ),
                                    fix_hint=(
                                        f"Add the missing fields to the factory, or make "
                                        f"them Optional in {model_name}."
                                    ),
                                )
                            )

        return findings

    # ── AST helpers ─────────────────────────────────────────────────────

    @staticmethod
    def _is_optional_annotation(annotation: ast.expr | None) -> bool:
        """Check if an AST annotation represents Optional[X] or X | None."""
        if annotation is None:
            return False

        # Optional[X] -> Subscript(Name("Optional"), ...)
        if isinstance(annotation, ast.Subscript):
            if isinstance(annotation.value, ast.Name) and annotation.value.id == "Optional":
                return True

        # X | None -> BinOp(left, BitOr, right)
        if isinstance(annotation, ast.BinOp) and isinstance(annotation.op, ast.BitOr):
            for operand in (annotation.left, annotation.right):
                if isinstance(operand, ast.Constant) and operand.value is None:
                    return True
                if isinstance(operand, ast.Name) and operand.id == "None":
                    return True

        # Direct None type
        if isinstance(annotation, ast.Constant) and annotation.value is None:
            return True
        if isinstance(annotation, ast.Name) and annotation.id == "None":
            return True

        return False

    @staticmethod
    def _get_nullable_kwarg(call: ast.Call) -> bool:
        """Extract nullable= kwarg from Column() or mapped_column(). Default True."""
        for kw in call.keywords:
            if kw.arg == "nullable":
                if isinstance(kw.value, ast.Constant):
                    return bool(kw.value.value)
                # If it's not a simple constant, assume nullable
                return True
        # SQLAlchemy default: nullable=True
        return True
