"""
Discover and parse test YAML files generated by ``scai test seed``.

Scans ``{project_root}/artifacts/**/test/*.yml`` and converts each file into
a :class:`~test_runner.common.models.TestFile` instance.

Expected YAML format::

    validation:
      source:
        steps:
          run: EXECUTE dbo.P @a = {0}
      target:
        steps:
          run: CALL P(A => {0})
      test_cases:
        - [1]
"""

from __future__ import annotations

import json
import logging
from pathlib import Path
from typing import Any, Optional

import yaml

from snowflake_code_unit_registry import CodeUnitRegistry, FindOptions

from .config import SelectorsConfig
from .models import TestFile, ValidationSteps
from .sql_patterns import DECLARE_PARAM_RE, PARAM_ASSIGNMENT_RE, POSITIONAL_PLACEHOLDER_RE
from .step_parser import extract_step_parameter_names, parse_step_validation

LOGGER = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Run-template parsing
# ---------------------------------------------------------------------------

# Backward-compatible aliases for the regex patterns (now in sql_patterns.py).
_PARAM_ASSIGNMENT_RE = PARAM_ASSIGNMENT_RE
_DECLARE_PARAM_RE = DECLARE_PARAM_RE
_POSITIONAL_PLACEHOLDER_RE = POSITIONAL_PLACEHOLDER_RE


def extract_parameter_names(run_template: str) -> list[str]:
    """Extract ordered parameter names from ``@name = {N}`` patterns."""
    return [m.group(1) for m in PARAM_ASSIGNMENT_RE.finditer(run_template)]


def extract_all_parameter_names(
    run_template: str,
    pre_execute: Optional[str] = None,
) -> list[str]:
    """Extract parameter names from both ``run`` and ``pre_execute`` templates.

    Collects ``(index, name)`` pairs from:

    * ``@name = {N}`` in ``run`` (input parameters)
    * ``DECLARE @name TYPE = {N}`` in ``pre_execute`` (SQL Server output params)
    * ``LET name TYPE := {N}`` in ``pre_execute`` (Snowflake output params)

    For function calls (``SELECT fn({0}, {1})``) where no named assignments
    exist, falls back to positional names (``param_0``, ``param_1``, …).

    Returns names sorted by their placeholder index so the list aligns
    1-to-1 with the ``test_cases`` array.
    """
    indexed: dict[int, str] = {}

    for m in _PARAM_ASSIGNMENT_RE.finditer(run_template):
        indexed[int(m.group(2))] = m.group(1)

    if pre_execute:
        for m in _DECLARE_PARAM_RE.finditer(pre_execute):
            name = m.group(1) or m.group(2)
            idx = int(m.group(3))
            if idx not in indexed:
                indexed[idx] = name

    # For functions: no named assignments exist, fall back to positional names
    if not indexed:
        for m in _POSITIONAL_PLACEHOLDER_RE.finditer(run_template):
            idx = int(m.group(1))
            if idx not in indexed:
                indexed[idx] = f"param_{idx}"

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


# ---------------------------------------------------------------------------
# YAML parsing helpers
# ---------------------------------------------------------------------------

def _parse_steps(raw: dict[str, Any] | None) -> Optional[ValidationSteps]:
    """Parse a ``steps`` dict into a :class:`ValidationSteps`."""
    if not raw:
        return None
    run_template = raw.get("run", "")
    if not run_template:
        return None
    output_section = raw.get("output") or {}
    output_parameters = output_section.get("parameters") if isinstance(output_section, dict) else None
    output_tables = output_section.get("tables") if isinstance(output_section, dict) else None
    output_cursors = output_section.get("cursors") if isinstance(output_section, dict) else None

    return ValidationSteps(
        run=run_template.strip(),
        pre_execute=(raw.get("pre_execute") or "").strip() or None,
        output_parameters=output_parameters,
        output_tables=output_tables,
        output_cursors=output_cursors,
    )


def _parse_yaml_file(file_path: Path, registry_dir: Optional[Path] = None) -> Optional[TestFile]:
    """Parse a single test YAML file into a :class:`TestFile`.

    Expected format::

        validation:
          source:
            steps:
              run: EXECUTE dbo.P @a = {0}
          target:          # optional
            steps:
              run: CALL P(A => {0})
          test_cases:
            - [1]
    """
    with open(file_path, "r", encoding="utf-8") as fh:
        raw: dict[str, Any] = yaml.safe_load(fh) or {}

    validation = raw.get("validation")
    if not validation:
        return None

    test_cases = validation.get("test_cases")
    if not test_cases:
        return None

    # Resolve code unit name (same for both v1 and v2).
    code_unit_name = ""
    if registry_dir is not None:
        code_unit_name = _load_code_unit_name(file_path, registry_dir)
    if not code_unit_name:
        code_unit_name = _extract_code_unit_name_from_path(file_path)

    # --- Step-based format (v2) ---
    steps_raw = validation.get("steps")
    if isinstance(steps_raw, list):
        step_validation = parse_step_validation(validation)
        parameter_names = extract_step_parameter_names(step_validation.steps)
        return TestFile(
            file_path=str(file_path),
            # Sentinel so downstream code that reads source.run doesn't break;
            # SNOW-3280023 will add runner logic to check step_validation first.
            source=ValidationSteps(run="<step-based>"),
            test_cases=test_cases,
            code_unit_name=code_unit_name,
            parameter_names=parameter_names,
            modifies_data=validation.get("modifies_data"),
            affected_tables=validation.get("affected_tables"),
            step_validation=step_validation,
        )

    # --- Legacy format (v1) ---
    source_section = validation.get("source")
    if not source_section:
        return None

    source_steps = _parse_steps(source_section.get("steps"))
    if source_steps is None:
        return None

    target_section = validation.get("target")
    target_steps = _parse_steps(target_section.get("steps")) if target_section else None

    run_template = source_steps.run

    return TestFile(
        file_path=str(file_path),
        source=source_steps,
        target=target_steps,
        test_cases=test_cases,
        code_unit_name=code_unit_name,
        parameter_names=extract_all_parameter_names(
            run_template, source_steps.pre_execute,
        ),
        modifies_data=validation.get("modifies_data"),
        affected_tables=validation.get("affected_tables"),
    )


# ---------------------------------------------------------------------------
# Code unit name resolution (CUR lookup → path fallback)
# ---------------------------------------------------------------------------

def _extract_code_unit_name_from_path(file_path: Path) -> str:
    """Fallback: infer the fully-qualified name from the artifacts path.

    Path layout: ``artifacts/{database}/{schema}/{objectType}/{name}/test/{id}.yml``
    """
    parts = file_path.parts
    try:
        idx = parts.index("artifacts")
        if idx + 4 < len(parts):
            database = parts[idx + 1]
            schema = parts[idx + 2]
            name = parts[idx + 4]
            return f"{database}.{schema}.{name}"
    except ValueError:
        pass
    return ""


def _load_code_unit_name(file_path: Path, registry_dir: Path) -> str:
    """Look up the code unit name from the CUR JSON file in the registry.

    The YAML filename (without extension) is the CUR ID.  The registry
    stores one JSON file per code unit at ``{registry_dir}/{id}.json``
    with ``source.database``, ``source.schema``, and ``source.name``.

    Returns ``{database}.{schema}.{name}`` or ``""`` if the lookup fails.
    """
    cur_id = file_path.stem
    cur_path = registry_dir / f"{cur_id}.json"
    if not cur_path.exists():
        return ""
    try:
        with open(cur_path, "r", encoding="utf-8") as fh:
            cur = json.load(fh)
        source = cur.get("source") or {}
        database = source.get("database", "")
        schema = source.get("schema", "")
        name = source.get("name", "")
        if name:
            parts = [p for p in (database, schema, name) if p]
            return ".".join(parts)
    except (ValueError, KeyError, OSError):
        pass
    return ""


# ---------------------------------------------------------------------------
# Selector filtering
# ---------------------------------------------------------------------------

def _extract_schema_from_path(file_path: str) -> str:
    """Infer the schema name from the artifacts directory structure."""
    parts = Path(file_path).parts
    try:
        idx = parts.index("artifacts")
        if idx + 2 < len(parts):
            return parts[idx + 2].upper()
    except ValueError:
        pass
    return ""


def _matches_selectors(test_file: TestFile, selectors: Optional[SelectorsConfig]) -> bool:
    if selectors is None:
        return True
    if selectors.schemas:
        schema = _extract_schema_from_path(test_file.file_path)
        if schema and schema not in selectors.schemas:
            return False
    if selectors.exclude:
        file_stem = Path(test_file.file_path).stem
        for excluded in selectors.exclude:
            if file_stem in excluded or excluded in test_file.file_path:
                return False
    return True


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

def discover_test_files(
    project_root: str | Path,
    selectors: Optional[SelectorsConfig] = None,
) -> list[TestFile]:
    """Scan ``artifacts/**/test/*.yml`` and return parsed :class:`TestFile` objects."""
    root = Path(project_root)
    artifacts_dir = root / "artifacts"
    if not artifacts_dir.exists():
        return []

    registry_dir = root / "registry"
    if not registry_dir.is_dir():
        registry_dir = None

    test_files: list[TestFile] = []
    for yml_path in sorted(artifacts_dir.rglob("test/*.yml")):
        parsed = _parse_yaml_file(yml_path, registry_dir)
        if parsed is None:
            continue
        if not _matches_selectors(parsed, selectors):
            continue
        test_files.append(parsed)

    return test_files


# ---------------------------------------------------------------------------
# CUR-based WHERE filtering
# ---------------------------------------------------------------------------

def filter_test_files(
    test_files: list[TestFile],
    project_root: str | Path,
    where: str | None = None,
    include_dependencies: bool = False,
) -> list[TestFile]:
    """Filter *test_files* using a CUR ``--where`` clause.

    Opens the CodeUnitRegistry at *project_root*, runs
    ``registry.find_all(FindOptions(filter=where, ...))`` and keeps only
    test files whose code-unit ID (the YAML file stem) appears in the
    result set.

    If *where* is ``None`` or empty, returns *test_files* unchanged.
    """
    if not where:
        return test_files

    registry = CodeUnitRegistry.open(str(project_root))

    try:
        matched_units = registry.find_all(FindOptions(
            filter=where,
            include_dependencies=include_dependencies,
            fields=["id"],
        ))
    except Exception as exc:
        LOGGER.error("CUR find_all failed for where=%r: %s", where, exc)
        raise SystemExit(f"Invalid --where clause: {exc}") from exc

    matched_ids: set[str] = {u.id for u in matched_units if u.id}
    LOGGER.info("--where matched %d code unit(s)", len(matched_ids))

    filtered = [
        tf for tf in test_files
        if Path(tf.file_path).stem in matched_ids
    ]
    return filtered
