"""
Gremlin AI helpers for Gemini-powered summaries and explanations.
"""

from __future__ import annotations

import asyncio
import json
import logging
import os
from datetime import datetime, timezone
from functools import partial
from pathlib import Path
from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING
from uuid import uuid4

from sqlalchemy import text as sql_text
from sqlalchemy.ext.asyncio import AsyncSession

if TYPE_CHECKING:
    from .sheet_classifier import TriageSummary

logger = logging.getLogger(__name__)

# Env-backed model constants for easy preview -> GA cutover.
GEMINI_FLASH_MODEL = os.getenv("GEMINI_FLASH_MODEL", "gemini-3-flash-preview")
GEMINI_PRO_MODEL = os.getenv("GEMINI_PRO_MODEL", "gemini-3-pro-preview")

# Default model by tier (used when mode is not specified)
MODEL_BY_TIER = {
    "free": None,  # no AI
    "pro": GEMINI_FLASH_MODEL,
    "scale": GEMINI_FLASH_MODEL,
    "unleashed": GEMINI_FLASH_MODEL,
}

# Mode-based model selection: complex tasks get smarter models
# Modes: data_quality, model, research
MODEL_BY_MODE = {
    "data_quality": GEMINI_FLASH_MODEL,  # Simple cleaning, deduping - fast
    "model": GEMINI_PRO_MODEL,           # Strategic modeling, formulas - best reasoning
    "research": GEMINI_FLASH_MODEL,      # Enrichment orchestration - fast
}

# Thinking levels for Gemini 3: MINIMAL < LOW < MEDIUM < HIGH
# Lower = faster latency, Higher = better reasoning
# For spreadsheet apps, latency is critical - use MINIMAL for simple ops
THINKING_LEVEL_BY_MODE = {
    "data_quality": "MINIMAL",   # Formatting, cleanup - speed is everything
    "model": "HIGH",             # Building formulas, models - need deep reasoning
    "research": "LOW",           # Enrichment lookups - moderate complexity
    "style": "MINIMAL",          # Style interpretation - fast
    "explain": "LOW",            # Formula explanation - some reasoning
    "triage": "MINIMAL",         # Quick scan summary - speed
    "chat": "MEDIUM",            # Conversational - balanced
}

# Task-based thinking levels for chat flows
THINKING_LEVEL_BY_TASK = {
    "quick_transform": "MINIMAL",
    "normalize": "MINIMAL",
    "style_suggestion": "MINIMAL",
    "simple_chat": "MINIMAL",
    "audit_analysis": "MEDIUM",
    "resolution_ambiguity": "MEDIUM",
    "dedupe_strategy": "LOW",
    "multi_step_plan": "HIGH",
    "template_suggestion": "HIGH",
    "complex_formula": "HIGH",
}

# Rough, best-effort pricing (USD per 1K tokens); used for visibility only.
PRICING_PER_1K = {
    GEMINI_FLASH_MODEL: {"input": 0.00015, "output": 0.0006},  # Flash is cheaper
    GEMINI_PRO_MODEL: {"input": 0.00125, "output": 0.005},
    "gemini-2.5-pro": {"input": 0.0035, "output": 0.0105},  # Fallback model
}

# Fallback model for rate limit errors (GA model with high quotas)
FALLBACK_MODEL = os.getenv("VERTEX_FALLBACK_MODEL", "gemini-2.5-pro")


def get_thinking_level(task_type: str, complexity_signals: Dict[str, Any]) -> str:
    """Select a thinking level based on task and complexity signals."""
    base_level = THINKING_LEVEL_BY_TASK.get(task_type, "LOW")
    if complexity_signals.get("multi_intent"):
        return "HIGH"
    if complexity_signals.get("ambiguous_range"):
        return "MEDIUM" if base_level == "MINIMAL" else base_level
    if complexity_signals.get("large_dataset") and base_level == "MINIMAL":
        return "LOW"
    return base_level


def select_model_for_task(mode: str, task_type: str, complexity: Dict[str, Any]) -> Optional[str]:
    """Select a model override based on task complexity."""
    base_model = MODEL_BY_MODE.get(mode)
    if not base_model:
        return None
    if task_type in ("multi_step_plan", "complex_formula", "template_suggestion"):
        if complexity.get("confidence_below_threshold"):
            return GEMINI_PRO_MODEL
    return base_model


def _is_rate_limit_error(exc: Exception) -> bool:
    """Check if exception is a rate limit (429) or quota exceeded error."""
    exc_str = str(exc).lower()
    exc_type = type(exc).__name__.lower()
    rate_limit_indicators = [
        "429", "rate limit", "ratelimit", "quota exceeded",
        "resource exhausted", "resourceexhausted", "too many requests",
    ]
    for indicator in rate_limit_indicators:
        if indicator in exc_str or indicator in exc_type:
            return True
    if hasattr(exc, "code") and getattr(exc, "code", None) in (8, 429):
        return True
    return False


def _is_refresh_error(exc: Exception) -> bool:
    """Check if exception is a google.auth RefreshError (credentials expired)."""
    exc_str = str(exc).lower()
    exc_type = type(exc).__name__
    # Check exception type name (handles google.auth.exceptions.RefreshError)
    if "refresherror" in exc_type.lower():
        return True
    # Check exception message for common refresh error indicators
    refresh_indicators = [
        "reauthentication", "refresh", "token has been expired",
        "invalid_grant", "token expired", "credentials",
    ]
    for indicator in refresh_indicators:
        if indicator in exc_str:
            return True
    # Check the exception chain
    cause = getattr(exc, "__cause__", None)
    if cause and "refresherror" in type(cause).__name__.lower():
        return True
    return False

PROMPT_DIR = Path(__file__).resolve().parent.parent / "prompts" / "gremlin"
SCAN_PROMPT_PATH = PROMPT_DIR / "scan_summary.txt"
EXPLAIN_PROMPT_PATH = PROMPT_DIR / "formula_explainer.txt"
TRIAGE_PROMPT_PATH = PROMPT_DIR / "triage_summary.txt"
STYLE_PROMPT_PATH = PROMPT_DIR / "style_interpreter.txt"
MODEL_EXPLAINER_PROMPT_PATH = PROMPT_DIR / "model_explainer_system.txt"

def _vertex_location_for_model(model: Optional[str], location: str) -> str:
    if (model or "").strip().lower().startswith("gemini-3-"):
        return "global"
    return location


class GremlinAI:
    def __init__(self, tier: str, mode: Optional[str] = None) -> None:
        normalized = (tier or "free").strip().lower()
        self.tier = normalized
        self._mode = mode
        # Default model from tier (fallback)
        self._default_model = MODEL_BY_TIER.get(normalized)
        # Mode-specific model (if mode provided and tier allows AI)
        if mode and self._default_model:
            self.model = MODEL_BY_MODE.get(mode, self._default_model)
        else:
            self.model = self._default_model
        self._scan_prompt = _load_prompt(SCAN_PROMPT_PATH)
        self._explain_prompt = _load_prompt(EXPLAIN_PROMPT_PATH)
        self._triage_prompt = _load_prompt(TRIAGE_PROMPT_PATH)
        self._style_prompt = _load_prompt(STYLE_PROMPT_PATH)
        self._model_explainer_prompt = _load_prompt(MODEL_EXPLAINER_PROMPT_PATH)
        if not self.model:
            logger.debug("Gremlin AI disabled for tier=%s", normalized)
        elif mode:
            logger.debug("Gremlin AI using model=%s for mode=%s tier=%s", self.model, mode, normalized)

    def get_model_for_mode(self, mode: str) -> Optional[str]:
        """Get the appropriate model for a specific mode. Returns None if tier has no AI access."""
        if not self._default_model:
            return None
        return MODEL_BY_MODE.get(mode, self._default_model)

    async def summarize_scan(
        self,
        structure: Dict[str, Any],
        lint_results: Dict[str, Any],
        account_id: str,
        db: AsyncSession,
    ) -> Optional[str]:
        """
        Summarize a scan result for paid tiers. Returns None on failure/timeout.
        """
        del lint_results  # reserved for future use

        if not self.model or not self._scan_prompt:
            return None

        health = structure.get("health") or {}
        counts = structure.get("counts") or {}
        issues_summary = _sanitize_placeholder(structure.get("issues_summary") or "No top issues captured.")

        try:
            prompt = self._scan_prompt.format(
                score=int(health.get("score") or 0),
                errors=int(health.get("errors") or 0),
                warnings=int(health.get("warnings") or 0),
                fixable=int(health.get("fixable") or 0),
                issues_summary=issues_summary,
                driver_count=int(counts.get("drivers") or 0),
                formula_count=int(counts.get("formulas") or 0),
                cross_sheet_refs=int(counts.get("cross_sheet_refs") or 0),
            )
        except Exception as exc:  # pragma: no cover - defensive
            logger.exception("Failed to format scan summary prompt: %s", exc)
            return None

        try:
            text, usage = await self._generate(
                prompt,
                response_mime_type="text/plain",
                max_tokens=320,
                thinking_level=THINKING_LEVEL_BY_MODE.get("triage", "MINIMAL"),
            )
        except asyncio.TimeoutError:
            logger.warning("Gremlin AI scan summary timed out")
            return None
        except Exception:  # pragma: no cover - upstream failures
            logger.exception("Gremlin AI scan summary failed")
            return None

        if not text:
            return None

        await self._log_usage(
            endpoint="scan_summary",
            usage=usage,
            model_used=usage.get("model_used"),
            account_id=account_id,
            db=db,
        )
        return text.strip()

    async def explain_formula(
        self,
        formula: str,
        cell: Optional[str],
        structure_snippet: Dict[str, Any],
        account_id: str,
        db: AsyncSession,
    ) -> Optional[Dict[str, Any]]:
        """
        Explain an individual formula using Gemini. Returns None on failure.
        """
        if not self.model or not self._explain_prompt:
            return None
        if not formula:
            return None

        try:
            snippet_text = json.dumps(structure_snippet or {}, indent=2)
        except Exception:
            snippet_text = str(structure_snippet or {})

        try:
            prompt = self._explain_prompt.format(
                cell=_sanitize_placeholder(cell or "(unknown)"),
                formula=_sanitize_placeholder(formula),
                structure_snippet=_sanitize_placeholder(snippet_text),
            )
        except Exception:  # pragma: no cover - defensive
            logger.exception("Failed to format formula explain prompt")
            return None

        try:
            raw_text, usage = await self._generate(
                prompt,
                response_mime_type="application/json",
                max_tokens=512,
                thinking_level=THINKING_LEVEL_BY_MODE.get("explain", "LOW"),
            )
        except asyncio.TimeoutError:
            logger.warning("Gremlin AI formula explanation timed out")
            return None
        except Exception:  # pragma: no cover - upstream failures
            logger.exception("Gremlin AI formula explanation failed")
            return None

        if not raw_text:
            return None

        parsed = _parse_json(raw_text)
        if not parsed:
            logger.warning("Gremlin AI returned non-JSON explain payload: %s", raw_text[:200])
            return None

        await self._log_usage(
            endpoint="explain",
            usage=usage,
            model_used=usage.get("model_used"),
            account_id=account_id,
            db=db,
        )
        return {
            "explanation": (parsed.get("explanation") or "").strip() or None,
            "complexity": parsed.get("complexity"),
            "dependencies": parsed.get("dependencies") or [],
        }

    async def generate_triage_summary(self, classification: "TriageSummary") -> Optional[str]:
        """Generate natural language triage summary for paid tiers."""
        if not self.model or not self._triage_prompt:
            return None

        null_summary = ", ".join(f"{k}: {v}" for k, v in list(classification.null_counts.items())[:5])

        try:
            prompt = self._triage_prompt.format(
                row_count=classification.row_count,
                column_count=classification.column_count,
                headers=", ".join(classification.headers[:10]),
                likely_record_type=classification.likely_record_type,
                classification_confidence=classification.classification_confidence,
                sfdc_id_types=", ".join(classification.sfdc_id_types) or "none",
                email_columns=", ".join(classification.email_columns) or "none",
                b2c_email_pct=f"{classification.b2c_email_pct:.1%}",
                duplicate_email_pct=f"{classification.duplicate_email_pct:.1%}",
                null_counts_summary=null_summary or "none",
                has_formulas=classification.has_formulas,
                formula_count=classification.formula_count,
            )
        except Exception:  # pragma: no cover - defensive
            return None

        try:
            text, _usage = await self._generate(
                prompt,
                response_mime_type="text/plain",
                max_tokens=300,
                thinking_level=THINKING_LEVEL_BY_MODE.get("triage", "MINIMAL"),
            )
        except asyncio.TimeoutError:
            logger.warning("Gremlin AI triage summary timed out")
            return None
        except Exception:  # pragma: no cover - upstream failures
            logger.exception("Gremlin AI triage summary failed")
            return None

        return text.strip() if text else None

    async def interpret_style(
        self,
        style_description: str,
        account_id: str,
        db: AsyncSession,
    ) -> Optional[Dict[str, Any]]:
        """
        Interpret a natural language style description into structured style parameters.
        Returns None on failure/timeout.
        """
        if not self.model or not self._style_prompt:
            return None
        if not style_description or not style_description.strip():
            return None

        try:
            prompt = self._style_prompt.format(
                style_description=_sanitize_placeholder(style_description.strip()),
            )
        except Exception:  # pragma: no cover - defensive
            logger.exception("Failed to format style interpreter prompt")
            return None

        try:
            raw_text, usage = await self._generate(
                prompt,
                response_mime_type="application/json",
                max_tokens=256,
                thinking_level=THINKING_LEVEL_BY_MODE.get("style", "MINIMAL"),
            )
        except asyncio.TimeoutError:
            logger.warning("Gremlin AI style interpretation timed out")
            return None
        except Exception:  # pragma: no cover - upstream failures
            logger.exception("Gremlin AI style interpretation failed")
            return None

        if not raw_text:
            return None

        parsed = _parse_json(raw_text)
        if not parsed:
            logger.warning("Gremlin AI returned non-JSON style payload: %s", raw_text[:200])
            return None

        await self._log_usage(
            endpoint="style_interpret",
            usage=usage,
            model_used=usage.get("model_used"),
            account_id=account_id,
            db=db,
        )

        # Validate and normalize the parsed style
        return _normalize_style_params(parsed)

    async def summarize_workbook(
        self,
        context: Dict[str, Any],
        account_id: str,
        db: AsyncSession,
    ) -> Optional[str]:
        """
        Summarize a workbook context into a plain-English description.
        """
        if not self.model or not self._model_explainer_prompt:
            return None

        try:
            prompt = self._model_explainer_prompt.format(
                context_json=_sanitize_placeholder(
                    json.dumps(context, separators=(",", ":"), ensure_ascii=True)
                )
            )
        except Exception:  # pragma: no cover - defensive
            logger.exception("Failed to format model explainer prompt")
            return None

        try:
            raw_text, usage = await self._generate(
                prompt,
                response_mime_type="text/plain",
                max_tokens=512,
                thinking_level=THINKING_LEVEL_BY_MODE.get("model", "LOW"),
            )
        except asyncio.TimeoutError:
            logger.warning("Gremlin AI model explainer timed out")
            return None
        except Exception:  # pragma: no cover - upstream failures
            logger.exception("Gremlin AI model explainer failed")
            return None

        if not raw_text:
            return None

        await self._log_usage(
            endpoint="model_explainer",
            usage=usage,
            model_used=usage.get("model_used"),
            account_id=account_id,
            db=db,
        )

        return raw_text.strip()

    async def _generate(
        self,
        prompt: str,
        response_mime_type: str,
        max_tokens: int,
        timeout: float = 30.0,
        model_override: Optional[str] = None,
        thinking_level: Optional[str] = None,
    ) -> Tuple[Optional[str], Dict[str, Any]]:
        """
        Execute a Vertex AI call with configurable timeout (default 30s).

        Args:
            model_override: If provided, use this model instead of self.model
                           (used for session pinning after fallback)
            thinking_level: Gemini 3 thinking budget: MINIMAL, LOW, MEDIUM, HIGH
                           Lower = faster, Higher = better reasoning

        Returns:
            Tuple of (response_text, usage_dict)
            usage_dict includes 'fallback_used' and 'model_used' for tracking
        """
        try:
            import vertexai
            from vertexai.generative_models import GenerativeModel, GenerationConfig
        except ImportError:  # pragma: no cover - environment specific
            logger.warning("Vertex AI SDK not installed; skipping Gremlin AI call")
            return None, {}

        project = (
            os.getenv("VERTEX_PROJECT")
            or os.getenv("GOOGLE_CLOUD_PROJECT")
            or os.getenv("GCP_PROJECT")
        )
        location = os.getenv("VERTEX_LOCATION", "us-central1")
        if not project:
            logger.warning("Vertex project not configured; skipping Gremlin AI call")
            return None, {}

        # Use override model if provided (for pinned sessions), otherwise use self.model
        target_model = model_override or self.model
        fallback_used = False
        model_used = target_model

        try:
            resolved_location = _vertex_location_for_model(target_model, location)
            if resolved_location != location:
                logger.info("Vertex AI: overriding location to global for model %s", target_model)
            vertexai.init(project=project, location=resolved_location)
            model = GenerativeModel(target_model)

            # Build generation config - add thinking level for Gemini 3 models
            config_kwargs = {
                "temperature": 0.2,
                "top_p": 0.9,
                "top_k": 32,
                "max_output_tokens": max_tokens,
                "response_mime_type": response_mime_type,
            }

            # Add thinking_config for Gemini 3 models (thinking levels for latency optimization)
            # Gemini 3 thinking levels: MINIMAL (fastest) < LOW < MEDIUM < HIGH (most reasoning)
            thinking_config_applied = False
            if thinking_level and target_model and "gemini-3" in target_model.lower():
                level = thinking_level.upper() if thinking_level else "MEDIUM"
                valid_levels = {"MINIMAL", "LOW", "MEDIUM", "HIGH"}
                if level in valid_levels:
                    # Try to create thinking config object
                    tc_obj = None
                    try:
                        from google.genai import types as genai_types
                        tc_obj = genai_types.ThinkingConfig(thinking_level=level)
                    except ImportError:
                        pass
                    except Exception:
                        pass

                    if tc_obj:
                        config_kwargs["thinking_config"] = tc_obj

            # Build GenerationConfig, falling back if thinking_config not supported
            try:
                generation_config = GenerationConfig(**config_kwargs)
                if "thinking_config" in config_kwargs:
                    thinking_config_applied = True
                    logger.debug("Gemini 3 thinking level %s applied", thinking_level)
            except TypeError as gen_err:
                if "thinking_config" in str(gen_err) and "thinking_config" in config_kwargs:
                    # SDK doesn't support thinking_config yet - proceed without it
                    config_kwargs.pop("thinking_config", None)
                    generation_config = GenerationConfig(**config_kwargs)
                    logger.debug("Thinking level not supported by SDK - using default")
                else:
                    raise
            call_model = partial(model.generate_content, [prompt], generation_config=generation_config)
            response = await asyncio.wait_for(asyncio.to_thread(call_model), timeout=timeout)
        except asyncio.TimeoutError:
            raise
        except Exception as exc:
            # Check for authentication/refresh errors first
            if _is_refresh_error(exc):
                logger.critical(
                    "VERTEX AI AUTH ERROR: Reauthentication needed. "
                    "Run: gcloud auth application-default login"
                )
                raise RuntimeError(
                    "Reauthentication is needed. Please run "
                    "gcloud auth application-default login to reauthenticate."
                ) from exc
            # Check if we should fallback (only if not already using fallback model)
            if _is_rate_limit_error(exc) and target_model != FALLBACK_MODEL:
                logger.warning(
                    "Vertex AI rate limit on %s, falling back to %s",
                    target_model,
                    FALLBACK_MODEL,
                )
                try:
                    fallback_location = _vertex_location_for_model(FALLBACK_MODEL, location)
                    vertexai.init(project=project, location=fallback_location)
                    fallback_model = GenerativeModel(FALLBACK_MODEL)
                    # Rebuild config without thinking_config (not supported by non-Gemini 3 models)
                    fallback_config = GenerationConfig(
                        temperature=0.2,
                        top_p=0.9,
                        top_k=32,
                        max_output_tokens=max_tokens,
                        response_mime_type=response_mime_type,
                    )
                    call_fallback = partial(
                        fallback_model.generate_content, [prompt], generation_config=fallback_config
                    )
                    response = await asyncio.wait_for(asyncio.to_thread(call_fallback), timeout=timeout)
                    fallback_used = True
                    model_used = FALLBACK_MODEL
                    logger.info("Vertex AI fallback to %s succeeded", FALLBACK_MODEL)
                except Exception as fallback_exc:
                    logger.exception("Vertex AI fallback to %s also failed: %s", FALLBACK_MODEL, fallback_exc)
                    raise fallback_exc from exc
            else:
                logger.exception("Vertex AI call failed: %s", exc)
                raise

        usage = _extract_usage(response)
        usage["fallback_used"] = fallback_used
        usage["model_used"] = model_used

        text = getattr(response, "text", None)
        if not text and getattr(response, "candidates", None):
            try:
                candidate = response.candidates[0]
                parts = getattr(getattr(candidate, "content", None), "parts", None) or []
                text_parts = [getattr(p, "text", None) for p in parts if getattr(p, "text", None)]
                text = "".join(tp for tp in text_parts if tp)
            except Exception:
                text = None

        return (text.strip() if isinstance(text, str) else None), usage

    async def _log_usage(
        self,
        endpoint: str,
        usage: Dict[str, Any],
        model_used: Optional[str],
        account_id: str,
        db: AsyncSession,
    ) -> None:
        if not db or not usage:
            return
        try:
            input_tokens = int(usage.get("input_tokens") or 0)
            output_tokens = int(usage.get("output_tokens") or 0)
            resolved_model = model_used or str(usage.get("model_used") or "") or self.model
            cost_cents = _estimate_cost_cents(resolved_model, input_tokens, output_tokens)
            stmt = sql_text(
                """
                INSERT INTO gremlin_ai_usage (id, account_id, endpoint, model, input_tokens, output_tokens, cost_cents, created_at)
                VALUES (:id, :account_id, :endpoint, :model, :input_tokens, :output_tokens, :cost_cents, :created_at)
                """
            )
            await db.execute(
                stmt,
                {
                    "id": str(uuid4()),
                    "account_id": account_id,
                    "endpoint": endpoint,
                    "model": resolved_model,
                    "input_tokens": input_tokens,
                    "output_tokens": output_tokens,
                    "cost_cents": cost_cents,
                    "created_at": datetime.now(timezone.utc),
                },
            )
            await db.commit()
        except Exception as exc:  # pragma: no cover - non-critical
            logger.debug("Failed to log Gremlin AI usage: %s", exc)


def _load_prompt(path: Path) -> Optional[str]:
    try:
        return path.read_text(encoding="utf-8")
    except FileNotFoundError:  # pragma: no cover - defensive
        logger.warning("Prompt file missing: %s", path)
    except Exception as exc:  # pragma: no cover - defensive
        logger.warning("Failed to load prompt %s: %s", path, exc)
    return None


def _sanitize_placeholder(value: str) -> str:
    return (value or "").replace("{", "{{").replace("}", "}}")


def _parse_json(raw: str) -> Optional[Dict[str, Any]]:
    text = (raw or "").strip()
    if not text:
        return None
    cleaned = text
    if cleaned.startswith("```"):
        cleaned = cleaned.strip("`")
    try:
        return json.loads(cleaned)
    except json.JSONDecodeError:
        pass
    # Try to salvage first JSON object in the string
    start = cleaned.find("{")
    end = cleaned.rfind("}")
    if start != -1 and end != -1 and end > start:
        try:
            return json.loads(cleaned[start : end + 1])
        except Exception:
            return None
    return None


def _extract_usage(response: Any) -> Dict[str, int]:
    usage = getattr(response, "usage_metadata", None)
    if not usage:
        return {}
    if isinstance(usage, dict):
        prompt_tokens = usage.get("prompt_token_count") or usage.get("input_token_count") or 0
        completion_tokens = usage.get("candidates_token_count") or usage.get("output_token_count") or 0
    else:
        prompt_tokens = getattr(usage, "prompt_token_count", None) or getattr(usage, "input_token_count", 0)
        completion_tokens = getattr(usage, "candidates_token_count", None) or getattr(usage, "output_token_count", 0)
    return {
        "input_tokens": int(prompt_tokens or 0),
        "output_tokens": int(completion_tokens or 0),
    }


def _estimate_cost_cents(model: Optional[str], input_tokens: int, output_tokens: int) -> int:
    if not model or model not in PRICING_PER_1K:
        return 0
    pricing = PRICING_PER_1K[model]
    input_cost = pricing.get("input", 0.0) * (input_tokens / 1000.0)
    output_cost = pricing.get("output", 0.0) * (output_tokens / 1000.0)
    return int(round((input_cost + output_cost) * 100))


def _normalize_style_params(parsed: Dict[str, Any]) -> Dict[str, Any]:
    """Validate and normalize AI-generated style parameters."""
    result: Dict[str, Any] = {}

    # header_rows: int 1-3
    if "header_rows" in parsed:
        try:
            val = int(parsed["header_rows"])
            result["header_rows"] = max(1, min(3, val))
        except (ValueError, TypeError):
            pass

    # booleans
    for key in ("header_bold", "stripe"):
        if key in parsed:
            result[key] = bool(parsed[key])

    # hex colors
    for key in ("header_fill", "header_font_color", "stripe_color", "font_color"):
        if key in parsed:
            color = _validate_hex_color(parsed[key])
            if color:
                result[key] = color

    # font_size: int 8-18
    if "font_size" in parsed:
        try:
            val = int(parsed["font_size"])
            result["font_size"] = max(8, min(18, val))
        except (ValueError, TypeError):
            pass

    return result


def _validate_hex_color(value: Any) -> Optional[str]:
    """Validate and normalize a hex color string."""
    if not isinstance(value, str):
        return None
    color = value.strip().lower()
    if not color.startswith("#"):
        color = "#" + color
    # Remove any extra characters
    color = color[:7]
    # Validate hex format
    if len(color) == 4:  # #RGB -> #RRGGBB
        color = "#" + color[1] * 2 + color[2] * 2 + color[3] * 2
    if len(color) != 7:
        return None
    try:
        int(color[1:], 16)
        return color
    except ValueError:
        return None
