"""
:mod:`tests.unit.cli.conftest` module.

Shared fixtures and helpers for pytest-based unit tests of
:mod:`etlplus.cli` modules.

Notes
-----
- Fixtures are designed for reuse and DRY test setup.
"""

from __future__ import annotations

import json
import types
from collections.abc import Callable
from collections.abc import Mapping
from dataclasses import dataclass
from dataclasses import field
from pathlib import Path
from typing import Final
from typing import cast

import pytest
import typer
from click.testing import Result
from typer.testing import CliRunner

from etlplus import Config
from etlplus.cli.commands import app as cli_app
from etlplus.cli.state import CliState

# SECTION: MARKERS ========================================================== #


# Directory-level marker for unit tests.
pytestmark = pytest.mark.unit


# SECTION: TYPES ============================================================ #


CSV_TEXT: Final[str] = 'a,b\n1,2\n3,4\n'

type AssertCapturedText = Callable[[str], str]
type CaptureIo = dict[str, list[tuple[tuple[object, ...], dict[str, object]]]]
type InvokeCli = Callable[..., Result]
type StubHandler = Callable[..., dict[str, object]]
type StubCommandMain = Callable[
    [Callable[..., object] | BaseException],
    dict[str, object],
]
type StubCommand = Callable[[Callable[..., object]], None]
type TyperContextFactory = Callable[..., typer.Context]


# SECTION: ASSERTIONS ======================================================= #


def assert_emit_json(
    calls: CaptureIo,
    payload: object,
    *,
    pretty: bool,
) -> None:
    """
    Assert that :func:`emit_json` was called once with expected payload.

    Parameters
    ----------
    calls : CaptureIo
        Captured IO calls from the CLI fixtures.
    payload : object
        Expected JSON payload.
    pretty : bool
        Expected pretty-print flag.
    """
    assert calls['emit_json'] == [((payload,), {'pretty': pretty})]


def assert_emit_or_write(
    calls: CaptureIo,
    payload: object,
    target: object,
    *,
    pretty: bool,
) -> dict[str, object]:
    """
    Assert that :func:`emit_or_write` was called once and return kwargs.

    Parameters
    ----------
    calls : CaptureIo
        Captured IO calls from the CLI fixtures.
    payload : object
        Expected payload argument.
    target : object
        Expected output path argument.
    pretty : bool
        Expected pretty-print flag.

    Returns
    -------
    dict[str, object]
        Captured keyword arguments for the call.
    """
    assert len(calls['emit_or_write']) == 1
    args, kwargs = calls['emit_or_write'][0]
    assert args[0] == payload
    assert args[1] == target
    assert kwargs['pretty'] is pretty
    return kwargs


def assert_mapping_contains(
    actual: Mapping[str, object],
    expected: Mapping[str, object],
) -> None:
    """
    Assert that *actual* contains the *expected* key/value pairs.

    Parameters
    ----------
    actual : Mapping[str, object]
        Mapping returned by the handler capture fixture.
    expected : Mapping[str, object]
        Expected key/value pairs that must be present in *actual*.
    """
    for key, value in expected.items():
        assert actual[key] == value


# SECTION: HELPERS ========================================================= #


def _record_calls(
    monkeypatch: pytest.MonkeyPatch,
    module: object,
    *names: str,
) -> CaptureIo:
    calls: CaptureIo = {name: [] for name in names}

    for name in names:

        def _record(
            *args: object,
            _name: str = name,
            **kwargs: object,
        ) -> None:
            calls[_name].append((args, kwargs))

        monkeypatch.setattr(module, name, _record)

    return calls


@dataclass(frozen=True, slots=True)
class DummyCfg:
    """Minimal stand-in pipeline config for CLI helper tests."""

    name: str = 'p1'
    version: str = 'v1'
    sources: list[object] = field(
        default_factory=lambda: [types.SimpleNamespace(name='s1')],
    )
    targets: list[object] = field(
        default_factory=lambda: [types.SimpleNamespace(name='t1')],
    )
    transforms: list[object] = field(
        default_factory=lambda: [types.SimpleNamespace(name='tr1')],
    )
    jobs: list[object] = field(
        default_factory=lambda: [types.SimpleNamespace(name='j1')],
    )


# SECTION: FIXTURES ========================================================= #


@pytest.fixture(name='assert_stdout_contains')
def assert_stdout_contains_fixture(
    capsys: pytest.CaptureFixture[str],
) -> AssertCapturedText:
    """
    Return helper asserting a substring exists in captured STDOUT.

    Parameters
    ----------
    capsys : pytest.CaptureFixture[str]
        Pytest capture fixture.

    Returns
    -------
    AssertCapturedText
        Assertion helper returning captured STDOUT text.
    """

    def _assert(expected: str) -> str:
        captured = capsys.readouterr()
        assert expected in captured.out
        return captured.out

    return _assert


@pytest.fixture(name='assert_stderr_contains')
def assert_stderr_contains_fixture(
    capsys: pytest.CaptureFixture[str],
) -> AssertCapturedText:
    """
    Return helper asserting a substring exists in captured STDERR.

    Parameters
    ----------
    capsys : pytest.CaptureFixture[str]
        Pytest capture fixture.

    Returns
    -------
    AssertCapturedText
        Assertion helper returning captured STDERR text.
    """

    def _assert(expected: str) -> str:
        captured = capsys.readouterr()
        assert expected in captured.err
        return captured.err

    return _assert


@pytest.fixture(name='capture_io')
def capture_io_fixture(monkeypatch: pytest.MonkeyPatch) -> CaptureIo:
    """
    Patch handler functions and capture CLI output.
    Returns a dict with lists of call args for each function.

    Parameters
    ----------
    monkeypatch : pytest.MonkeyPatch
        Pytest monkeypatch fixture.

    Returns
    -------
    CaptureIo
        Mapping of captured IO call arguments by function name.
    """
    import etlplus.cli.io as _io

    return _record_calls(
        monkeypatch,
        _io,
        'emit_or_write',
        'emit_json',
        'print_json',
    )


@pytest.fixture(name='csv_text')
def csv_text_fixture() -> str:
    """Return sample CSV text."""
    return CSV_TEXT


@pytest.fixture(name='dummy_cfg')
def dummy_cfg_fixture() -> Config:
    """Return a minimal dummy pipeline config."""
    return cast(Config, DummyCfg())


@pytest.fixture(name='invoke_cli')
def invoke_cli_fixture(runner: CliRunner) -> Callable[..., Result]:
    """Invoke the Typer CLI with convenience defaults."""

    def _invoke(*args: str) -> Result:
        return runner.invoke(cli_app, list(args))

    return _invoke


@pytest.fixture(name='runner')
def runner_fixture() -> CliRunner:
    """Return a reusable Typer CLI runner."""
    return CliRunner()


@pytest.fixture(name='stub_command')
def stub_command_fixture(
    stub_command_main: StubCommandMain,
) -> Callable[[Callable[..., object]], None]:
    """Install a Typer command stub that delegates to the provided action."""

    def _install(action: Callable[..., object]) -> None:
        stub_command_main(action)

    return _install


@pytest.fixture(name='stub_command_main')
def stub_command_main_fixture(
    monkeypatch: pytest.MonkeyPatch,
) -> StubCommandMain:
    """
    Patch Typer command dispatch and capture low-level ``command.main`` calls.

    Parameters
    ----------
    monkeypatch : pytest.MonkeyPatch
        Pytest monkeypatch fixture.

    Returns
    -------
    StubCommandMain
        Installer that returns captured dispatch keyword arguments.
    """

    def _install(
        action: Callable[..., object] | BaseException,
    ) -> dict[str, object]:
        calls: dict[str, object] = {}

        class _StubCommand:
            """Typer command stub class with patched :meth:`main` method."""

            def main(
                self,
                *,
                args: list[str],
                prog_name: str,
                standalone_mode: bool,
            ) -> object:
                """Stubbed command main method capturing call arguments."""
                calls.update(
                    {
                        'args': args,
                        'prog_name': prog_name,
                        'standalone_mode': standalone_mode,
                    },
                )
                if isinstance(action, BaseException):
                    raise action
                return action(
                    args=args,
                    prog_name=prog_name,
                    standalone_mode=standalone_mode,
                )

        monkeypatch.setattr(
            typer.main,
            'get_command',
            lambda _app: _StubCommand(),
        )
        return calls

    return _install


@pytest.fixture(name='stub_handler')
def stub_handler_fixture(
    monkeypatch: pytest.MonkeyPatch,
) -> StubHandler:
    """
    Patch a handler-like callable and capture keyword arguments.

    Parameters
    ----------
    monkeypatch : pytest.MonkeyPatch
        Pytest monkeypatch fixture.

    Returns
    -------
    StubHandler
        Installer returning a mutable mapping of captured kwargs.
    """

    def _install(
        module: object,
        attr: str,
        *,
        result: object = 0,
    ) -> dict[str, object]:
        calls: dict[str, object] = {}

        def _stub(**kwargs: object) -> object:
            calls.update(kwargs)
            return result

        monkeypatch.setattr(module, attr, _stub)
        return calls

    return _install


@pytest.fixture(name='typer_ctx_factory')
def typer_ctx_factory_fixture() -> TyperContextFactory:
    """
    Return helper creating Typer contexts for command unit tests.

    Returns
    -------
    TyperContextFactory
        Callable creating :class:`typer.Context` with optional state.
    """
    command = typer.main.get_command(cli_app)

    def _make(
        *,
        state: CliState | None = None,
    ) -> typer.Context:
        ctx = typer.Context(command)
        ctx.obj = state or CliState()
        return ctx

    return _make


@pytest.fixture(name='widget_spec_paths')
def widget_spec_paths_fixture(tmp_path: Path) -> tuple[Path, Path]:
    """Return paths for a widget spec and output SQL file."""
    spec = {
        'schema': 'dbo',
        'table': 'Widget',
        'columns': [
            {'name': 'Id', 'type': 'int', 'nullable': False},
            {'name': 'Name', 'type': 'nvarchar(50)', 'nullable': True},
        ],
        'primary_key': {'columns': ['Id']},
    }
    spec_path = tmp_path / 'spec.json'
    out_path = tmp_path / 'out.sql'
    spec_path.write_text(json.dumps(spec))
    return spec_path, out_path
