"""Tests for ``razin init`` command behavior and helpers."""

from __future__ import annotations

import json
from pathlib import Path
from unittest.mock import patch

import pytest

from razin.cli.init_flow import parse_csv_domains, render_init_yaml
from razin.cli.main import build_parser, main
from razin.types.init_config import InitConfigDraft


def _write_findings(path: Path, findings: list[dict[str, object]]) -> None:
    """Write a minimal findings.json payload for init-from-scan tests."""
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(findings, indent=2), encoding="utf-8")


def test_build_parser_accepts_init_flags(tmp_path: Path) -> None:
    """`init` parser accepts root/config/yes/dry-run flags."""
    parser = build_parser()
    args = parser.parse_args(
        [
            "init",
            "--root",
            str(tmp_path),
            "--config",
            str(tmp_path / "custom.yaml"),
            "--yes",
            "--dry-run",
            "--from-scan",
            str(tmp_path / "output"),
        ]
    )
    assert args.command == "init"
    assert args.root == tmp_path
    assert args.config == tmp_path / "custom.yaml"
    assert args.yes is True
    assert args.dry_run is True
    assert args.from_scan == tmp_path / "output"


def test_build_parser_init_defaults() -> None:
    """`init` parser defaults root to cwd and prompt flags to false."""
    parser = build_parser()
    args = parser.parse_args(["init"])
    assert args.root == Path(".")
    assert args.config is None
    assert args.yes is False
    assert args.dry_run is False
    assert args.from_scan is None


@pytest.mark.parametrize(
    ("raw", "expected"),
    [
        pytest.param(
            "Example.com, docs.example.com,example.com",
            ("docs.example.com", "example.com"),
            id="normalize-sort-dedupe",
        ),
        pytest.param("   ", (), id="blank-input"),
        pytest.param("api.openai.com", ("api.openai.com",), id="single-domain"),
    ],
)
def test_parse_csv_domains(raw: str, expected: tuple[str, ...]) -> None:
    """CSV domain parsing normalizes, deduplicates, and sorts values."""
    assert parse_csv_domains(raw) == expected


def test_render_init_yaml_sorts_list_values() -> None:
    """Rendered YAML list fields are always deterministic and sorted."""
    rendered = render_init_yaml(
        InitConfigDraft(
            allowlist_domains=("z.example.com", "a.example.com", "z.example.com"),
            mcp_allowlist_domains=("mcp.z.com", "mcp.a.com"),
        )
    )
    assert "allowlist_domains:\n  - a.example.com\n  - z.example.com\n" in rendered
    assert "mcp_allowlist_domains:\n  - mcp.a.com\n  - mcp.z.com\n" in rendered


def test_main_init_yes_writes_valid_config(tmp_path: Path) -> None:
    """`init --yes` writes a default config that passes validate-config."""
    code = main(["init", "--root", str(tmp_path), "--yes"])
    assert code == 0
    config_path = tmp_path / "razin.yaml"
    assert config_path.exists()
    assert main(["validate-config", "--root", str(tmp_path)]) == 0


@patch("builtins.input", side_effect=AssertionError("input must not be called in --yes mode"))
def test_main_init_yes_overwrites_without_prompt(mock_input, tmp_path: Path) -> None:  # type: ignore[no-untyped-def]
    """`init --yes` overwrites existing config without prompting."""
    config_path = tmp_path / "razin.yaml"
    config_path.write_text("profile: strict\n", encoding="utf-8")
    code = main(["init", "--root", str(tmp_path), "--yes"])
    assert code == 0
    assert "# Generated by razin init" in config_path.read_text(encoding="utf-8")
    assert mock_input.called is False


def test_main_init_yes_dry_run_writes_nothing(tmp_path: Path, capsys) -> None:  # type: ignore[no-untyped-def]
    """`init --yes --dry-run` prints config and does not write files."""
    code = main(["init", "--root", str(tmp_path), "--yes", "--dry-run"])
    captured = capsys.readouterr()
    assert code == 0
    assert "# Generated by razin init" in captured.out
    assert "profile: balanced" in captured.out
    assert not (tmp_path / "razin.yaml").exists()


@patch(
    "builtins.input",
    side_effect=[
        "strict",
        "b.com, a.com",
        "mcp.b.com,mcp.a.com",
        "bad.example",
        "bad-mcp.example",
        "y",
        "y",
        "7",
        "y",
    ],
)
def test_main_init_interactive_writes_selected_values(mock_input, tmp_path: Path) -> None:  # type: ignore[no-untyped-def]
    """Interactive `init` writes the prompt-selected configuration values."""
    code = main(["init", "--root", str(tmp_path)])
    assert code == 0
    content = (tmp_path / "razin.yaml").read_text(encoding="utf-8")
    assert "profile: strict" in content
    assert "allowlist_domains:\n  - a.com\n  - b.com\n" in content
    assert "mcp_allowlist_domains:\n  - mcp.a.com\n  - mcp.b.com\n" in content
    assert "denylist_domains:\n  - bad.example\n" in content
    assert "mcp_denylist_domains:\n  - bad-mcp.example\n" in content
    assert "strict_subdomains: true" in content
    assert "ignore_default_allowlist: true" in content
    assert "max_file_mb: 7" in content
    assert mock_input.called


@patch(
    "builtins.input",
    side_effect=[
        "",
        "",
        "",
        "",
        "",
        "n",
        "n",
        "",
        "n",
    ],
)
def test_main_init_declines_overwrite_keeps_existing(mock_input, tmp_path: Path) -> None:  # type: ignore[no-untyped-def]
    """Declining overwrite keeps the existing config unchanged."""
    config_path = tmp_path / "razin.yaml"
    config_path.write_text("profile: strict\n", encoding="utf-8")
    code = main(["init", "--root", str(tmp_path)])
    assert code == 0
    assert config_path.read_text(encoding="utf-8") == "profile: strict\n"
    assert mock_input.called


@patch(
    "builtins.input",
    side_effect=[
        "",
        "",
        "",
        "",
        "",
        "n",
        "n",
        "",
        "y",
    ],
)
def test_main_init_confirms_overwrite_replaces_existing(mock_input, tmp_path: Path) -> None:  # type: ignore[no-untyped-def]
    """Confirming overwrite replaces existing config with generated content."""
    config_path = tmp_path / "razin.yaml"
    config_path.write_text("profile: strict\n", encoding="utf-8")
    code = main(["init", "--root", str(tmp_path)])
    assert code == 0
    updated = config_path.read_text(encoding="utf-8")
    assert updated != "profile: strict\n"
    assert "# Generated by razin init" in updated
    assert mock_input.called


def test_main_init_rejects_missing_root(tmp_path: Path, capsys) -> None:  # type: ignore[no-untyped-def]
    """`init` returns config error when root directory does not exist."""
    missing_root = tmp_path / "missing-root"
    code = main(["init", "--root", str(missing_root), "--yes"])
    captured = capsys.readouterr()
    assert code == 2
    assert "root directory does not exist" in captured.err


def test_main_init_from_scan_yes_writes_commented_candidates(
    tmp_path: Path,
    capsys: pytest.CaptureFixture[str],
) -> None:
    """`init --from-scan --yes` writes commented candidates with occurrence counts."""
    output_dir = tmp_path / "output"
    _write_findings(
        output_dir / "skill-a" / "findings.json",
        [
            {
                "rule_id": "NET_DOC_DOMAIN",
                "description": "Documentation references external domain 'composio.dev'.",
                "evidence": {"snippet": "Read docs at https://composio.dev/docs"},
            },
            {
                "rule_id": "NET_DOC_DOMAIN",
                "description": "Documentation references external domain 'other.dev'.",
                "evidence": {"snippet": "Read docs at https://composio.dev/api"},
            },
            {
                "rule_id": "NET_DOC_DOMAIN",
                "description": "Documentation references external domain 'other.dev'.",
                "evidence": {"snippet": "Read docs at https://other.dev/guide"},
            },
            {
                "rule_id": "MCP_ENDPOINT",
                "description": "Documentation references MCP endpoint 'https://rube.app/mcp'.",
                "evidence": {"snippet": "Connect to https://rube.app/mcp"},
            },
            {
                "rule_id": "MCP_ENDPOINT",
                "description": "Documentation references MCP endpoint 'https://rube.app/mcp'.",
                "evidence": {"snippet": "Endpoint https://rube.app/mcp"},
            },
        ],
    )

    code = main(
        [
            "init",
            "--root",
            str(tmp_path),
            "--from-scan",
            str(output_dir),
            "--yes",
        ]
    )
    assert code == 0
    captured = capsys.readouterr()
    assert "Analyzing scan output from" in captured.err
    assert "Found 1 findings files; parsed 1." in captured.err
    assert "Matched findings: NET_DOC_DOMAIN=3, MCP_ENDPOINT=2." in captured.err
    assert "Generated candidate domains: allowlist_domains=2, mcp_allowlist_domains=1." in captured.err
    content = (tmp_path / "razin.yaml").read_text(encoding="utf-8")
    assert "# Generated by razin init --from-scan" in content
    assert "allowlist_domains:" in content
    assert "  # - composio.dev  # appeared in 2 NET_DOC_DOMAIN findings" in content
    assert "  # - other.dev  # appeared in 1 NET_DOC_DOMAIN findings" in content
    assert "mcp_allowlist_domains:" in content
    assert "  # - rube.app  # appeared in 2 MCP_ENDPOINT findings" in content
    assert main(["validate-config", "--root", str(tmp_path)]) == 0


def test_main_init_from_scan_dry_run_writes_nothing(tmp_path: Path, capsys) -> None:  # type: ignore[no-untyped-def]
    """`init --from-scan --dry-run` prints candidates and does not write file."""
    output_dir = tmp_path / "output"
    _write_findings(
        output_dir / "skill-a" / "findings.json",
        [
            {
                "rule_id": "NET_DOC_DOMAIN",
                "description": "Documentation references external domain 'composio.dev'.",
                "evidence": {"snippet": "Read docs at https://composio.dev/docs"},
            }
        ],
    )
    code = main(
        [
            "init",
            "--root",
            str(tmp_path),
            "--from-scan",
            str(output_dir),
            "--yes",
            "--dry-run",
        ]
    )
    captured = capsys.readouterr()
    assert code == 0
    assert "# Generated by razin init --from-scan" in captured.out
    assert "  # - composio.dev  # appeared in 1 NET_DOC_DOMAIN findings" in captured.out
    assert not (tmp_path / "razin.yaml").exists()


def test_main_init_from_scan_rejects_missing_output_dir(tmp_path: Path, capsys) -> None:  # type: ignore[no-untyped-def]
    """`init --from-scan` returns config error when output directory does not exist."""
    missing = tmp_path / "missing-output"
    code = main(
        [
            "init",
            "--root",
            str(tmp_path),
            "--from-scan",
            str(missing),
            "--yes",
        ]
    )
    captured = capsys.readouterr()
    assert code == 2
    assert "scan output directory does not exist" in captured.err


def test_main_init_from_scan_logs_no_candidate_message(
    tmp_path: Path,
    capsys: pytest.CaptureFixture[str],
) -> None:
    """`init --from-scan` logs explicit no-candidate status when none are found."""
    output_dir = tmp_path / "output"
    _write_findings(
        output_dir / "skill-a" / "findings.json",
        [
            {
                "rule_id": "SECRET_REF",
                "description": "Unrelated rule.",
                "evidence": {"snippet": "token: ${API_TOKEN}"},
            }
        ],
    )
    code = main(
        [
            "init",
            "--root",
            str(tmp_path),
            "--from-scan",
            str(output_dir),
            "--yes",
            "--dry-run",
        ]
    )
    captured = capsys.readouterr()
    assert code == 0
    assert "Generated candidate domains: allowlist_domains=0, mcp_allowlist_domains=0." in captured.err
    assert "No domain candidates were discovered from NET_DOC_DOMAIN or MCP_ENDPOINT findings." in captured.err
