"""Parse step-based YAML validation definitions.

Converts the ``validation.steps`` list from a YAML file into typed
:class:`~test_runner.common.models.step_types.Step` dataclasses and a
:class:`~test_runner.common.models.step_types.StepBasedValidation` container.

This module is intentionally separate from :mod:`discovery` so that v1 and v2
parsing logic live in different files, making future v1 deprecation a clean
deletion.
"""

from __future__ import annotations

from typing import Any

from .models.step_types import (
    VALIDATE_SKIP,
    CursorStep,
    ParamStep,
    QueryStep,
    Step,
    StepBasedValidation,
    TableStep,
    ValidateCursor,
    ValidateEntry,
    ValidateQuery,
    ValidateResult,
    ValidateTable,
)
from .sql_patterns import DECLARE_PARAM_RE, PARAM_ASSIGNMENT_RE, POSITIONAL_PLACEHOLDER_RE

# YAML key names — defined once, referenced in detection sets and parse functions.
_K_BOTH = "both"
_K_SOURCE_QUERY = "source_query"
_K_TARGET_QUERY = "target_query"
_K_SOURCE_PARAMS = "source_params"
_K_TARGET_PARAMS = "target_params"
_K_TABLE = "table"
_K_SOURCE_TABLE = "source_table"
_K_TARGET_TABLE = "target_table"
_K_CURSOR = "cursor"
_K_SOURCE_CURSOR = "source_cursor"
_K_TARGET_CURSOR = "target_cursor"
_K_VALIDATE = "validate"
_K_RESULT = "result"

# Keys that identify each step type (excluding 'validate' which is universal).
_QUERY_KEYS = {_K_SOURCE_QUERY, _K_TARGET_QUERY, _K_BOTH}
_PARAM_KEYS = {_K_SOURCE_PARAMS, _K_TARGET_PARAMS}
_TABLE_KEYS = {_K_TABLE, _K_SOURCE_TABLE, _K_TARGET_TABLE}
_CURSOR_KEYS = {_K_CURSOR, _K_SOURCE_CURSOR, _K_TARGET_CURSOR}
_ALL_KNOWN_KEYS = _QUERY_KEYS | _PARAM_KEYS | _TABLE_KEYS | _CURSOR_KEYS | {_K_VALIDATE}
_VALIDATE_ENTRY_KEYS = {_K_TABLE, _K_CURSOR, _K_TARGET_QUERY}


def parse_step(raw: dict[str, Any], index: int) -> Step:
    """Parse a single YAML step dict into a typed :class:`Step`.

    Parameters
    ----------
    raw
        A dictionary from the ``validation.steps`` list.
    index
        Zero-based position in the list, used for error messages.

    Raises
    ------
    ValueError
        If the step contains unknown keys or keys from multiple step types.
    """
    keys = set(raw.keys())
    unknown = keys - _ALL_KNOWN_KEYS
    if unknown:
        raise ValueError(
            f"Step {index}: unknown key(s) {sorted(unknown)}. "
            f"Expected keys from: {sorted(_ALL_KNOWN_KEYS - {_K_VALIDATE})}"
        )

    raw_validate = raw.get(_K_VALIDATE, True)
    validate: bool | list[ValidateEntry]
    if isinstance(raw_validate, list):
        validate = _parse_validate_list(raw_validate, index)
    elif isinstance(raw_validate, bool):
        validate = raw_validate
    else:
        raise ValueError(
            f"Step {index}: 'validate' must be true, false, or a list; "
            f"got {raw_validate!r}"
        )

    # Determine which step-type keys are present.
    has_query = bool(keys & _QUERY_KEYS)
    has_param = bool(keys & _PARAM_KEYS)
    has_table = bool(keys & _TABLE_KEYS)
    has_cursor = bool(keys & _CURSOR_KEYS)

    type_count = sum([has_query, has_param, has_table, has_cursor])
    if type_count == 0:
        raise ValueError(
            f"Step {index}: no recognised step-type keys found. "
            f"Got: {sorted(keys - {_K_VALIDATE})}"
        )
    if type_count > 1:
        raise ValueError(
            f"Step {index}: mixed step-type keys. A step must contain keys "
            f"from exactly one type. Got: {sorted(keys - {_K_VALIDATE})}"
        )

    if has_query:
        return _parse_query_step(raw, validate, index)
    if has_param:
        return _parse_param_step(raw, validate)
    if has_table:
        return _parse_table_step(raw, validate)
    return _parse_cursor_step(raw, validate)


def _parse_query_step(raw: dict[str, Any], validate: bool | list[ValidateEntry], index: int) -> QueryStep:
    both = raw.get(_K_BOTH)
    if both is not None:
        return QueryStep(
            validate=validate,
            source_query=str(both),
            target_query=str(both),
        )
    source = _str_or_none(raw.get(_K_SOURCE_QUERY))
    target = _str_or_none(raw.get(_K_TARGET_QUERY))
    if source is None and target is None:
        raise ValueError(
            f"Step {index}: query step has no SQL — "
            f"source_query and target_query are both empty"
        )
    return QueryStep(
        validate=validate,
        source_query=source,
        target_query=target,
    )


def _parse_param_step(raw: dict[str, Any], validate: bool | list[ValidateEntry]) -> ParamStep:
    return ParamStep(
        validate=validate,
        source_params=raw.get(_K_SOURCE_PARAMS) or [],
        target_params=raw.get(_K_TARGET_PARAMS) or [],
    )


def _parse_table_step(raw: dict[str, Any], validate: bool | list[ValidateEntry]) -> TableStep:
    shared = raw.get(_K_TABLE)
    if shared is not None:
        return TableStep(
            validate=validate,
            source_table=str(shared),
            target_table=str(shared),
        )
    return TableStep(
        validate=validate,
        source_table=_str_or_none(raw.get(_K_SOURCE_TABLE)),
        target_table=_str_or_none(raw.get(_K_TARGET_TABLE)),
    )


def _parse_cursor_step(raw: dict[str, Any], validate: bool | list[ValidateEntry]) -> CursorStep:
    shared = raw.get(_K_CURSOR)
    if shared is not None:
        return CursorStep(
            validate=validate,
            source_cursor=str(shared),
            target_cursor=str(shared),
        )
    return CursorStep(
        validate=validate,
        source_cursor=_str_or_none(raw.get(_K_SOURCE_CURSOR)),
        target_cursor=_str_or_none(raw.get(_K_TARGET_CURSOR)),
    )


def _str_or_none(value: Any) -> str | None:
    return str(value) if value is not None else None


# ---------------------------------------------------------------------------
# validate: [list] parsing
# ---------------------------------------------------------------------------

def _parse_validate_list(raw: list, index: int) -> list[ValidateEntry]:
    return [_parse_validate_entry(entry, index, i) for i, entry in enumerate(raw)]


def _parse_validate_entry(entry: Any, step_index: int, entry_index: int) -> ValidateEntry:
    if entry is False:
        return VALIDATE_SKIP
    if entry == _K_RESULT:
        return ValidateResult()
    if isinstance(entry, dict):
        keys = set(entry.keys())
        unknown = keys - _VALIDATE_ENTRY_KEYS
        if unknown:
            raise ValueError(
                f"Step {step_index}, validate[{entry_index}]: "
                f"unknown key(s) {sorted(unknown)}"
            )
        if _K_TABLE in entry:
            return ValidateTable(table=str(entry[_K_TABLE]))
        if _K_CURSOR in entry:
            return ValidateCursor(cursor=str(entry[_K_CURSOR]))
        if _K_TARGET_QUERY in entry:
            return ValidateQuery(target_query=str(entry[_K_TARGET_QUERY]))
    raise ValueError(
        f"Step {step_index}, validate[{entry_index}]: "
        f"expected '{_K_RESULT}', false, or a dict with '{_K_TABLE}', "
        f"'{_K_CURSOR}', or '{_K_TARGET_QUERY}'; got {entry!r}"
    )


# ---------------------------------------------------------------------------
# Top-level parser
# ---------------------------------------------------------------------------

def parse_step_validation(validation: dict[str, Any]) -> StepBasedValidation:
    """Parse a full ``validation`` block that uses the step-based format.

    Parameters
    ----------
    validation
        The ``validation`` dict from the YAML file, expected to contain a
        ``steps`` key with a list value.

    Returns
    -------
    StepBasedValidation

    Raises
    ------
    ValueError
        If ``steps`` is not a non-empty list.
    """
    raw_steps = validation.get("steps")
    if not isinstance(raw_steps, list) or not raw_steps:
        raise ValueError("validation.steps must be a non-empty list")

    steps = [parse_step(raw, i) for i, raw in enumerate(raw_steps)]

    return StepBasedValidation(
        steps=steps,
        test_cases=validation.get("test_cases") or [],
        modifies_data=validation.get("modifies_data"),
        affected_tables=validation.get("affected_tables"),
    )


# ---------------------------------------------------------------------------
# Parameter name extraction for step-based files
# ---------------------------------------------------------------------------

def extract_step_parameter_names(steps: list[Step]) -> list[str]:
    """Extract ordered parameter names from step-based definitions.

    Scans ``source_query`` on :class:`QueryStep` instances for
    ``@name = {N}``, ``DECLARE @name TYPE = {N}`` / ``LET name TYPE := {N}``,
    and ``{N}`` positional placeholders.  Only source-side SQL is scanned,
    matching v1 behaviour where names come from ``source.steps.run`` and
    ``source.steps.pre_execute``.

    Returns names sorted by their placeholder index.
    """
    indexed: dict[int, str] = {}

    for step in steps:
        if isinstance(step, QueryStep) and step.source_query:
            _extract_from_sql(step.source_query, indexed)

    return [indexed[i] for i in sorted(indexed.keys())]


def _extract_from_sql(sql: str, indexed: dict[int, str]) -> None:
    """Extract parameter names/indices from a SQL string into *indexed*."""
    # Named assignments: @name = {N}
    for m in PARAM_ASSIGNMENT_RE.finditer(sql):
        idx = int(m.group(2))
        if idx not in indexed:
            indexed[idx] = m.group(1)

    # DECLARE @name TYPE = {N} / LET name TYPE := {N}
    for m in DECLARE_PARAM_RE.finditer(sql):
        name = m.group(1) or m.group(2)
        idx = int(m.group(3))
        if idx not in indexed:
            indexed[idx] = name

    # Positional fallback: {N} placeholders not yet captured.
    for m in POSITIONAL_PLACEHOLDER_RE.finditer(sql):
        idx = int(m.group(1))
        if idx not in indexed:
            indexed[idx] = f"param_{idx}"
