"""Scenario-based test cockpit utilities for the SDK."""

from __future__ import annotations

import csv
import json
import os
import re
import shutil
import sqlite3
import subprocess
import sys
import time
import urllib.request
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Any, Mapping, MutableMapping, Optional, Sequence

try:
    import yaml
except Exception:  # pragma: no cover - runtime dependency
    yaml = None  # type: ignore[assignment]


def _require_yaml() -> None:
    if yaml is None:  # pragma: no cover - runtime dependency
        raise RuntimeError("PyYAML is required for Tester YAML helpers")


def _root(root: Optional[Path | str] = None) -> Path:
    return Path(root).expanduser().resolve() if root is not None else Path(__file__).resolve().parents[1]


def _slug(value: str) -> str:
    return re.sub(r"[^A-Za-z0-9._-]+", "_", value.strip()).strip("._-") or "scenario"


def _as_path(value: Path | str, *, root: Optional[Path] = None) -> Path:
    path = Path(value).expanduser()
    return path.resolve() if path.is_absolute() else ((root or Path.cwd()) / path).resolve()


def _norm(text: str) -> str:
    return "\n".join(line.rstrip() for line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n")).strip("\n")


def _read_text(path: Path, *, encoding: str = "utf-8") -> str:
    return path.read_text(encoding=encoding)


def _csv_rows(path: Path, *, delimiter: str = "\t", encoding: str = "utf-8") -> list[list[str]]:
    with path.open("r", encoding=encoding, newline="") as handle:
        return [[cell.strip() for cell in row] for row in csv.reader(handle, delimiter=delimiter)]


def _write_csv(path: Path, rows: Sequence[Sequence[Any]], *, delimiter: str = "\t", encoding: str = "utf-8") -> Path:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", encoding=encoding, newline="") as handle:
        writer = csv.writer(handle, delimiter=delimiter)
        for row in rows:
            writer.writerow(list(row))
    return path


class TestMode(str, Enum):
    """Canonical test modes for this SDK."""

    FLOW = "flow"
    STEP = "step"
    SNAPSHOT = "snapshot"
    CONTRACT = "contract"


@dataclass(frozen=True, slots=True)
class TestModeSpec:
    """Description of one test mode."""

    mode: TestMode
    goal: str
    when_to_use: str
    scope: str
    naming_prefix: str
    typical_assertions: tuple[str, ...]

    def as_dict(self) -> dict[str, Any]:
        return {
            "mode": self.mode.value,
            "goal": self.goal,
            "when_to_use": self.when_to_use,
            "scope": self.scope,
            "naming_prefix": self.naming_prefix,
            "typical_assertions": list(self.typical_assertions),
        }


@dataclass(frozen=True, slots=True)
class TestRecipe:
    """Concrete usage recipe for one test mode."""

    mode: TestMode
    title: str
    given: tuple[str, ...]
    when: tuple[str, ...]
    then: tuple[str, ...]
    notes: tuple[str, ...] = ()

    def as_dict(self) -> dict[str, Any]:
        return {
            "mode": self.mode.value,
            "title": self.title,
            "given": list(self.given),
            "when": list(self.when),
            "then": list(self.then),
            "notes": list(self.notes),
        }

    def as_markdown(self) -> str:
        lines = [f"# {self.title}", "", f"- Mode: `{self.mode.value}`", ""]
        for heading, items in (("Given", self.given), ("When", self.when), ("Then", self.then), ("Notes", self.notes)):
            if not items:
                continue
            lines.extend([f"## {heading}", *[f"- {item}" for item in items], ""])
        return "\n".join(lines).rstrip()


@dataclass(frozen=True, slots=True)
class TestPlan:
    """A full test plan: mode + recipe + scenario name."""

    mode: TestMode
    subject: str
    case: str | None = None
    recipe: TestRecipe | None = None

    @property
    def name(self) -> str:
        parts = [self.mode.value, _slug(self.subject)]
        if self.case:
            parts.append(_slug(self.case))
        return "_".join(parts)

    @property
    def title(self) -> str:
        return self.recipe.title if self.recipe is not None else f"{self.mode.value.title()}Test Plan"

    def as_dict(self) -> dict[str, Any]:
        return {
            "mode": self.mode.value,
            "subject": self.subject,
            "case": self.case,
            "name": self.name,
            "title": self.title,
            "recipe": self.recipe.as_dict() if self.recipe is not None else None,
        }

    def as_markdown(self) -> str:
        lines = [f"# {self.title}", "", f"- Mode: `{self.mode.value}`", f"- Subject: `{self.subject}`"]
        if self.case:
            lines.append(f"- Case: `{self.case}`")
        lines.append(f"- Name: `{self.name}`")
        lines.append("")
        if self.recipe is not None:
            lines.append(self.recipe.as_markdown())
        return "\n".join(lines).rstrip()

    def with_recipe(self, recipe: TestRecipe) -> "TestPlan":
        return TestPlan(mode=self.mode, subject=self.subject, case=self.case, recipe=recipe)

    def scenario_name(self) -> str:
        return self.name

    def scenario(self, tester: "Tester") -> "Scenario":
        return tester.scenario(self.scenario_name())

    def pipeline(self, tester: "Tester") -> "PipelineScenario":
        return tester.pipeline(self.scenario_name())

    def run(self, tester: "Tester") -> "Scenario":
        return tester.execute(self)

    def start(self, tester: "Tester") -> "Scenario":
        return self.run(tester)


TEST_MODE_SPECS: tuple[TestModeSpec, ...] = (
    TestModeSpec(
        mode=TestMode.FLOW,
        goal="Test the full business flow end-to-end.",
        when_to_use="Use when you care about the whole orchestration, not an isolated helper.",
        scope="Whole pipeline / scenario / integration flow.",
        naming_prefix="flow",
        typical_assertions=("exit_code", "artifact_exists", "summary_contains", "no_traceback"),
    ),
    TestModeSpec(
        mode=TestMode.STEP,
        goal="Test one step in isolation.",
        when_to_use="Use when you want fast feedback on a single transformation or boundary method.",
        scope="Single step, single boundary, single adapter seam.",
        naming_prefix="step",
        typical_assertions=("row_count", "column_set", "single_output", "call_order"),
    ),
    TestModeSpec(
        mode=TestMode.SNAPSHOT,
        goal="Freeze and compare produced artifacts.",
        when_to_use="Use when the output is stable and you want regression protection on files or tabular output.",
        scope="CSV, SQLite rows, markdown summary, YAML, JSON, logs.",
        naming_prefix="snapshot",
        typical_assertions=("csv_equals", "text_equals", "json_equals", "yaml_equals"),
    ),
    TestModeSpec(
        mode=TestMode.CONTRACT,
        goal="Verify the contract between components or against external boundaries.",
        when_to_use="Use when schema, format, or API compatibility must stay stable.",
        scope="Input/output schema, adapter contract, public API, boundary behavior.",
        naming_prefix="contract",
        typical_assertions=("schema", "types", "required_fields", "allowed_values"),
    ),
)


TEST_MODE_RECIPES: tuple[TestRecipe, ...] = (
    TestRecipe(
        mode=TestMode.FLOW,
        title="FlowTest Recipe",
        given=(
            "Prepare the full input dataset.",
            "Seed config.yaml or fixtures required by the business flow.",
            "Seed golden files only if you want to compare stable artifacts.",
        ),
        when=(
            "Run the pipeline, command, or end-to-end scenario.",
        ),
        then=(
            "Assert exit code is zero.",
            "Assert no traceback occurred.",
            "Assert pipeline summary and final artifacts exist.",
            "Assert final rows, columns, and log markers if needed.",
        ),
        notes=(
            "Use this when you care about the whole orchestration.",
            "Prefer this for the public business flow path.",
        ),
    ),
    TestRecipe(
        mode=TestMode.STEP,
        title="StepTest Recipe",
        given=(
            "Prepare the smallest possible input for one step.",
            "Mock only the external boundary touched by that step.",
        ),
        when=(
            "Run only the selected step or call the seam method directly.",
        ),
        then=(
            "Assert the step output shape, values, and side effects.",
            "Assert call order if the step interacts with a fake boundary.",
        ),
        notes=(
            "Use this for fast feedback on a single transformation.",
            "Keep it narrow; do not load the whole flow unless necessary.",
        ),
    ),
    TestRecipe(
        mode=TestMode.SNAPSHOT,
        title="SnapshotTest Recipe",
        given=(
            "Prepare a known-good baseline artifact or golden file.",
            "Stabilize non-deterministic fields before comparison.",
        ),
        when=(
            "Run the code that emits CSV, markdown, JSON, YAML, SQLite rows, or logs.",
        ),
        then=(
            "Assert the produced artifact matches the golden snapshot.",
            "Use normalized text/CSV comparison when whitespace may vary.",
        ),
        notes=(
            "Use this for regression protection on outputs.",
            "Prefer this for pipeline step dumps and summary reports.",
        ),
    ),
    TestRecipe(
        mode=TestMode.CONTRACT,
        title="ContractTest Recipe",
        given=(
            "Prepare a contract description or schema expectation.",
            "Define the required columns, types, keys, or API behavior.",
        ),
        when=(
            "Run the adapter, boundary, or public API that must obey the contract.",
        ),
        then=(
            "Assert schema, required fields, allowed values, and types.",
            "Assert boundary compatibility rather than full content equality.",
        ),
        notes=(
            "Use this to protect interoperability with external systems.",
            "This is the right mode for Google Sheet, Binance, MT5, Telegram, DB, and HTTP boundaries.",
        ),
    ),
)


def _mode_recipe_map() -> dict[TestMode, TestRecipe]:
    return {recipe.mode: recipe for recipe in TEST_MODE_RECIPES}


def _mode_spec_map() -> dict[TestMode, TestModeSpec]:
    return {spec.mode: spec for spec in TEST_MODE_SPECS}


def _mode_from_string(value: str) -> TestMode | None:
    normalized = value.strip().lower()
    for mode in TestMode:
        if normalized == mode.value:
            return mode
    return None


@dataclass(slots=True)
class CommandResult:
    __test__ = False

    command: list[str]
    cwd: Path
    returncode: int
    stdout: str
    stderr: str
    duration_seconds: float
    started_at: float
    finished_at: float

    @property
    def success(self) -> bool:
        return self.returncode == 0

    def check(self) -> "CommandResult":
        if not self.success:
            raise AssertionError(self.format_failure())
        return self

    def format_failure(self, preview: int = 4000) -> str:
        return (
            f"Command failed: {' '.join(self.command)}\n"
            f"cwd: {self.cwd}\n"
            f"returncode: {self.returncode}\n"
            f"stdout:\n{self.stdout[-preview:]}\n"
            f"stderr:\n{self.stderr[-preview:]}"
        )

    def as_dict(self) -> dict[str, Any]:
        return {
            "command": self.command,
            "cwd": str(self.cwd),
            "returncode": self.returncode,
            "stdout": self.stdout,
            "stderr": self.stderr,
            "duration_seconds": self.duration_seconds,
            "started_at": self.started_at,
            "finished_at": self.finished_at,
            "success": self.success,
        }


@dataclass(slots=True)
class Tester:
    """Private-project friendly helper for scenario-style tests."""

    __test__ = False

    root: Path | str | None = None
    artifacts_dir_name: str = ".tester_artifacts"
    golden_dir_name: str = "golden"

    def root_dir(self) -> Path:
        return _root(self.root)

    def artifacts_dir(self) -> Path:
        path = self.root_dir() / self.artifacts_dir_name
        path.mkdir(parents=True, exist_ok=True)
        return path

    def golden_dir(self) -> Path:
        return self.root_dir() / self.golden_dir_name

    def path(self, *parts: str) -> Path:
        return self.artifacts_dir().joinpath(*parts)

    def mode_spec(self, mode: TestMode | str) -> TestModeSpec:
        resolved = _mode_from_string(mode) if isinstance(mode, str) else mode
        if resolved is None:
            raise ValueError(f"Unknown test mode: {mode!r}")
        return _mode_spec_map()[resolved]

    def mode_specs(self) -> tuple[TestModeSpec, ...]:
        return TEST_MODE_SPECS

    def recipe(self, mode: TestMode | str) -> TestRecipe:
        resolved = _mode_from_string(mode) if isinstance(mode, str) else mode
        if resolved is None:
            raise ValueError(f"Unknown test mode: {mode!r}")
        return _mode_recipe_map()[resolved]

    def flow_recipe(self) -> TestRecipe:
        return self.recipe(TestMode.FLOW)

    def step_recipe(self) -> TestRecipe:
        return self.recipe(TestMode.STEP)

    def snapshot_recipe(self) -> TestRecipe:
        return self.recipe(TestMode.SNAPSHOT)

    def contract_recipe(self) -> TestRecipe:
        return self.recipe(TestMode.CONTRACT)

    def plan(self, mode: TestMode | str, subject: str, case: str | None = None) -> TestPlan:
        resolved = _mode_from_string(mode) if isinstance(mode, str) else mode
        if resolved is None:
            raise ValueError(f"Unknown test mode: {mode!r}")
        return TestPlan(mode=resolved, subject=subject, case=case, recipe=self.recipe(resolved))

    def flow_plan(self, subject: str, case: str | None = None) -> TestPlan:
        return self.plan(TestMode.FLOW, subject, case)

    def step_plan(self, subject: str, case: str | None = None) -> TestPlan:
        return self.plan(TestMode.STEP, subject, case)

    def snapshot_plan(self, subject: str, case: str | None = None) -> TestPlan:
        return self.plan(TestMode.SNAPSHOT, subject, case)

    def contract_plan(self, subject: str, case: str | None = None) -> TestPlan:
        return self.plan(TestMode.CONTRACT, subject, case)

    def flow_case(self, subject: str, case: str) -> TestPlan:
        return self.flow_plan(subject, case)

    def step_case(self, subject: str, case: str) -> TestPlan:
        return self.step_plan(subject, case)

    def snapshot_case(self, subject: str, case: str) -> TestPlan:
        return self.snapshot_plan(subject, case)

    def contract_case(self, subject: str, case: str) -> TestPlan:
        return self.contract_plan(subject, case)

    def boundary_contract(self, boundary: str, subject: str, case: str | None = None) -> TestPlan:
        """Shortcut for boundary-focused contract tests."""

        return self.contract_plan(f"{_slug(boundary)}_{_slug(subject)}", case)

    def sheet_contract(self, subject: str, case: str | None = None) -> TestPlan:
        return self.boundary_contract("sheet", subject, case)

    def binance_contract(self, subject: str, case: str | None = None) -> TestPlan:
        return self.boundary_contract("binance", subject, case)

    def mt5_contract(self, subject: str, case: str | None = None) -> TestPlan:
        return self.boundary_contract("mt5", subject, case)

    def telegram_contract(self, subject: str, case: str | None = None) -> TestPlan:
        return self.boundary_contract("telegram", subject, case)

    def database_contract(self, subject: str, case: str | None = None) -> TestPlan:
        return self.boundary_contract("database", subject, case)

    def http_contract(self, subject: str, case: str | None = None) -> TestPlan:
        return self.boundary_contract("http", subject, case)

    def execute(self, plan: TestPlan) -> "Scenario":
        """Instantiate the correct scenario type for a plan."""

        if plan.mode in {TestMode.FLOW}:
            return plan.pipeline(self)
        return plan.scenario(self)

    def start(self, plan: TestPlan) -> "Scenario":
        return self.execute(plan)

    def run(self, plan: TestPlan) -> "Scenario":
        return self.execute(plan)

    def recipes(self) -> tuple[TestRecipe, ...]:
        return TEST_MODE_RECIPES

    def taxonomy(self) -> dict[str, Any]:
        """Return the 4-mode test taxonomy as serializable data."""

        return {
            "modes": [spec.as_dict() for spec in TEST_MODE_SPECS],
            "naming_convention": self.naming_convention(),
        }

    def mode_guide(self) -> str:
        """Render the test taxonomy as Markdown."""

        lines = ["# Soigia Test Taxonomy", ""]
        for spec in TEST_MODE_SPECS:
            lines.extend(
                [
                    f"## {spec.mode.value.title()}Test",
                    f"- Goal: {spec.goal}",
                    f"- When to use: {spec.when_to_use}",
                    f"- Scope: {spec.scope}",
                    f"- Naming prefix: `{spec.naming_prefix}`",
                    f"- Typical assertions: {', '.join(spec.typical_assertions)}",
                    "",
                ]
            )
        lines.append("## Naming Convention")
        lines.extend(
            [
                f"- Test file: `{self.naming_convention()['file_pattern']}`",
                f"- Test function: `{self.naming_convention()['function_pattern']}`",
                f"- Scenario folder: `{self.naming_convention()['scenario_pattern']}`",
                f"- Artifact folder: `{self.naming_convention()['artifact_pattern']}`",
            ]
        )
        return "\n".join(lines)

    def examples(self) -> dict[str, str]:
        """Return canonical usage examples for the 4 test modes."""

        return {
            "flow": (
                "tester.flow('sales_flow')\n"
                "    .given_yaml('config.yaml', {'source': 'sample'})\n"
                "    .given_csv('input/orders.tsv', [['order_id', 'amount'], ['A1', 100]])\n"
                "    .when_run_module('examples.pipeline_yaml_config_demo')\n"
                "    .then_exit_code(0)\n"
                "    .then_no_traceback()\n"
                "    .then_pipeline_summary_exists()\n"
            ),
            "step": (
                "tester.step('normalize_step')\n"
                "    .given_csv('input.csv', [['order_id', 'amount'], ['A1', 100]])\n"
                "    .when_run_command(['python', '-c', 'print(\"normalize\")'])\n"
                "    .then_exit_code(0)\n"
                "    .then_stdout_contains('normalize')\n"
            ),
            "snapshot": (
                "tester.snapshot('summary_snapshot')\n"
                "    .given_text('expected.md', '# Report\\n')\n"
                "    .when_run_command(['python', '-c', 'print(\"# Report\")'])\n"
                "    .then_exit_code(0)\n"
                "    .then_stdout_contains('Report')\n"
            ),
            "contract": (
                "tester.contract('sheet_contract')\n"
                "    .given_yaml('contract.yaml', {'required_columns': ['id', 'amount']})\n"
                "    .when_run_command(['python', '-c', 'print(\"contract ok\")'])\n"
                "    .then_exit_code(0)\n"
                "    .then_stdout_contains('contract ok')\n"
            ),
        }

    def examples_markdown(self) -> str:
        """Render the canonical usage examples as Markdown."""

        lines = ["# Soigia Tester Examples", ""]
        for mode, example in self.examples().items():
            lines.extend([f"## {mode.title()}Test", "```python", example.rstrip(), "```", ""])
        return "\n".join(lines)

    def recipe_markdown(self) -> str:
        """Render the canonical recipes as Markdown."""

        lines = ["# Soigia Test Recipes", ""]
        for recipe in TEST_MODE_RECIPES:
            lines.append(recipe.as_markdown())
            lines.append("")
        return "\n".join(lines).rstrip()

    def recipe_guide(self) -> dict[str, Any]:
        """Return recipe guidance as serializable data."""

        return {
            "recipes": [recipe.as_dict() for recipe in TEST_MODE_RECIPES],
            "recipes_markdown": self.recipe_markdown(),
        }

    def naming_convention(self) -> dict[str, str]:
        """Return the naming convention used by the four test modes."""

        return {
            "file_pattern": "tests/test_<mode>_<subject>.py",
            "function_pattern": "test_<mode>_<subject>_<case>",
            "scenario_pattern": ".tester_artifacts/<mode>/<subject>/<case>",
            "artifact_pattern": "data/<pipeline_name>/* or logs/<pipeline_name>.log",
        }

    def test_name(self, mode: TestMode | str, subject: str, case: str | None = None) -> str:
        """Build a canonical test name for the selected mode."""

        spec = self.mode_spec(mode)
        parts = [f"test_{spec.naming_prefix}", _slug(subject)]
        if case:
            parts.append(_slug(case))
        return "_".join(parts)

    def recommend_mode(self, intent: str) -> TestMode:
        """Best-effort mode recommendation from a short intent string."""

        text = intent.lower()
        if any(keyword in text for keyword in ("snapshot", "golden", "artifact", "csv", "parquet", "markdown", "yaml", "json")):
            return TestMode.SNAPSHOT
        if any(keyword in text for keyword in ("contract", "schema", "boundary", "adapter", "api", "compat")):
            return TestMode.CONTRACT
        if any(keyword in text for keyword in ("step", "single", "isolate", "isolated", "unit")):
            return TestMode.STEP
        return TestMode.FLOW

    def scenario(self, name: str = "scenario") -> "Scenario":
        return Scenario(self, name=name)

    def pipeline(self, name: str = "pipeline") -> "PipelineScenario":
        return PipelineScenario(self, name=name)

    def flow(self, name: str = "flow") -> "FlowScenario":
        return FlowScenario(self, name=name)

    def step(self, name: str = "step") -> "StepScenario":
        return StepScenario(self, name=name)

    def snapshot(self, name: str = "snapshot") -> "SnapshotScenario":
        return SnapshotScenario(self, name=name)

    def contract(self, name: str = "contract") -> "ContractScenario":
        return ContractScenario(self, name=name)

    def write_text(self, relative_path: str, content: str, *, encoding: str = "utf-8") -> Path:
        path = self.path(relative_path)
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(content, encoding=encoding)
        return path

    def read_text(self, relative_path: str, *, encoding: str = "utf-8") -> str:
        return self.path(relative_path).read_text(encoding=encoding)

    def write_json(self, relative_path: str, data: Any, *, indent: int = 2, sort_keys: bool = True) -> Path:
        path = self.path(relative_path)
        path.parent.mkdir(parents=True, exist_ok=True)
        with path.open("w", encoding="utf-8") as handle:
            json.dump(data, handle, ensure_ascii=False, indent=indent, sort_keys=sort_keys)
            handle.write("\n")
        return path

    def read_json(self, relative_path: str) -> Any:
        with self.path(relative_path).open("r", encoding="utf-8") as handle:
            return json.load(handle)

    def write_yaml(self, relative_path: str, data: Mapping[str, Any], *, sort_keys: bool = False) -> Path:
        _require_yaml()
        path = self.path(relative_path)
        path.parent.mkdir(parents=True, exist_ok=True)
        with path.open("w", encoding="utf-8") as handle:
            yaml.safe_dump(data, handle, sort_keys=sort_keys, allow_unicode=True)
        return path

    def read_yaml(self, relative_path: str) -> Any:
        _require_yaml()
        with self.path(relative_path).open("r", encoding="utf-8") as handle:
            return yaml.safe_load(handle)

    def write_csv(self, relative_path: str, rows: Sequence[Sequence[Any]], *, delimiter: str = "\t") -> Path:
        return _write_csv(self.path(relative_path), rows, delimiter=delimiter)

    def read_csv(self, relative_path: str, *, delimiter: str = "\t") -> list[list[str]]:
        return _csv_rows(self.path(relative_path), delimiter=delimiter)

    def write_sqlite_table(self, relative_path: str, rows: Sequence[Mapping[str, Any]], *, table_name: str = "data") -> Path:
        path = self.path(relative_path)
        path.parent.mkdir(parents=True, exist_ok=True)
        rows = list(rows)
        with sqlite3.connect(path) as connection:
            if not rows:
                connection.execute(f"CREATE TABLE IF NOT EXISTS {table_name} (id INTEGER)")
                connection.commit()
                return path
            columns = list(rows[0].keys())
            connection.execute(
                f'CREATE TABLE IF NOT EXISTS {table_name} ({", ".join(f"""\"{c}\" TEXT""" for c in columns)})'
            )
            connection.execute(f"DELETE FROM {table_name}")
            placeholders = ", ".join("?" for _ in columns)
            insert_sql = f'INSERT INTO {table_name} ({", ".join(columns)}) VALUES ({placeholders})'
            for row in rows:
                connection.execute(insert_sql, [row.get(column) for column in columns])
            connection.commit()
        return path

    def read_sqlite_table(self, relative_path: str, *, table_name: str = "data") -> list[dict[str, Any]]:
        with sqlite3.connect(self.path(relative_path)) as connection:
            connection.row_factory = sqlite3.Row
            return [dict(row) for row in connection.execute(f"SELECT * FROM {table_name}").fetchall()]

    def sqlite_row_count(self, relative_path: str, *, table_name: str = "data") -> int:
        with sqlite3.connect(self.path(relative_path)) as connection:
            row = connection.execute(f"SELECT COUNT(*) FROM {table_name}").fetchone()
            return int(row[0]) if row else 0

    def exists(self, relative_path: str) -> bool:
        return self.path(relative_path).exists()

    def copy_file(self, source: Path | str, relative_target: str) -> Path:
        src = _as_path(source, root=self.root_dir())
        dst = self.path(relative_target)
        dst.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(src, dst)
        return dst

    def assert_file_exists(self, relative_path: str) -> Path:
        path = self.path(relative_path)
        if not path.exists():
            raise AssertionError(f"Expected file to exist: {path}")
        return path

    def assert_text_contains(self, relative_path: str, needle: str, *, encoding: str = "utf-8") -> Path:
        path = self.assert_file_exists(relative_path)
        if needle not in _read_text(path, encoding=encoding):
            raise AssertionError(f"{needle!r} not found in {path}")
        return path

    def assert_text_equals(self, relative_path: str, expected: str, *, encoding: str = "utf-8") -> Path:
        path = self.assert_file_exists(relative_path)
        if _norm(_read_text(path, encoding=encoding)) != _norm(expected):
            raise AssertionError(f"Text mismatch in {path}")
        return path

    def assert_json_equals(self, relative_path: str, expected: Any) -> Path:
        path = self.assert_file_exists(relative_path)
        actual = self.read_json(relative_path)
        if actual != expected:
            raise AssertionError(f"JSON mismatch in {path}\nactual={actual!r}\nexpected={expected!r}")
        return path

    def assert_yaml_equals(self, relative_path: str, expected: Any) -> Path:
        path = self.assert_file_exists(relative_path)
        actual = self.read_yaml(relative_path)
        if actual != expected:
            raise AssertionError(f"YAML mismatch in {path}\nactual={actual!r}\nexpected={expected!r}")
        return path

    def assert_csv_equals(self, relative_path: str, expected_rows: Sequence[Sequence[Any]], *, delimiter: str = "\t") -> Path:
        path = self.assert_file_exists(relative_path)
        actual = _csv_rows(path, delimiter=delimiter)
        expected = [[str(cell).strip() for cell in row] for row in expected_rows]
        if actual != expected:
            raise AssertionError(f"CSV mismatch in {path}\nactual={actual!r}\nexpected={expected!r}")
        return path

    def assert_sqlite_row_count(self, relative_path: str, expected_count: int, *, table_name: str = "data") -> Path:
        path = self.assert_file_exists(relative_path)
        actual_count = self.sqlite_row_count(relative_path, table_name=table_name)
        if actual_count != expected_count:
            raise AssertionError(f"Row count mismatch in {path}: {actual_count} != {expected_count}")
        return path

    def assert_sqlite_columns(self, relative_path: str, expected_columns: Sequence[str], *, table_name: str = "data") -> Path:
        path = self.assert_file_exists(relative_path)
        with sqlite3.connect(path) as connection:
            rows = connection.execute(f"PRAGMA table_info({table_name})").fetchall()
        actual = [str(row[1]) for row in rows]
        expected = list(expected_columns)
        if actual != expected:
            raise AssertionError(f"Column mismatch in {path}: {actual!r} != {expected!r}")
        return path

    def run_command(
        self,
        command: Sequence[str] | str,
        *,
        cwd: Path | str | None = None,
        env: Optional[Mapping[str, str]] = None,
        timeout: float | None = None,
        check: bool = False,
    ) -> CommandResult:
        args = command if isinstance(command, str) else [str(item) for item in command]
        working_dir = _as_path(cwd, root=self.root_dir()) if cwd is not None else self.root_dir()
        runtime_env: MutableMapping[str, str] = dict(os.environ)
        if env:
            runtime_env.update({str(k): str(v) for k, v in env.items()})
        started_at = time.time()
        start = time.perf_counter()
        completed = subprocess.run(
            args,
            cwd=str(working_dir),
            env=runtime_env,
            capture_output=True,
            text=True,
            timeout=timeout,
            shell=isinstance(command, str),
        )
        result = CommandResult(
            command=args if isinstance(args, list) else [args],
            cwd=working_dir,
            returncode=completed.returncode,
            stdout=completed.stdout,
            stderr=completed.stderr,
            duration_seconds=time.perf_counter() - start,
            started_at=started_at,
            finished_at=time.time(),
        )
        if check:
            result.check()
        return result

    def run_module(self, module: str, *, args: Sequence[str] = (), cwd: Path | str | None = None, env: Optional[Mapping[str, str]] = None, timeout: float | None = None, check: bool = False) -> CommandResult:
        return self.run_command([sys.executable, "-m", module, *[str(arg) for arg in args]], cwd=cwd, env=env, timeout=timeout, check=check)

    def run_script(self, script: Path | str, *, args: Sequence[str] = (), cwd: Path | str | None = None, env: Optional[Mapping[str, str]] = None, timeout: float | None = None, check: bool = False) -> CommandResult:
        return self.run_command([sys.executable, str(_as_path(script, root=self.root_dir())), *[str(arg) for arg in args]], cwd=cwd, env=env, timeout=timeout, check=check)

    def cleanup(self) -> None:
        shutil.rmtree(self.artifacts_dir(), ignore_errors=True)


@dataclass(slots=True)
class Scenario:
    """Given / When / Then runner for end-to-end cases."""

    __test__ = False

    tester: Tester
    name: str = "scenario"
    result: CommandResult | None = None

    @property
    def scenario_path(self) -> Path:
        return self.tester.artifacts_dir() / _slug(self.name)

    def path(self, *parts: str) -> Path:
        return self.scenario_path.joinpath(*parts)

    def given_text(self, relative_path: str, content: str, *, encoding: str = "utf-8") -> "Scenario":
        path = self.path(relative_path)
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(content, encoding=encoding)
        return self

    def given_json(self, relative_path: str, data: Any) -> "Scenario":
        path = self.path(relative_path)
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
        return self

    def given_yaml(self, relative_path: str, data: Mapping[str, Any], *, sort_keys: bool = False) -> "Scenario":
        _require_yaml()
        path = self.path(relative_path)
        path.parent.mkdir(parents=True, exist_ok=True)
        with path.open("w", encoding="utf-8") as handle:
            yaml.safe_dump(data, handle, sort_keys=sort_keys, allow_unicode=True)
        return self

    def given_csv(self, relative_path: str, rows: Sequence[Sequence[Any]], *, delimiter: str = "\t") -> "Scenario":
        _write_csv(self.path(relative_path), rows, delimiter=delimiter)
        return self

    def given_file(self, source: Path | str, relative_target: str) -> "Scenario":
        src = _as_path(source, root=self.tester.root_dir())
        dst = self.path(relative_target)
        dst.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(src, dst)
        return self

    def when_run_command(self, command: Sequence[str] | str, *, cwd: Path | str | None = None, env: Optional[Mapping[str, str]] = None, timeout: float | None = None, check: bool = False) -> "Scenario":
        self.result = self.tester.run_command(command, cwd=cwd or self.scenario_path, env=env, timeout=timeout, check=check)
        return self

    def when_run_module(self, module: str, *, args: Sequence[str] = (), cwd: Path | str | None = None, env: Optional[Mapping[str, str]] = None, timeout: float | None = None, check: bool = False) -> "Scenario":
        self.result = self.tester.run_module(module, args=args, cwd=cwd or self.scenario_path, env=env, timeout=timeout, check=check)
        return self

    def when_run_script(self, script: Path | str, *, args: Sequence[str] = (), cwd: Path | str | None = None, env: Optional[Mapping[str, str]] = None, timeout: float | None = None, check: bool = False) -> "Scenario":
        self.result = self.tester.run_script(script, args=args, cwd=cwd or self.scenario_path, env=env, timeout=timeout, check=check)
        return self

    def then_exit_code(self, expected: int = 0) -> "Scenario":
        self._require_result()
        if self.result.returncode != expected:
            raise AssertionError(self.result.format_failure())
        return self

    def then_no_traceback(self) -> "Scenario":
        self._require_result()
        content = f"{self.result.stdout}\n{self.result.stderr}"
        if "Traceback (most recent call last)" in content or "Traceback:" in content:
            raise AssertionError(self.result.format_failure())
        return self

    def then_stdout_contains(self, needle: str) -> "Scenario":
        self._require_result()
        if needle not in self.result.stdout:
            raise AssertionError(f"{needle!r} not found in stdout")
        return self

    def then_stderr_contains(self, needle: str) -> "Scenario":
        self._require_result()
        if needle not in self.result.stderr:
            raise AssertionError(f"{needle!r} not found in stderr")
        return self

    def then_file_exists(self, relative_path: str) -> "Scenario":
        if not self.path(relative_path).exists():
            raise AssertionError(f"Expected file to exist: {self.path(relative_path)}")
        return self

    def then_text_contains(self, relative_path: str, needle: str, *, encoding: str = "utf-8") -> "Scenario":
        if needle not in _read_text(self.path(relative_path), encoding=encoding):
            raise AssertionError(f"{needle!r} not found in {self.path(relative_path)}")
        return self

    def then_text_equals(self, relative_path: str, expected: str, *, encoding: str = "utf-8") -> "Scenario":
        if _norm(_read_text(self.path(relative_path), encoding=encoding)) != _norm(expected):
            raise AssertionError(f"Text mismatch in {self.path(relative_path)}")
        return self

    def then_json_equals(self, relative_path: str, expected: Any) -> "Scenario":
        with self.path(relative_path).open("r", encoding="utf-8") as handle:
            actual = json.load(handle)
        if actual != expected:
            raise AssertionError(f"JSON mismatch in {self.path(relative_path)}\nactual={actual!r}\nexpected={expected!r}")
        return self

    def then_yaml_equals(self, relative_path: str, expected: Any) -> "Scenario":
        _require_yaml()
        with self.path(relative_path).open("r", encoding="utf-8") as handle:
            actual = yaml.safe_load(handle)
        if actual != expected:
            raise AssertionError(f"YAML mismatch in {self.path(relative_path)}\nactual={actual!r}\nexpected={expected!r}")
        return self

    def then_csv_equals(self, relative_path: str, expected_rows: Sequence[Sequence[Any]], *, delimiter: str = "\t") -> "Scenario":
        actual = _csv_rows(self.path(relative_path), delimiter=delimiter)
        expected = [[str(cell).strip() for cell in row] for row in expected_rows]
        if actual != expected:
            raise AssertionError(f"CSV mismatch in {self.path(relative_path)}\nactual={actual!r}\nexpected={expected!r}")
        return self

    def then_sqlite_row_count(self, relative_path: str, expected_count: int, *, table_name: str = "data") -> "Scenario":
        actual = self.tester.sqlite_row_count(str(self.path(relative_path).relative_to(self.tester.artifacts_dir())), table_name=table_name)
        if actual != expected_count:
            raise AssertionError(f"Row count mismatch in {self.path(relative_path)}: {actual} != {expected_count}")
        return self

    def dump_debug(self) -> Path:
        dump_dir = self.scenario_path / "debug"
        dump_dir.mkdir(parents=True, exist_ok=True)
        if self.result is not None:
            (dump_dir / "command.txt").write_text(" ".join(self.result.command), encoding="utf-8")
            (dump_dir / "stdout.txt").write_text(self.result.stdout, encoding="utf-8")
            (dump_dir / "stderr.txt").write_text(self.result.stderr, encoding="utf-8")
            (dump_dir / "result.json").write_text(json.dumps(self.result.as_dict(), ensure_ascii=False, indent=2), encoding="utf-8")
        return dump_dir

    def _require_result(self) -> None:
        if self.result is None:
            raise AssertionError("Scenario has no command result yet. Call when_run_* first.")


@dataclass(slots=True)
class PipelineScenario(Scenario):
    """Scenario runner with pipeline-specific assertions."""

    __test__ = False

    @property
    def pipeline_name(self) -> str:
        return self.name

    def pipeline_dir(self) -> Path:
        return self.tester.root_dir() / "data" / _slug(self.pipeline_name)

    def pipeline_summary_path(self) -> Path:
        return self.pipeline_dir() / "pipeline_summary.md"

    def pipeline_log_path(self) -> Path:
        return self.tester.root_dir() / "logs" / f"{_slug(self.pipeline_name)}.log"

    def then_pipeline_summary_exists(self) -> "PipelineScenario":
        path = self.pipeline_summary_path()
        if not path.exists():
            raise AssertionError(f"Missing pipeline summary: {path}")
        return self

    def then_pipeline_summary_contains(self, needle: str) -> "PipelineScenario":
        path = self.pipeline_summary_path()
        if not path.exists():
            raise AssertionError(f"Missing pipeline summary: {path}")
        if needle not in _read_text(path, encoding="utf-8"):
            raise AssertionError(f"{needle!r} not found in pipeline summary {path}")
        return self

    def then_pipeline_log_contains(self, needle: str) -> "PipelineScenario":
        path = self.pipeline_log_path()
        if not path.exists():
            raise AssertionError(f"Missing pipeline log: {path}")
        if needle not in _read_text(path, encoding="utf-8"):
            raise AssertionError(f"{needle!r} not found in pipeline log {path}")
        return self

    def then_pipeline_artifact_exists(self, relative_path: str) -> "PipelineScenario":
        path = self.pipeline_dir() / relative_path
        if not path.exists():
            raise AssertionError(f"Expected pipeline artifact to exist: {path}")
        return self

    def then_pipeline_csv_equals(
        self,
        relative_path: str,
        expected_rows: Sequence[Sequence[Any]],
        *,
        delimiter: str = "\t",
    ) -> "PipelineScenario":
        path = self.pipeline_dir() / relative_path
        actual = _csv_rows(path, delimiter=delimiter)
        expected = [[str(cell).strip() for cell in row] for row in expected_rows]
        if actual != expected:
            raise AssertionError(f"CSV mismatch in {path}\nactual={actual!r}\nexpected={expected!r}")
        return self

    def then_pipeline_sqlite_row_count(self, relative_path: str, expected_count: int, *, table_name: str = "data") -> "PipelineScenario":
        path = self.pipeline_dir() / relative_path
        with sqlite3.connect(path) as connection:
            row = connection.execute(f"SELECT COUNT(*) FROM {table_name}").fetchone()
            actual = int(row[0]) if row else 0
        if actual != expected_count:
            raise AssertionError(f"Row count mismatch in {path}: {actual} != {expected_count}")
        return self


@dataclass(slots=True)
class FlowScenario(PipelineScenario):
    __test__ = False

    mode: TestMode = TestMode.FLOW


@dataclass(slots=True)
class StepScenario(Scenario):
    __test__ = False

    mode: TestMode = TestMode.STEP


@dataclass(slots=True)
class SnapshotScenario(Scenario):
    __test__ = False

    mode: TestMode = TestMode.SNAPSHOT


@dataclass(slots=True)
class ContractScenario(Scenario):
    __test__ = False

    mode: TestMode = TestMode.CONTRACT


@dataclass(slots=True)
class PipelineTester(Tester):
    __test__ = False

    def pipeline_dir(self, pipeline_name: str) -> Path:
        return self.root_dir() / "data" / _slug(pipeline_name)

    def pipeline_summary_path(self, pipeline_name: str) -> Path:
        return self.pipeline_dir(pipeline_name) / "pipeline_summary.md"

    def pipeline_log_path(self, pipeline_name: str) -> Path:
        return self.root_dir() / "logs" / f"{_slug(pipeline_name)}.log"


@dataclass(slots=True)
class UITester(Tester):
    __test__ = False

    def run_streamlit_script(
        self,
        script: Path | str,
        *,
        port: int = 8501,
        timeout: float = 60.0,
        args: Sequence[str] = (),
        cwd: Path | str | None = None,
        env: Optional[Mapping[str, str]] = None,
    ) -> CommandResult:
        script_path = _as_path(script, root=self.root_dir())
        command = [
            sys.executable,
            "-m",
            "streamlit",
            "run",
            str(script_path),
            "--server.headless",
            "true",
            "--server.address",
            "127.0.0.1",
            "--server.port",
            str(port),
            "--logger.level",
            "error",
            *[str(arg) for arg in args],
        ]
        working_dir = _as_path(cwd, root=self.root_dir()) if cwd is not None else self.root_dir()
        runtime_env: MutableMapping[str, str] = dict(os.environ)
        runtime_env.setdefault("STREAMLIT_BROWSER_GATHER_USAGE_STATS", "false")
        if env:
            runtime_env.update({str(k): str(v) for k, v in env.items()})

        started_at = time.time()
        start = time.perf_counter()
        proc = subprocess.Popen(command, cwd=str(working_dir), env=runtime_env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
        url = f"http://127.0.0.1:{port}"
        deadline = time.monotonic() + timeout
        started = False
        last_error: Exception | None = None
        try:
            while time.monotonic() < deadline:
                if proc.poll() is not None:
                    break
                try:
                    with urllib.request.urlopen(url, timeout=1.0):
                        started = True
                        break
                except Exception as exc:  # pragma: no cover - transient startup
                    last_error = exc
                    time.sleep(0.25)
            if not started and proc.poll() is None:
                last_error = last_error or TimeoutError(f"Streamlit app did not start within {timeout} seconds")
        finally:
            if proc.poll() is None:
                proc.terminate()
                try:
                    stdout, stderr = proc.communicate(timeout=10)
                except subprocess.TimeoutExpired:
                    proc.kill()
                    stdout, stderr = proc.communicate()
            else:
                stdout, stderr = proc.communicate()
        result = CommandResult(
            command=command,
            cwd=working_dir,
            returncode=proc.returncode if proc.returncode is not None else 1,
            stdout=stdout or "",
            stderr=stderr or "",
            duration_seconds=time.perf_counter() - start,
            started_at=started_at,
            finished_at=time.time(),
        )
        if last_error is not None and not started and result.returncode == 0:
            result = CommandResult(
                command=command,
                cwd=working_dir,
                returncode=1,
                stdout=result.stdout,
                stderr=f"{result.stderr}\n{last_error!r}",
                duration_seconds=result.duration_seconds,
                started_at=result.started_at,
                finished_at=result.finished_at,
            )
        return result


__all__ = [
    "CommandResult",
    "ContractScenario",
    "FlowScenario",
    "PipelineScenario",
    "PipelineTester",
    "Scenario",
    "SnapshotScenario",
    "StepScenario",
    "TestMode",
    "TestModeSpec",
    "TestPlan",
    "TestRecipe",
    "Tester",
    "UITester",
]
