"""
Pydantic models for instruction and data validation.

All models use strict validation with no defaults - missing required fields
will cause immediate validation errors.
"""

from enum import Enum
from pathlib import Path
from typing import Annotated, Any, Literal, TypeAlias

from pydantic import AfterValidator, BaseModel, ConfigDict, Field, model_validator


def _validate_non_empty(v: str) -> str:
    if not v or not v.strip():
        msg = "Value cannot be empty or whitespace only"
        raise ValueError(msg)
    return v


NonEmptySelector = Annotated[str, AfterValidator(_validate_non_empty)]
NonEmptyString = Annotated[str, AfterValidator(_validate_non_empty)]


class BrowserType(str, Enum):
    """Supported browser engines for crawling."""

    CAMOUFOX = "camoufox"
    CHROMIUM = "chromium"


class DebugMode(str, Enum):
    """Debug screenshot modes."""

    NONE = "none"
    START = "start"
    END = "end"
    ALL = "all"


class FieldType(str, Enum):
    """Supported form field types."""

    TEXT = "text"
    TEXTAREA = "textarea"
    DROPDOWN = "dropdown"
    RADIO = "radio"
    CHECKBOX = "checkbox"
    DATE = "date"
    SLIDER = "slider"
    FILE = "file"
    IFRAME_FIELD = "iframe_field"
    CLICK_ONLY = "click_only"
    COMBOBOX = "combobox"
    CLICK_SELECT = "click_select"


class ActionType(str, Enum):
    """Supported action types for navigation and interaction."""

    CLICK = "click"
    WAIT = "wait"
    WAIT_HIDDEN = "wait_hidden"
    SCROLL = "scroll"
    DELAY = "delay"
    CONDITIONAL = "conditional"


class ConditionType(str, Enum):
    """Supported condition types for conditional actions."""

    SELECTOR_VISIBLE = "selector_visible"
    SELECTOR_HIDDEN = "selector_hidden"
    DATA_EQUALS = "data_equals"
    DATA_EXISTS = "data_exists"


class ConditionDefinition(BaseModel):
    """Definition of a condition for conditional actions."""

    model_config = ConfigDict(extra="forbid")

    type: ConditionType = Field(description="Type of condition to evaluate")
    selector: str | None = Field(default=None, description="CSS selector for selector-based conditions")
    key: str | None = Field(default=None, description="Data key for data-based conditions")
    value: Any | None = Field(default=None, description="Expected value for data_equals condition")


class DateFieldConfig(BaseModel):
    """Configuration specific to date fields."""

    model_config = ConfigDict(extra="forbid")

    format: str = Field(description="Date format string (e.g., '%d.%m.%Y')")


class DropdownFieldConfig(BaseModel):
    """Configuration specific to dropdown/select fields."""

    model_config = ConfigDict(extra="forbid")

    select_by: str = Field(description="How to select dropdown options: 'value', 'text', or 'index'")
    option_visible_timeout_ms: int | None = Field(
        default=None, description="Timeout in ms for option visibility in dropdowns"
    )


class ComboboxFieldConfig(BaseModel):
    """Configuration specific to combobox/autocomplete fields."""

    model_config = ConfigDict(extra="forbid")

    option_selector: str = Field(description="CSS selector for combobox options")
    type_delay_ms: int | None = Field(default=None, description="Delay between keystrokes in ms for slow typing")
    wait_after_type_ms: int | None = Field(
        default=None, description="Wait time in ms after typing before selecting option"
    )
    press_enter: bool = Field(default=False, description="Press Enter after selecting option")
    clear_before_type: bool = Field(default=False, description="Clear the field before typing")
    option_visible_timeout_ms: int | None = Field(default=None, description="Timeout in ms for option visibility")


class ClickSelectFieldConfig(BaseModel):
    """Configuration specific to click-select fields (click to reveal options, then click option)."""

    model_config = ConfigDict(extra="forbid")

    option_selector: str = Field(description="CSS selector for options after clicking field")
    option_visible_timeout_ms: int | None = Field(default=None, description="Timeout in ms for option visibility")


FieldTypeConfig: TypeAlias = DateFieldConfig | DropdownFieldConfig | ComboboxFieldConfig | ClickSelectFieldConfig

# Maps field type to its config class for unambiguous type_config parsing
_FIELD_TYPE_CONFIG_MAP: dict[FieldType, type[FieldTypeConfig]] = {
    FieldType.DATE: DateFieldConfig,
    FieldType.DROPDOWN: DropdownFieldConfig,
    FieldType.COMBOBOX: ComboboxFieldConfig,
    FieldType.CLICK_SELECT: ClickSelectFieldConfig,
}


class FieldDefinition(BaseModel):
    """Definition of a single form field to interact with."""

    model_config = ConfigDict(extra="forbid")

    data_key: str | None = Field(
        default=None, description="Key in input_data dict to get the value from (optional for click_only)"
    )
    selector: NonEmptySelector = Field(description="CSS selector to locate the field")
    type: FieldType = Field(description="Type of the form field")
    type_config: FieldTypeConfig | None = Field(
        default=None,
        description="Type-specific configuration (DateFieldConfig, DropdownFieldConfig, ComboboxFieldConfig, ClickSelectFieldConfig)",
    )
    iframe_selector: str | None = Field(default=None, description="CSS selector for iframe if field is inside one")
    field_visible_timeout_ms: int | None = Field(default=None, description="Timeout in ms for field visibility")
    post_click_delay_ms: int | None = Field(default=None, description="Wait time in ms after clicking field")
    skip_verification: bool | None = Field(
        default=None, description="Skip value verification after filling (for fields that may disappear)"
    )
    force_click: bool | None = Field(
        default=None, description="Use force click to bypass element interception (for sticky toolbars, overlays)"
    )

    @model_validator(mode="before")
    @classmethod
    def _coerce_type_config(cls, data: Any) -> Any:
        """
        Coerce type_config to the correct config class based on field type.

        Without this, Pydantic's union parsing picks the first matching type in the union,
        which causes ambiguity between ComboboxFieldConfig and ClickSelectFieldConfig
        (both share option_selector + option_visible_timeout_ms).
        """
        if not isinstance(data, dict):
            return data
        raw_config = data.get("type_config")
        field_type_raw = data.get("type")
        if raw_config is None or field_type_raw is None or not isinstance(raw_config, dict):
            return data
        field_type = FieldType(field_type_raw)
        config_class = _FIELD_TYPE_CONFIG_MAP.get(field_type)
        if config_class is not None:
            # Shallow copy to avoid mutating the caller's dict
            data = dict(data)
            data["type_config"] = config_class(**raw_config)
        return data


class ActionDefinition(BaseModel):
    """Definition of an action to perform during navigation."""

    model_config = ConfigDict(extra="forbid")

    type: ActionType = Field(description="Type of action to perform")
    selector: str | None = Field(default=None, description="CSS selector for the action target")
    iframe_selector: str | None = Field(default=None, description="CSS selector for iframe if target is inside one")
    delay_ms: int | None = Field(default=None, description="Delay in milliseconds for delay action")
    condition: ConditionDefinition | None = Field(default=None, description="Condition for conditional action")
    actions: list["ActionDefinition"] | None = Field(default=None, description="Nested actions for conditional action")
    pre_action_delay_ms: int | None = Field(default=None, description="Wait time in ms before executing action")
    post_action_delay_ms: int | None = Field(default=None, description="Wait time in ms after executing action")


class StepExtractionDefinition(BaseModel):
    """Definition of data to extract during a step, with optional field interactions."""

    model_config = ConfigDict(extra="forbid")

    name: str = Field(description="Name for this extraction (used as key prefix in extracted_data)")
    selector: NonEmptySelector = Field(description="CSS selector to locate the value element")
    attribute: str | None = Field(default=None, description="Element attribute to extract, or None for text content")
    regex: str | None = Field(default=None, description="Optional regex pattern (uses first capture group if present)")
    wait_before_ms: int | None = Field(
        default=None,
        description="Wait time in ms before extraction (useful for post-field extraction where UI needs to update)",
    )
    click_before: str | None = Field(
        default=None, description="CSS selector to click before extracting (e.g., to switch tabs/options)"
    )
    force_click: bool | None = Field(
        default=None,
        description="Whether to use force click (bypasses visibility checks but may not trigger JS handlers)",
    )
    wait_after_click_ms: int | None = Field(
        default=None, description="Wait time in ms after clicking before extraction"
    )
    dismiss_modal_selector: str | None = Field(
        default=None, description="CSS selector to click after extraction to dismiss modal (e.g., close button)"
    )


class StepDefinition(BaseModel):
    """Definition of a single form step (page)."""

    model_config = ConfigDict(extra="forbid")

    name: NonEmptyString = Field(description="Unique name for this step")
    wait_for: NonEmptySelector = Field(description="CSS selector or condition to wait for before interacting")
    fields: list[FieldDefinition] = Field(description="List of fields to fill in this step")
    next_action: ActionDefinition = Field(description="Action to perform to navigate to next step")
    timeout_ms: int = Field(description="Timeout in milliseconds for waiting")
    data_extraction: list[StepExtractionDefinition] | None = Field(
        default=None, description="Optional list of data to extract BEFORE field handling in this step"
    )
    post_field_extraction: list[StepExtractionDefinition] | None = Field(
        default=None, description="Optional list of data to extract AFTER field handling (useful for modal results)"
    )


class FinalPageDefinition(BaseModel):
    """Definition of the final result page."""

    model_config = ConfigDict(extra="forbid")

    wait_for: NonEmptySelector | None = Field(
        default=None, description="CSS selector to wait for on the final page (static)"
    )
    wait_for_data_key: str | None = Field(
        default=None,
        description='Data key to get text value for exact text match selector (uses text="{value}" format)',
    )
    screenshot_selector: str | None = Field(
        default=None, description="CSS selector for element to screenshot, or None for full page"
    )
    timeout_ms: int = Field(description="Timeout in milliseconds for waiting")
    post_wait_delay_ms: int = Field(
        default=0,
        description="Delay in ms after wait_for selector found, for SPA content to render",
    )

    @model_validator(mode="after")
    def validate_wait_for_source(self) -> "FinalPageDefinition":
        if self.wait_for is None and self.wait_for_data_key is None:
            msg = "Either 'wait_for' or 'wait_for_data_key' must be provided"
            raise ValueError(msg)
        if self.wait_for is not None and self.wait_for_data_key is not None:
            msg = "Only one of 'wait_for' or 'wait_for_data_key' can be provided, not both"
            raise ValueError(msg)
        return self


class ExtractionFieldDefinition(BaseModel):
    """Definition of a single data extraction field."""

    model_config = ConfigDict(extra="forbid")

    selector: NonEmptySelector = Field(description="CSS selector to locate element(s)")
    attribute: str | None = Field(description="Element attribute to extract, or None for text content")
    regex: str | None = Field(description="Optional regex pattern (uses first capture group if present)")
    multiple: bool = Field(description="True for list of all matches, False for first match only")
    iframe_selector: str | None = Field(default=None, description="CSS selector for iframe if element is inside one")


class NestedOutputFormat(str, Enum):
    """Output formats for nested field assembly."""

    PAIRED_DICT = "paired_dict"
    OBJECT_LIST = "object_list"


class NestedFieldDefinition(BaseModel):
    """Definition of a nested field that combines multiple flat fields into a structured output."""

    model_config = ConfigDict(extra="forbid")

    output_format: NestedOutputFormat = Field(description="Output format: paired_dict or object_list")
    key_field: str | None = Field(
        default=None, description="Source field for dict keys (required for paired_dict format)"
    )
    value_field: str | None = Field(
        default=None, description="Source field for dict values (required for paired_dict format)"
    )
    fields: dict[str, str] | None = Field(
        default=None,
        description="Mapping of output field names to source fields (required for object_list format)",
    )


class DataExtractionConfig(BaseModel):
    """Configuration for extracting structured data from the final page."""

    model_config = ConfigDict(extra="forbid")

    fields: dict[str, ExtractionFieldDefinition] = Field(
        description="Dictionary mapping field names to their extraction definitions"
    )
    nested_fields: dict[str, NestedFieldDefinition] | None = Field(
        default=None,
        description="Optional nested field definitions that combine flat fields into structured outputs",
    )


class BrowserConfig(BaseModel):
    """Configuration for browser settings."""

    model_config = ConfigDict(extra="forbid")

    browser_type: BrowserType = Field(
        description="Browser engine to use: camoufox (anti-detect Firefox) or chromium (standard Playwright)"
    )
    viewport_width: int = Field(description="Browser viewport width in pixels")
    viewport_height: int = Field(description="Browser viewport height in pixels")
    user_agent: str | None = Field(default=None, description="Custom user agent string")
    blocked_url_patterns: list[str] | None = Field(
        default=None,
        description="URL glob patterns to block via page.route(). Matched requests are aborted before being sent. "
        "Examples: '**/glassbox/**', '*://uxmetrics.*/**', '**/*.gif'",
    )


class CookieConsentConfig(BaseModel):
    """Configuration for cookie consent banner handling."""

    model_config = ConfigDict(extra="forbid")

    banner_selector: NonEmptySelector = Field(description="CSS selector for the cookie consent banner")
    accept_selector: str | None = Field(
        default=None, description="CSS selector for the accept button (for regular banners)"
    )
    shadow_host_selector: str | None = Field(
        default=None, description="CSS selector for shadow DOM host (for Usercentrics etc.)"
    )
    accept_button_texts: list[str] | None = Field(
        default=None,
        description="Text patterns to match accept buttons in shadow DOM (e.g., ['akzeptieren', 'accept all'])",
    )
    banner_settle_delay_ms: int | None = Field(default=None, description="Wait time in ms before checking for banner")
    banner_visible_timeout_ms: int | None = Field(default=None, description="Timeout in ms for banner visibility")
    accept_button_timeout_ms: int | None = Field(default=None, description="Timeout in ms for accept button visibility")
    post_consent_delay_ms: int | None = Field(default=None, description="Wait time in ms after cookie handling")


class CaptchaConfig(BaseModel):
    """
    Configuration for CAPTCHA handling (e.g., Cloudflare Turnstile).

    Note: Cloudflare Turnstile uses advanced bot detection that prevents
    automated solving in most cases.
    """

    model_config = ConfigDict(extra="forbid")

    container_selector: NonEmptySelector = Field(
        description="CSS selector for the CAPTCHA container element (e.g., '#le-captcha')"
    )
    response_selector: str = Field(
        description="CSS selector for the hidden response input that gets filled when CAPTCHA is solved"
    )
    click_offset_x: int = Field(description="X offset from container left edge to click the checkbox")
    click_offset_y: int = Field(description="Y offset from container vertical center to click")
    pre_click_delay_ms: int = Field(description="Wait time in ms before attempting to solve CAPTCHA")
    post_solve_delay_ms: int = Field(description="Wait time in ms after CAPTCHA appears solved")
    solve_timeout_ms: int = Field(description="Maximum time in ms to wait for CAPTCHA to be solved (per attempt)")
    max_retries: int = Field(description="Maximum number of click attempts before giving up")
    submit_after_solve_selector: str | None = Field(
        default=None,
        description="CSS selector for submit button to click after CAPTCHA is solved (some sites require re-clicking)",
    )
    dismiss_modal_selector: str | None = Field(
        default=None,
        description="CSS selector for modal dismiss button to click after submit (e.g., 'uebernehmen' on Check24)",
    )
    dismiss_modal_timeout_ms: int = Field(
        default=10000,
        description="Timeout in ms to wait for modal dismiss button to appear",
    )
    container_visible_timeout_ms: int | None = Field(
        default=None,
        description="Timeout in ms for CAPTCHA container to become visible",
    )
    post_scroll_delay_ms: int | None = Field(
        default=None,
        description="Wait time in ms after scrolling CAPTCHA into view",
    )
    mouse_move_delay_ms: int | None = Field(
        default=None,
        description="Wait time in ms between mouse movements",
    )
    mouse_settle_delay_ms: int | None = Field(
        default=None,
        description="Wait time in ms after mouse reaches target position",
    )
    post_submit_delay_ms: int | None = Field(
        default=None,
        description="Wait time in ms after clicking submit button",
    )
    post_modal_dismiss_delay_ms: int | None = Field(
        default=None,
        description="Wait time in ms after dismissing modal",
    )
    retry_delay_ms: int | None = Field(
        default=None,
        description="Wait time in ms between retry attempts",
    )
    poll_interval_ms: int | None = Field(
        default=None,
        description="Interval in ms for polling CAPTCHA response",
    )


class Instructions(BaseModel):
    """Complete instruction set for form crawling."""

    model_config = ConfigDict(extra="forbid")

    url: str = Field(description="Starting URL for the crawler")
    browser_config: BrowserConfig = Field(description="Browser configuration including engine type and viewport")
    steps: list[StepDefinition] = Field(description="Ordered list of form steps to process")
    final_page: FinalPageDefinition = Field(description="Configuration for the final result page")
    cookie_consent: CookieConsentConfig | None = Field(
        default=None, description="Optional cookie consent banner handling configuration"
    )
    captcha: CaptchaConfig | None = Field(
        default=None, description="Optional CAPTCHA handling configuration for Cloudflare Turnstile etc."
    )
    data_extraction: DataExtractionConfig | None = Field(
        default=None, description="Optional configuration for extracting structured data from final page"
    )


class CrawlResult(BaseModel):
    """Result of a successful crawl operation with file output."""

    model_config = ConfigDict(extra="forbid")

    html_path: Path = Field(description="Path to saved HTML file")
    screenshot_path: Path = Field(description="Path to saved screenshot")
    final_url: str = Field(description="Final URL after all navigation")
    steps_completed: int = Field(description="Number of steps successfully completed")
    extracted_data: dict[str, Any] = Field(description="Extracted data from final page")


class ProxyConfig(BaseModel):
    """Proxy configuration for browser context."""

    model_config = ConfigDict(extra="forbid")

    server: str = Field(description="Proxy server URL (e.g., 'http://proxy-server.com:8080')")
    username: str | None = Field(default=None, description="Proxy authentication username")
    password: str | None = Field(default=None, description="Proxy authentication password")

    def _mask_password(self) -> str:
        if self.password is None:
            return "None"
        visible_chars = 2
        if len(self.password) <= visible_chars:
            return "*" * len(self.password)
        masked_length = len(self.password) - visible_chars
        return "*" * masked_length + self.password[-visible_chars:]

    def __repr__(self) -> str:
        return f"ProxyConfig(server={self.server!r}, username={self.username!r}, password={self._mask_password()!r})"

    def __str__(self) -> str:
        return self.__repr__()


class GeolocationConfig(BaseModel):
    """Geolocation configuration for browser context."""

    model_config = ConfigDict(extra="forbid")

    latitude: float = Field(description="Latitude coordinate")
    longitude: float = Field(description="Longitude coordinate")
    accuracy: float = Field(default=100.0, description="Accuracy in meters")


class HumanizeConfig(BaseModel):
    """
    Configuration for human-like cursor movement.

    Camoufox includes built-in human-like cursor movement based on a C++ algorithm
    with distance-aware trajectories. When enabled, all click and mouse operations
    will simulate natural cursor movement patterns.

    The cursor typically takes up to 1.5 seconds to move across the window by default.

    See: https://camoufox.com/fingerprint/cursor-movement/
    """

    model_config = ConfigDict(extra="forbid")

    enabled: bool = Field(description="Enable human-like cursor movement")
    max_time_seconds: float | None = Field(
        default=None,
        description="Maximum duration for cursor movement in seconds (default 1.5)",
    )


class ContextOptions(BaseModel):
    """Browser context options for stealth configuration."""

    model_config = ConfigDict(extra="forbid")

    user_agent: str | None = Field(default=None, description="Custom user agent string")
    locale: str | None = Field(default=None, description="Browser locale (e.g., 'en-US', 'de-DE')")
    timezone_id: str | None = Field(default=None, description="Timezone ID (e.g., 'America/New_York', 'Europe/Berlin')")
    permissions: list[str] | None = Field(default=None, description="Permissions to grant (e.g., ['geolocation'])")
    geolocation: GeolocationConfig | None = Field(default=None, description="Geolocation coordinates")


class CrawlerBrowserConfig(BaseModel):
    """
    Runtime browser configuration passed to the crawler.

    Contains proxy, stealth, and context settings. Camoufox-specific fields
    (humanize, geoip, os) are only applied when browser_type is camoufox.
    """

    model_config = ConfigDict(extra="forbid")

    os: Literal["windows", "macos", "linux"] | None = Field(
        default=None,
        description="OS to spoof in browser fingerprint. Camoufox rotates between all OS types if not set.",
    )
    geoip: bool | None = Field(
        default=None,
        description="Automatically detect geolocation based on IP address. Useful with proxies to match location to exit IP.",
    )
    context_options: ContextOptions | None = Field(
        default=None,
        description="Browser context options (locale, timezone, permissions, geolocation)",
    )
    init_scripts: list[str] | None = Field(
        default=None,
        description="JavaScript to inject via page.add_init_script() before navigation",
    )
    proxy: ProxyConfig | None = Field(
        default=None,
        description="Proxy configuration for IP-based detection bypass",
    )
    ignore_https_errors: bool | None = Field(
        default=None,
        description="Ignore HTTPS certificate errors (required for some proxies like BrightData)",
    )
    humanize: HumanizeConfig | None = Field(
        default=None,
        description="Human-like cursor movement configuration for enhanced stealth",
    )


class InMemoryCrawlResult(BaseModel):
    """Result of a successful crawl operation returned in-memory without file output."""

    model_config = ConfigDict(extra="forbid")

    url: str = Field(description="Starting URL that was crawled")
    input_data: dict[str, Any] = Field(description="Input data used to fill form fields")
    instructions: dict[str, Any] = Field(description="Instructions used for crawling")
    screenshots: list[bytes] = Field(description="List of screenshot images as bytes")
    html: str = Field(description="Final page HTML content")
    final_url: str = Field(description="Final URL after all navigation")
    steps_completed: int = Field(description="Number of steps successfully completed")
    extracted_data: dict[str, Any] = Field(description="Extracted data from final page")

    def __repr__(self) -> str:
        def truncate(value: str, max_length: int = 50) -> str:
            if len(value) <= max_length:
                return value
            return value[: max_length - 3] + "..."

        def format_bytes(size: int) -> str:
            if size < 1024:
                return f"{size} B"
            if size < 1024 * 1024:
                return f"{size / 1024:.1f} KB"
            return f"{size / (1024 * 1024):.1f} MB"

        def format_dict(data: dict[str, Any], max_keys: int = 3) -> str:
            keys = list(data.keys())
            if len(keys) <= max_keys:
                return str(keys)
            return f"{keys[:max_keys]} ... ({len(keys)} keys)"

        screenshot_info = f"{len(self.screenshots)} screenshots"
        if self.screenshots:
            total_size = sum(len(s) for s in self.screenshots)
            screenshot_info += f" ({format_bytes(total_size)})"

        rows = [
            ("url", truncate(self.url, max_length=60)),
            ("final_url", truncate(self.final_url, max_length=60)),
            ("steps_completed", str(self.steps_completed)),
            ("input_data", format_dict(self.input_data, max_keys=50)),
            ("instructions", format_dict(self.instructions, max_keys=50)),
            ("screenshots", screenshot_info),
            ("html", f"{len(self.html):,} chars"),
            ("extracted_data", format_dict(self.extracted_data)),
        ]

        label_width = max(len(row[0]) for row in rows)
        lines = ["InMemoryCrawlResult:"]
        lines.append("-" * (label_width + 65))
        for label, value in rows:
            lines.append(f"  {label:<{label_width}}  |  {value}")
        lines.append("-" * (label_width + 65))

        return "\n".join(lines)

    def __str__(self) -> str:
        return self.__repr__()
