import io
import json
from pathlib import Path

import pytest

from valn8n.cli import EXIT_CLEAN, EXIT_ERROR, EXIT_VIOLATIONS, run
from valn8n.cli.args import build_parser
from valn8n.cli.commands import cmd_check_provisioned
from fixtures import workflows_dir
from valn8n_lib.output import (
    CsvFormatter,
    JsonFormatter,
    LstFormatter,
    RichFormatter,
    TextFormatter,
)
from valn8n_lib.output import Formatter as FormatterProtocol
from valn8n_lib.rules import RULE_REGISTRY, register
from valn8n_lib.types import Diagnostic, Location, RuleCode, Severity


@pytest.fixture
def fixture_dir() -> Path:
    return Path(__file__).parent / "fixtures" / "test_cli"


@pytest.fixture
def fixture_file(fixture_dir: Path) -> Path:
    return fixture_dir / "gna.json"


def _sample_results() -> dict[Path, list[Diagnostic]]:
    """Create sample results for formatter tests."""
    return {
        Path("test.json"): [
            Diagnostic(
                file="test.json",
                location=Location(node_name="MyNode", field_path="parameters.value"),
                code=RuleCode("WF0001", Severity.WARNING),
                message="Test warning",
            ),
            Diagnostic(
                file="test.json",
                location=None,
                code=RuleCode("WF0002", Severity.ERROR),
                message="Test error",
            ),
        ]
    }


def _empty_results() -> dict[Path, list[Diagnostic]]:
    return {Path("clean.json"): []}


class TestBuildParser:
    def test_parser_has_check_subcommand(self) -> None:
        parser = build_parser()
        args = parser.parse_args(["check", "some_file.json"])
        assert args.command == "check"
        assert args.paths == ["some_file.json"]

    def test_parser_check_defaults(self) -> None:
        parser = build_parser()
        args = parser.parse_args(["check", "f.json"])
        assert args.output_format == "rich"
        assert args.select is None
        assert args.ignore is None
        assert args.quiet is False

    def test_parser_check_fmt_json(self) -> None:
        parser = build_parser()
        args = parser.parse_args(["check", "--fmt", "json", "f.json"])
        assert args.output_format == "json"

    def test_parser_check_fmt_choices(self) -> None:
        parser = build_parser()
        for fmt in ("rich", "text", "lst", "json", "csv"):
            args = parser.parse_args(["check", "--fmt", fmt, "f.json"])
            assert args.output_format == fmt

    def test_parser_select(self) -> None:
        parser = build_parser()
        args = parser.parse_args(["check", "--select", "WF0001,ND0102", "f.json"])
        assert args.select == {"WF0001", "ND0102"}

    def test_parser_ignore(self) -> None:
        parser = build_parser()
        args = parser.parse_args(["check", "--ignore", "WF0003", "f.json"])
        assert args.ignore == {"WF0003"}

    def test_parser_extend_select(self) -> None:
        parser = build_parser()
        args = parser.parse_args(["check", "--extend-select", "ND0504,ND0501", "f.json"])
        assert args.extend_select == {"ND0504", "ND0501"}

    def test_parser_extend_select_default(self) -> None:
        parser = build_parser()
        args = parser.parse_args(["check", "f.json"])
        assert args.extend_select is None

    def test_parser_suggestions_default(self) -> None:
        parser = build_parser()
        args = parser.parse_args(["check", "f.json"])
        assert args.suggestions == "full"

    def test_parser_suggestions_choices(self) -> None:
        parser = build_parser()
        for level in ("none", "short", "full"):
            args = parser.parse_args(["check", "--suggestions", level, "f.json"])
            assert args.suggestions == level

    def test_parser_tolerate_node_keys_default(self) -> None:
        parser = build_parser()
        args = parser.parse_args(["check", "f.json"])
        assert args.tolerate_node_keys == ""

    def test_parser_tolerate_node_keys_specific(self) -> None:
        parser = build_parser()
        args = parser.parse_args(["check", "--tolerate-node-keys", "meta,stickyNote", "f.json"])
        assert args.tolerate_node_keys == "meta,stickyNote"

    def test_parser_tolerate_node_keys_wildcard(self) -> None:
        parser = build_parser()
        args = parser.parse_args(["check", "--tolerate-node-keys", "*", "f.json"])
        assert args.tolerate_node_keys == "*"


class TestVersion:
    def test_version_output(self, capsys: pytest.CaptureFixture[str]) -> None:
        parser = build_parser()
        with pytest.raises(SystemExit, match="0"):
            parser.parse_args(["--version"])


class TestCheckNoArgs:
    def test_no_command_returns_clean(self) -> None:
        assert run([]) == EXIT_CLEAN


class TestCheckValidPath:
    def test_valid_file_no_rules(self, fixture_file: Path) -> None:
        assert run(["check", str(fixture_file)]) == EXIT_CLEAN

    def test_valid_dir_no_rules(self, fixture_dir: Path) -> None:
        assert run(["check", str(fixture_dir)]) == EXIT_CLEAN


class TestCheckNonexistent:
    def test_nonexistent_path_returns_error(self) -> None:
        assert run(["check", "nonexistent.json"]) == EXIT_ERROR


class TestFormatJson:
    def test_json_output_is_valid(
        self, fixture_file: Path, capsys: pytest.CaptureFixture[str]
    ) -> None:
        run(["check", "--fmt", "json", str(fixture_file)])
        captured = capsys.readouterr()
        parsed = json.loads(captured.out)
        assert isinstance(parsed, list)


class TestSelectIgnore:
    def test_select_filters_rules(self, fixture_file: Path) -> None:
        """With select set to a nonexistent rule, no rules run."""
        assert run(["check", "--select", "X999", str(fixture_file)]) == EXIT_CLEAN

    def test_ignore_filters_rules(self, fixture_file: Path) -> None:
        assert run(["check", "--ignore", "WF0001", str(fixture_file)]) == EXIT_CLEAN

    def test_extend_select_alone_is_noop(self, fixture_file: Path) -> None:
        """--extend-select without --ignore doesn't change results."""
        base = run(["check", str(fixture_file)])
        extended = run(["check", "--extend-select", "ND0504", str(fixture_file)])
        assert base == extended

    def test_extend_select_reinclude_after_prefix_ignore(self, fixture_file: Path) -> None:
        """--ignore ND05 --extend-select ND0504 re-includes ND0504."""
        from valn8n_lib.rules.registry import get_rules

        ignored = get_rules(ignore={"ND05"})
        reincluded = get_rules(ignore={"ND05"}, extend_select={"ND0504"})
        assert "ND0504" not in ignored
        assert "ND0504" in reincluded
        # All other ND05xx should still be ignored
        for code in reincluded:
            if code.startswith("ND05") and code != "ND0504":
                assert False, f"{code} should be ignored"

    def test_extend_select_with_select_and_ignore(self) -> None:
        """--select ND --ignore ND05 --extend-select ND0504 interaction."""
        from valn8n_lib.rules.registry import get_rules

        rules = get_rules(select={"ND"}, ignore={"ND05"}, extend_select={"ND0504"})
        assert "ND0504" in rules
        # Non-ND rules should not be present (select=ND)
        for code in rules:
            assert code.startswith("ND"), f"{code} should not be in select=ND"

    def test_extend_select_with_prefix(self) -> None:
        """--ignore ND --extend-select ND05 re-includes all ND05xx rules."""
        from valn8n_lib.rules.registry import get_rules

        rules = get_rules(ignore={"ND"}, extend_select={"ND05"})
        for code in rules:
            assert code.startswith("ND05"), f"unexpected {code}"
        # Should have at least one ND05 rule
        assert any(code.startswith("ND05") for code in rules)


class TestQuiet:
    def test_quiet_suppresses_detail(
        self, fixture_file: Path, capsys: pytest.CaptureFixture[str]
    ) -> None:
        run(["check", "--fmt", "text", "-q", str(fixture_file)])
        captured = capsys.readouterr()
        assert "All checks passed" not in captured.out


class TestWithRule:
    """Test that a registered rule produces violations and correct exit code."""

    def test_rule_produces_violations(self, fixture_file: Path) -> None:
        code = "_TEST001"

        @register(code, Severity.WARNING)
        def _test_rule(
            workflow: dict,
            path: Path,  # type: ignore[type-arg]
            source_map: object = None,
            suggestions: object = None,
        ) -> list[Diagnostic]:
            return [
                Diagnostic(
                    file=str(path),
                    location=Location(node_name="TestNode", field_path="test.field"),
                    code=RuleCode("WF0001", Severity.WARNING),
                    message="Test warning",
                )
            ]

        try:
            result = run(["check", "--fmt", "text", str(fixture_file)])
            assert result == EXIT_VIOLATIONS
        finally:
            RULE_REGISTRY.pop(code, None)


class TestFormatterProtocol:
    """All five formatters satisfy the Formatter protocol."""

    @pytest.mark.parametrize(
        "cls",
        [RichFormatter, TextFormatter, LstFormatter, JsonFormatter, CsvFormatter],
    )
    def test_is_formatter(self, cls: type) -> None:
        assert isinstance(cls(), FormatterProtocol)


class TestCmdCheckProvisioned:
    """Test cmd_check_provisioned with injected formatter."""

    def test_clean_file_returns_clean(self, fixture_file: Path) -> None:
        formatter = TextFormatter()
        result = cmd_check_provisioned([fixture_file], formatter=formatter)
        assert result == EXIT_CLEAN

    def test_missing_path_returns_error(self) -> None:
        formatter = TextFormatter()
        result = cmd_check_provisioned([Path("nonexistent.json")], formatter=formatter)
        assert result == EXIT_ERROR

    def test_violations_returns_violations(self, fixture_file: Path) -> None:
        code = "_TEST_PROV"

        @register(code, Severity.ERROR)
        def _test_rule(
            workflow: dict,
            path: Path,  # type: ignore[type-arg]
            source_map: object = None,
            suggestions: object = None,
        ) -> list[Diagnostic]:
            return [
                Diagnostic(
                    file=str(path),
                    location=None,
                    code=RuleCode("WF0002", Severity.ERROR),
                    message="Error",
                )
            ]

        try:
            formatter = TextFormatter()
            result = cmd_check_provisioned([fixture_file], formatter=formatter)
            assert result == EXIT_VIOLATIONS
        finally:
            RULE_REGISTRY.pop(code, None)


class TestCmdCheckProvisionedTolerateKeys:
    """Test cmd_check_provisioned with tolerate_node_keys."""

    def test_tolerate_suppresses_nd0003(self) -> None:
        tolerate_dir = workflows_dir() / "test_tolerate_node_keys"
        formatter = TextFormatter()
        result = cmd_check_provisioned(
            [tolerate_dir],
            formatter=formatter,
            select={"ND0201"},
            tolerate_node_keys=frozenset({"meta", "stickyNote"}),
        )
        assert result == EXIT_CLEAN


class TestStrictOnly:
    def test_parser_strict_only_default(self) -> None:
        parser = build_parser()
        args = parser.parse_args(["check", "f.json"])
        assert args.strict_only is False

    def test_parser_strict_only_flag(self) -> None:
        parser = build_parser()
        args = parser.parse_args(["check", "--strict-only", "f.json"])
        assert args.strict_only is True

    def test_get_rules_strict_only_returns_only_strict(self) -> None:
        """get_rules(strict_only=True) returns only strict rules."""
        from valn8n_lib.rules.registry import get_rules

        rules = get_rules(strict_only=True)
        for code, (rc, _) in rules.items():
            assert rc.strict is True, f"{code} should be strict"
        # Should have at least some strict rules
        assert len(rules) > 0

    def test_get_rules_strict_only_excludes_non_strict(self) -> None:
        """get_rules(strict_only=True) excludes non-strict rules like ND05xx-ND08xx."""
        from valn8n_lib.rules.registry import get_rules

        rules = get_rules(strict_only=True)
        for code in rules:
            prefix = code[:4]
            assert prefix not in (
                "ND05",
                "ND06",
                "ND07",
                "ND08",
            ), f"{code} should not be in strict-only results"

    def test_get_rules_strict_only_with_ignore(self) -> None:
        """get_rules(strict_only=True, ignore={"ND04"}) excludes ND04xx."""
        from valn8n_lib.rules.registry import get_rules

        rules = get_rules(strict_only=True, ignore={"ND04"})
        for code in rules:
            assert not code.startswith("ND04"), f"{code} should be ignored"
        assert len(rules) > 0

    def test_strict_only_suppresses_non_strict_violations(self) -> None:
        """Integration: --strict-only suppresses non-strict violations."""
        suspect = workflows_dir() / "test_suspect_expression" / "suspect1.json"
        # ND0402 is strict, so it should still be reported with --strict-only
        result = run(["check", "--strict-only", "--fmt", "lst", "--select", "ND0402", str(suspect)])
        assert result == EXIT_VIOLATIONS


class TestRuleSubcommand:
    def test_rule_returns_clean(self) -> None:
        assert run(["rule"]) == EXIT_CLEAN

    def test_rule_output_contains_groups(self, capsys: pytest.CaptureFixture[str]) -> None:
        run(["rule"])
        output = capsys.readouterr().out
        assert "File I/O validation (IO00xx):" in output
        assert "Filter operations (ND01xx):" in output
        assert "Node structure/keys (ND02xx):" in output
        assert "Parameter validation (ND03xx):" in output
        assert "Expression syntax (ND04xx):" in output
        assert "Credential validation (ND05xx):" in output

    def test_rule_output_contains_io_codes(self, capsys: pytest.CaptureFixture[str]) -> None:
        run(["rule"])
        output = capsys.readouterr().out
        for code in ("IO0001", "IO0002", "IO0010"):
            assert code in output

    def test_rule_output_contains_codes(self, capsys: pytest.CaptureFixture[str]) -> None:
        run(["rule"])
        output = capsys.readouterr().out
        for code in ("ND0101", "ND0201", "ND0301", "ND0401", "ND0501"):
            assert code in output

    def test_rule_output_contains_descriptions(self, capsys: pytest.CaptureFixture[str]) -> None:
        run(["rule"])
        output = capsys.readouterr().out
        assert "Invalid filter operation for type" in output
        assert "Unknown node key" in output

    def test_rule_output_contains_strict_column(self, capsys: pytest.CaptureFixture[str]) -> None:
        run(["rule"])
        output = capsys.readouterr().out
        assert "strict" in output

    def test_parser_has_rule_subcommand(self) -> None:
        parser = build_parser()
        args = parser.parse_args(["rule"])
        assert args.command == "rule"


class TestTextFormatter:
    def test_renders_diagnostics(self) -> None:
        stream = io.StringIO()
        TextFormatter().render(_sample_results(), stream=stream)
        output = stream.getvalue()
        assert "WF0001" in output
        assert "WF0002" in output
        assert "warning:" in output
        assert "error:" in output
        assert "Found 2 issues" in output

    def test_quiet_hides_details(self) -> None:
        stream = io.StringIO()
        TextFormatter().render(_sample_results(), quiet=True, stream=stream)
        output = stream.getvalue()
        assert "Test warning" not in output
        assert "Found 2 issues" in output

    def test_clean_output(self) -> None:
        stream = io.StringIO()
        TextFormatter().render(_empty_results(), stream=stream)
        assert "All checks passed" in stream.getvalue()


class TestRichFormatter:
    def test_renders_diagnostics(self) -> None:
        stream = io.StringIO()
        RichFormatter().render(_sample_results(), stream=stream)
        output = stream.getvalue()
        assert "WF0001" in output
        assert "WF0002" in output
        assert "Found 2 issues" in output

    def test_quiet_hides_table(self) -> None:
        stream = io.StringIO()
        RichFormatter().render(_sample_results(), quiet=True, stream=stream)
        output = stream.getvalue()
        assert "MyNode" not in output
        assert "Found 2 issues" in output

    def test_clean_output(self) -> None:
        stream = io.StringIO()
        RichFormatter().render(_empty_results(), stream=stream)
        assert "All checks passed" in stream.getvalue()


class TestLstFormatter:
    def test_outputs_codes_only(self) -> None:
        stream = io.StringIO()
        LstFormatter().render(_sample_results(), stream=stream)
        lines = stream.getvalue().strip().split("\n")
        assert lines == ["WF0001", "WF0002"]

    def test_ignores_quiet(self) -> None:
        stream = io.StringIO()
        LstFormatter().render(_sample_results(), quiet=True, stream=stream)
        lines = stream.getvalue().strip().split("\n")
        assert lines == ["WF0001", "WF0002"]

    def test_empty_results(self) -> None:
        stream = io.StringIO()
        LstFormatter().render(_empty_results(), stream=stream)
        assert stream.getvalue() == ""


class TestJsonFormatter:
    def test_outputs_valid_json(self) -> None:
        stream = io.StringIO()
        JsonFormatter().render(_sample_results(), stream=stream)
        parsed = json.loads(stream.getvalue())
        assert len(parsed) == 2
        assert parsed[0]["code"] == "WF0001"
        assert parsed[0]["node_name"] == "MyNode"
        assert parsed[1]["code"] == "WF0002"
        assert "node_name" not in parsed[1]

    def test_empty_results(self) -> None:
        stream = io.StringIO()
        JsonFormatter().render(_empty_results(), stream=stream)
        assert json.loads(stream.getvalue()) == []


class TestCsvFormatter:
    def test_outputs_csv_with_headers(self) -> None:
        stream = io.StringIO()
        CsvFormatter().render(_sample_results(), stream=stream)
        lines = stream.getvalue().strip().splitlines()
        assert lines[0].strip() == "file,code,severity,message,node_name,field_path,line"
        assert len(lines) == 3  # header + 2 rows

    def test_csv_row_content(self) -> None:
        stream = io.StringIO()
        CsvFormatter().render(_sample_results(), stream=stream)
        lines = stream.getvalue().strip().split("\n")
        assert "WF0001" in lines[1]
        assert "MyNode" in lines[1]

    def test_empty_results(self) -> None:
        stream = io.StringIO()
        CsvFormatter().render(_empty_results(), stream=stream)
        lines = stream.getvalue().strip().split("\n")
        assert len(lines) == 1  # header only
