"""Behavioural tests for assembling Vale ZIPs via the stilyagi CLI."""

from __future__ import annotations

import shutil
import subprocess
import sys
import typing as typ
from pathlib import Path
from zipfile import ZipFile

import pytest
from pytest_bdd import given, scenarios, then, when

FEATURE_PATH = Path(__file__).resolve().parents[2] / "features" / "stilyagi_zip.feature"


class ScenarioState(typ.TypedDict, total=False):
    """Mutable cross-step storage used by pytest-bdd scenarios."""

    project_root: Path
    stdout: str
    archive_path: Path
    expected_styles_path: str
    expected_style_name: str
    expected_vocab: str


scenarios(str(FEATURE_PATH))


def _archive_member(archive_path: Path, relative: str) -> str:
    """Return the path inside the archive for ``relative``."""
    return f"{archive_path.stem}/{relative.lstrip('/')}"


def _create_sample_style(root: Path, *, style_name: str = "concordat") -> None:
    """Create a minimal style tree and vocabulary under *root*."""
    style_dir = root / "styles" / style_name
    style_dir.mkdir(parents=True, exist_ok=True)
    (style_dir / "OxfordComma.yml").write_text("extends: existence\n", encoding="utf-8")

    vocab_dir = root / "styles" / "config" / "vocabularies" / style_name
    vocab_dir.mkdir(parents=True, exist_ok=True)
    (vocab_dir / "accept.txt").write_text("allowlist\n", encoding="utf-8")


def _write_manifest(project_root: Path, *, style_name: str = "concordat") -> None:
    """Write a simple stilyagi manifest for packaging tests."""
    manifest = project_root / "stilyagi.toml"
    manifest.write_text(
        f"""
        [install]
        style_name = "{style_name}"
        vocab = "{style_name}"
        min_alert_level = "warning"
        """.strip()
        + "\n",
        encoding="utf-8",
    )


@pytest.fixture
def repo_root() -> Path:
    """Return the repository root so the CLI can run via python -m."""
    return Path(__file__).resolve().parents[2]


@pytest.fixture
def scenario_state() -> ScenarioState:
    """Provide mutable per-scenario storage across step functions."""
    return {}


@given("a clean staging project containing the styles tree")
def staging_project(
    tmp_path: Path, repo_root: Path, scenario_state: ScenarioState
) -> Path:
    """Create a temporary project with a minimal style and manifest."""
    staging = tmp_path / "staging"
    staging.mkdir()
    style_name = "concordat"
    _create_sample_style(staging, style_name=style_name)
    _write_manifest(staging, style_name=style_name)
    scenario_state["project_root"] = staging
    scenario_state["expected_vocab"] = style_name
    return staging


@given("STILYAGI_ environment variables are set")
def set_stilyagi_env_vars(
    monkeypatch: pytest.MonkeyPatch, scenario_state: ScenarioState
) -> None:
    """Configure CLI overrides that rely on environment variables."""
    project_root = scenario_state["project_root"]
    concordat = project_root / "styles" / "concordat"
    custom_style = project_root / "styles" / "custom_concordat"
    if not custom_style.exists():
        shutil.copytree(concordat, custom_style)

    scenario_state["expected_styles_path"] = "custom_styles"
    scenario_state["expected_style_name"] = "custom_concordat"

    monkeypatch.setenv("STILYAGI_INI_STYLES_PATH", "custom_styles")
    monkeypatch.setenv("STILYAGI_STYLE", "custom_concordat")


@when("I run stilyagi zip for that staging project")
def run_stilyagi_zip(repo_root: Path, scenario_state: ScenarioState) -> None:
    """Invoke the CLI with an explicit version and capture its output."""
    project_root = scenario_state["project_root"]
    dist_dir = project_root / "dist"
    command = [
        sys.executable,
        "-m",
        "stilyagi.stilyagi",
        "zip",
        "--project-root",
        str(project_root),
        "--output-dir",
        str(dist_dir),
        "--archive-version",
        "9.9.9-test",
        "--force",
    ]
    # NOTE: arguments are repository-controlled in tests.
    result = subprocess.run(  # noqa: S603
        command,
        cwd=repo_root,
        check=True,
        capture_output=True,
        text=True,
    )
    stdout_lines = [line for line in result.stdout.splitlines() if line.strip()]
    scenario_state["stdout"] = stdout_lines[-1] if stdout_lines else ""

    produced_archives = sorted((dist_dir).glob("*.zip"))
    assert produced_archives, (
        f"stilyagi zip did not create an archive in {dist_dir}:\n{result.stderr}"
    )
    scenario_state["archive_path"] = produced_archives[-1]


@then("a zip archive is emitted in its dist directory")
def archive_exists(scenario_state: ScenarioState) -> None:
    """Assert that the CLI produced a ZIP artefact in the expected folder."""
    archive_path = scenario_state["archive_path"]
    assert Path(archive_path).exists(), "Expected the zip archive to exist"


@then("the archive includes the concordat content and config")
def archive_has_content(scenario_state: ScenarioState) -> None:
    """Verify that the archive captured both rules and shared config assets."""
    archive_path = scenario_state["archive_path"]
    style_name = scenario_state.get("expected_style_name", "concordat")
    expected_rule = f"{style_name}/OxfordComma.yml"
    with ZipFile(archive_path) as archive:
        names = set(archive.namelist())
        assert any(name.endswith(expected_rule) for name in names), (
            f"Archive missing {expected_rule}"
        )
        assert any("/config/" in name for name in names), (
            "Archive missing shared config"
        )


@then("the archive contains a .vale.ini listing only the core settings")
def archive_has_ini(scenario_state: ScenarioState) -> None:
    """Ensure the generated .vale.ini only declares StylesPath/Vocab."""
    archive_path = scenario_state["archive_path"]
    expected_styles_path = scenario_state.get("expected_styles_path", "styles")
    expected_vocab = scenario_state.get("expected_vocab")
    with ZipFile(archive_path) as archive:
        ini_body = archive.read(_archive_member(archive_path, ".vale.ini")).decode(
            "utf-8"
        )
    assert f"StylesPath = {expected_styles_path}" in ini_body, (
        f"Generated ini should point StylesPath at {expected_styles_path}/"
    )
    assert "BasedOnStyles" not in ini_body, "Generated ini should not set BasedOnStyles"
    if expected_vocab:
        assert f"Vocab = {expected_vocab}" in ini_body, (
            f"Generated ini should reference the {expected_vocab} vocabulary"
        )
    else:
        assert "Vocab =" not in ini_body, "Ini should omit Vocab when none provided"
    assert "[*." not in ini_body, "Generated ini should not include file globs"


@then("the archive includes the stilyagi configuration manifest")
def archive_has_manifest(scenario_state: ScenarioState) -> None:
    """Verify the manifest file is packaged alongside rules and config."""
    archive_path = scenario_state["archive_path"]
    with ZipFile(archive_path) as archive:
        manifest_member = _archive_member(archive_path, "stilyagi.toml")
        names = set(archive.namelist())
        assert manifest_member in names, "Archive should include stilyagi.toml"
        manifest_body = archive.read(manifest_member).decode("utf-8")

    assert "[install]" in manifest_body, "Manifest content should be preserved"


@then("the archive .vale.ini uses the STILYAGI_ environment variable values")
def archive_ini_uses_env_overrides(scenario_state: ScenarioState) -> None:
    """Confirm CLI picks up STILYAGI_ overrides for .vale.ini content."""
    archive_path = scenario_state["archive_path"]
    expected_styles_path = scenario_state["expected_styles_path"]

    with ZipFile(archive_path) as archive:
        ini_body = archive.read(_archive_member(archive_path, ".vale.ini")).decode(
            "utf-8"
        )

    assert f"StylesPath = {expected_styles_path}" in ini_body, (
        f"Expected StylesPath {expected_styles_path}, got {ini_body!r}"
    )
    assert "BasedOnStyles" not in ini_body, (
        "Generated ini should never hard-code BasedOnStyles entries"
    )


@pytest.mark.parametrize(
    "case",
    [
        pytest.param("missing-project", id="missing-project"),
        pytest.param("overwrite", id="overwrite-without-force"),
    ],
)
def test_stilyagi_zip_cli_errors(tmp_path: Path, repo_root: Path, case: str) -> None:
    """Validate CLI error handling for invalid inputs and overwrite attempts."""
    if case == "missing-project":
        project_root = tmp_path / "missing"
        args = ["--project-root", str(project_root)]
        expected_error = "does not exist"
    elif case == "overwrite":
        project_root = tmp_path / "staging"
        project_root.mkdir()
        _create_sample_style(project_root)
        dist_dir = project_root / "dist"
        dist_dir.mkdir()
        expected_version = "9.9.9-test"
        (dist_dir / f"concordat-{expected_version}.zip").write_bytes(b"placeholder")
        args = [
            "--project-root",
            str(project_root),
            "--output-dir",
            str(dist_dir),
            "--archive-version",
            expected_version,
        ]
        expected_error = "already exists"
    else:  # pragma: no cover - defensive fallback
        pytest.fail(f"Unknown case {case}")

    command = [
        sys.executable,
        "-m",
        "stilyagi.stilyagi",
        "zip",
        *args,
    ]
    # NOTE: arguments come from controlled fixtures in tests.
    result = subprocess.run(  # noqa: S603
        command,
        cwd=repo_root,
        capture_output=True,
        text=True,
        check=False,
    )

    assert result.returncode != 0, "CLI should fail for the parametrised error scenario"
    assert expected_error in result.stderr, (
        f"CLI stderr should contain {expected_error!r}"
    )
