from __future__ import annotations

import textwrap
from pathlib import Path
from typing import Any, Callable

import click
import pytest
import yaml
from click.testing import CliRunner

from chik.util.virtual_project_analysis import (
    Annotation,
    ChikFile,
    Config,
    DirectoryParameters,
    File,
    Package,
    build_dependency_graph,
    build_virtual_dependency_graph,
    cli,
    config,
    find_cycles,
    parse_file_or_package,
)


@pytest.mark.parametrize(
    "file_string, expected, annotated",
    [
        ("# Package: example\n# Some other comment", "example", True),
        ("# Some comment\n# Some other comment", "chik-blockchain", False),
    ],
)
def test_parse_annotation(file_string: str, expected: str, annotated: bool) -> None:
    """
    Test that parse returns an Annotation instance for a valid annotation or
    raises ValueError for an invalid one.
    """
    annotation = Annotation.parse(file_string)
    assert isinstance(annotation, Annotation)
    assert annotation.package == expected
    assert annotation.is_annotated == annotated


# Temporary directory fixture to create test files
@pytest.fixture
def create_test_file(tmp_path: Path) -> Callable[[str, str], Path]:
    def _create_test_file(name: str, content: str) -> Path:
        file_path = tmp_path / name
        file_path.write_text(content, encoding="utf-8")
        return file_path

    return _create_test_file


def test_parse_with_annotation(create_test_file: Callable[[str, str], Path]) -> None:
    """Test parsing a file that contains a valid annotation."""
    file_content = "# Package: test_package\n# Some other comment"
    test_file = create_test_file("annotated_file.txt", file_content)

    parsed_file = ChikFile.parse(test_file)

    assert parsed_file.path == test_file
    assert isinstance(parsed_file.annotations, Annotation)
    assert parsed_file.annotations.package == "test_package"


def test_parse_without_annotation(create_test_file: Callable[[str, str], Path]) -> None:
    """Test parsing a file that does not contain any annotations."""
    file_content = "# Some comment\n# Some other comment"
    test_file = create_test_file("non_annotated_file.txt", file_content)

    parsed_file = ChikFile.parse(test_file)

    assert parsed_file.path == test_file
    assert not parsed_file.annotations.is_annotated


# This test is optional and can be adapted based on expected behavior for non-existent files
def test_parse_nonexistent_file() -> None:
    """Test attempting to parse a non-existent file."""
    with pytest.raises(FileNotFoundError):
        ChikFile.parse(Path("/path/to/nonexistent/file.txt"))


# Helper function to create a non-empty Python file
def create_python_file(dir_path: Path, name: str, content: str) -> Path:
    file_path = dir_path / name
    file_path.write_text(content, encoding="utf-8")
    return file_path


# Helper function to create an empty Python file
def create_empty_python_file(dir_path: Path, name: str) -> Path:
    file_path = dir_path / name
    file_path.touch()
    return file_path


def test_gather_non_empty_python_files(tmp_path: Path) -> None:
    # Set up directory structure
    dir_path = tmp_path / "test_dir"
    dir_path.mkdir()
    excluded_dir = tmp_path / "excluded_dir"
    excluded_dir.mkdir()

    # Create test files
    non_empty_file = create_python_file(dir_path, "non_empty.py", "print('Hello World')")
    create_empty_python_file(dir_path, "empty.py")
    create_python_file(excluded_dir, "excluded.py", "print('Hello World')")

    # Initialize DirectoryParameters with excluded paths
    dir_params = DirectoryParameters(dir_path=dir_path, excluded_paths=[excluded_dir])

    # Perform the test
    python_files = dir_params.gather_non_empty_python_files()

    # Assertions
    assert len(python_files) == 1  # Only one non-empty Python file should be found
    assert python_files[0].path == non_empty_file  # The path of the gathered file should match the non-empty file


def test_gather_with_nested_directories_and_exclusions(tmp_path: Path) -> None:
    # Set up directory structure
    base_dir = tmp_path / "base_dir"
    base_dir.mkdir()
    nested_dir = base_dir / "nested_dir"
    nested_dir.mkdir()
    excluded_dir = base_dir / "excluded_dir"
    excluded_dir.mkdir()

    # Create test files
    nested_file = create_python_file(nested_dir, "nested.py", "print('Hello World')")
    create_empty_python_file(nested_dir, "nested_empty.py")
    create_python_file(excluded_dir, "excluded.py", "print('Hello World')")

    # Initialize DirectoryParameters without excluded paths
    dir_params = DirectoryParameters(dir_path=base_dir, excluded_paths=[excluded_dir])

    # Perform the test
    python_files = dir_params.gather_non_empty_python_files()

    # Assertions
    assert len(python_files) == 1  # Only the non-empty Python file in the nested directory should be found
    assert python_files[0].path == nested_file  # The path of the gathered file should match the nested non-empty file


def test_find_missing_annotations(tmp_path: Path) -> None:
    # Set up directory structure
    dir_path = tmp_path / "test_dir"
    dir_path.mkdir()

    # Create test files
    create_python_file(dir_path, "non_empty.py", "print('Hello World')")

    # Run the command
    runner = CliRunner()
    result = runner.invoke(cli, ["find_missing_annotations", "--directory", str(dir_path)])
    assert result.output == f"{dir_path / 'non_empty.py'}\n"

    # Rewrite file to have annotation
    create_python_file(dir_path, "non_empty.py", "# Package: misc\n")

    # Run the command again with no results
    result = runner.invoke(cli, ["find_missing_annotations", "--directory", str(dir_path)])
    assert result.output == ""


def test_parse_file_or_package() -> None:
    assert parse_file_or_package("example.py") == File(Path("example.py"))
    assert parse_file_or_package("example.py (extra info)") == File(Path("example.py"))
    assert parse_file_or_package("(package_name)") == Package("package_name")
    assert parse_file_or_package("package_name") == Package("package_name")
    assert parse_file_or_package("package_name(") == Package("package_name(")
    assert parse_file_or_package("(package_name") == Package("(package_name")
    assert parse_file_or_package("package_name)") == Package("package_name)")


@pytest.fixture
def chik_package_structure(tmp_path: Path) -> Path:
    base_dir = tmp_path / "chik_project"
    base_dir.mkdir()
    chik_dir = base_dir / "chik"
    chik_dir.mkdir()

    # Create some files within the chik package
    create_python_file(chik_dir, "module1.py", "def func1(): pass")
    create_python_file(chik_dir, "module2.py", "def func2(): pass\nfrom chik.module1 import func1")
    create_python_file(chik_dir, "module3.py", "def func3(): pass\nimport chik.module2")

    return chik_dir


def test_build_dependency_graph(chik_package_structure: Path) -> None:
    chik_dir = chik_package_structure
    dir_params = DirectoryParameters(dir_path=chik_dir)
    graph = build_dependency_graph(dir_params)
    assert chik_dir / "module1.py" in graph
    assert chik_dir / "module2.py" in graph
    assert chik_dir / "module3.py" in graph
    assert chik_dir / "module1.py" in graph[chik_dir / "module2.py"]
    assert chik_dir / "module2.py" in graph[chik_dir / "module3.py"]


def test_print_dependency_graph(chik_package_structure: Path) -> None:
    # Run the command
    runner = CliRunner()
    result = runner.invoke(cli, ["print_dependency_graph", "--directory", str(chik_package_structure)])
    assert "module1.py" in result.output
    assert "module2.py" in result.output
    assert "module3.py" in result.output


# Mock the build_dependency_graph function to control its output
def mock_build_dependency_graph(dir_params: DirectoryParameters) -> dict[Path, list[Path]]:
    return {
        Path("/path/to/package1/module1.py"): [
            Path("/path/to/package2/module2.py"),
            Path("/path/to/package3/module3.py"),
        ],
        Path("/path/to/package2/module2.py"): [],
        Path("/path/to/package3/module3.py"): [Path("/path/to/package2/module2.py")],
    }


# Helper function to simulate ChikFile.parse for testing
def mock_chik_file_parse(path: Path) -> ChikFile:
    annotations_map = {
        Path("/path/to/package1/module1.py"): Annotation("Package1", True),
        Path("/path/to/package2/module2.py"): Annotation("Package2", True),
        Path("/path/to/package3/module3.py"): Annotation("Package3", True),
    }
    return ChikFile(path=Path(path), annotations=annotations_map.get(path, Annotation("chik-blockchain", False)))


@pytest.fixture
def prepare_mocks(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setattr("chik.util.virtual_project_analysis.build_dependency_graph", mock_build_dependency_graph)
    monkeypatch.setattr("chik.util.virtual_project_analysis.ChikFile.parse", mock_chik_file_parse)


def test_build_virtual_dependency_graph(prepare_mocks: None) -> None:
    dir_params = DirectoryParameters(dir_path=Path("/path/to/package1"))
    virtual_graph = build_virtual_dependency_graph(dir_params)

    assert "Package2" in virtual_graph["Package1"]
    assert "Package3" in virtual_graph["Package1"]
    assert virtual_graph["Package2"] == []
    assert "Package2" in virtual_graph["Package3"]


def test_print_virtual_dependency_graph(tmp_path: Path) -> None:
    chik_dir = tmp_path / "chik"
    chik_dir.mkdir()

    # Create some files within the chik package
    create_python_file(chik_dir, "module1.py", "# Package: one\ndef func1(): pass")
    create_python_file(chik_dir, "module2.py", "# Package: two\ndef func2(): pass\nfrom chik.module1 import func1")
    create_python_file(chik_dir, "module3.py", "# Package: three\ndef func3(): pass\nimport chik.module2")

    # Run the command
    runner = CliRunner()
    result = runner.invoke(cli, ["print_virtual_dependency_graph", "--directory", str(chik_dir)])
    assert "one" in result.output
    assert "two" in result.output
    assert "three" in result.output


# Helper function to simulate ChikFile.parse for testing
def mock_chik_file_parse2(path: Path) -> ChikFile:
    annotations_map = {
        Path("/path/to/package1/module1.py"): Annotation("Package1", True),
        Path("/path/to/package2/module2.py"): Annotation("Package2", True),
        Path("/path/to/package3/module3.py"): Annotation("Package1", True),
    }
    return ChikFile(path=Path(path), annotations=annotations_map.get(path, Annotation("chik-blockchain", False)))


@pytest.fixture
def prepare_mocks2(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setattr("chik.util.virtual_project_analysis.ChikFile.parse", mock_chik_file_parse2)


def test_cycle_detection(prepare_mocks2: None) -> None:
    # Example graph with a simple cycle
    graph: dict[Path, list[Path]] = {
        Path("/path/to/package1/module1.py"): [Path("/path/to/package2/module2.py")],
        Path("/path/to/package2/module2.py"): [Path("/path/to/package3/module3.py")],  # Cycle here
        Path("/path/to/package3/module3.py"): [],
    }
    cycles = find_cycles(
        graph,
        build_virtual_dependency_graph(DirectoryParameters(dir_path=Path("path")), existing_graph=graph),
        excluded_paths=[],
        ignore_cycles_in=[],
        ignore_specific_files=[],
        ignore_specific_edges=[],
    )
    # \path\to\package1\module1.py (Package1) -> \path\to\package2\module2.py (Package2) -> (Package1)
    # \path\to\package2\module2.py (Package2) -> \path\to\package3\module3.py (Package1) -> (Package2)
    assert len(cycles) == 2


def test_print_cycles(tmp_path: Path) -> None:
    chik_dir = tmp_path / "chik"
    chik_dir.mkdir()

    # Create some files within the chik package
    create_python_file(chik_dir, "module1.py", "# Package: one\ndef func1(): pass\nfrom chik.module2 import func2")
    create_python_file(chik_dir, "module2.py", "# Package: two\ndef func2(): pass\nfrom chik.module3 import func3")
    create_python_file(chik_dir, "module3.py", "# Package: one\ndef func3(): pass\n")

    # Run the command
    runner = CliRunner()
    result = runner.invoke(cli, ["print_cycles", "--directory", str(chik_dir)])
    assert "module1.py (one) -> " in result.output


def test_check_config(tmp_path: Path) -> None:
    chik_dir = tmp_path / "chik"
    chik_dir.mkdir()

    # Create some files within the chik package
    create_python_file(
        chik_dir,
        "module1.py",
        textwrap.dedent(
            """
            # Package: one
            def func1(): pass
            from chik.module2 import func2
            """
        ),
    )
    create_python_file(
        chik_dir,
        "module1b.py",
        textwrap.dedent(
            """
            # Package: one
            def func1b(): pass
            """
        ),
    )
    create_python_file(
        chik_dir,
        "module2.py",
        textwrap.dedent(
            """
            # Package: two
            def func2(): pass
            from chik.module3 import func3
            from chik.module1b import func1b
            """
        ),
    )
    create_python_file(
        chik_dir,
        "module3.py",
        textwrap.dedent(
            """
            # Package: three
            def func3(): pass
            """
        ),
    )

    # Run the command
    runner = CliRunner()
    result = runner.invoke(
        cli,
        [
            "check_config",
            "--directory",
            str(chik_dir),
            "--ignore-cycles-in",
            "three",
            "--ignore-specific-file",
            str(chik_dir / "module3.py"),
            "--ignore-specific-edge",
            str(chik_dir / "module2.py") + " -> " + str(chik_dir / "module3.py"),
            "--ignore-specific-edge",
            str(chik_dir / "module2.py") + " -> " + str(chik_dir / "module1b.py"),
        ],
    )
    assert "    module three ignored but no cycles were found" in result.output
    assert f"    file {chik_dir / 'module3.py'!s} ignored but no cycles were found" in result.output
    assert (
        f"edge {str(chik_dir / 'module2.py') + ' -> ' + str(chik_dir / 'module3.py')} ignored but no cycles were found"
        in result.output
    )
    assert (
        f"edge {str(chik_dir / 'module2.py') + ' -> ' + str(chik_dir / 'module1b.py')} ignored but no cycles were found"
        not in result.output
    )


def test_excluded_paths_handling(prepare_mocks2: None) -> None:
    # Graph where module2.py is excluded
    graph = {
        Path("/path/to/package1/module1.py"): [Path("/path/to/package2/module2.py")],
        Path("/path/to/package2/module2.py"): [Path("/path/to/package1/module1.py")],
    }
    cycles = find_cycles(
        graph,
        build_virtual_dependency_graph(DirectoryParameters(dir_path=Path("path")), existing_graph=graph),
        excluded_paths=[Path("/path/to/package2/module2.py")],
        ignore_cycles_in=[],
        ignore_specific_files=[],
        ignore_specific_edges=[],
    )
    assert len(cycles) == 0  # No cycles due to exclusion


def test_ignore_cycles_in_specific_packages(prepare_mocks2: None) -> None:
    graph: dict[Path, list[Path]] = {
        Path("/path/to/package1/module1.py"): [Path("/path/to/package2/module2.py")],
        Path("/path/to/package2/module2.py"): [Path("/path/to/package3/module3.py")],
        Path("/path/to/package3/module3.py"): [],
    }
    # Assuming module1.py and module3.py belong to Package1, which is ignored
    cycles = find_cycles(
        graph,
        build_virtual_dependency_graph(DirectoryParameters(dir_path=Path("path")), existing_graph=graph),
        excluded_paths=[],
        ignore_cycles_in=["Package1"],
        ignore_specific_files=[],
        ignore_specific_edges=[],
    )
    assert len(cycles) == 1  # Cycles in Package1 are ignored


def test_ignore_cycles_with_specific_edges(monkeypatch: pytest.MonkeyPatch) -> None:
    def _mock_chik_file_parse(path: Path) -> ChikFile:
        annotations_map = {
            Path("/path/to/package1/module1a.py"): Annotation("Package1", True),
            Path("/path/to/package2/module2.py"): Annotation("Package2", True),
            Path("/path/to/package3/module3.py"): Annotation("Package3", True),
            Path("/path/to/package1/module1b.py"): Annotation("Package1", True),
        }
        return ChikFile(path=Path(path), annotations=annotations_map.get(path, Annotation("chik-blockchain", False)))

    monkeypatch.setattr("chik.util.virtual_project_analysis.ChikFile.parse", _mock_chik_file_parse)

    graph = {
        Path("/path/to/package1/module1a.py"): [Path("/path/to/package2/module2.py")],
        Path("/path/to/package2/module2.py"): [Path("/path/to/package3/module3.py")],
        Path("/path/to/package3/module3.py"): [Path("/path/to/package1/module1b.py")],
        Path("/path/to/package1/module1b.py"): [],
    }
    virtual_graph = build_virtual_dependency_graph(DirectoryParameters(dir_path=Path("path")), existing_graph=graph)
    cycles = find_cycles(
        graph,
        virtual_graph,
        excluded_paths=[],
        ignore_cycles_in=[],
        ignore_specific_files=[],
        ignore_specific_edges=[
            (File(Path("/path/to/package3/module3.py")), File(Path("/path/to/package2/module2.py"))),
            (Package("Package3"), Package("Package2")),
        ],
    )
    assert len(cycles) == 0


def test_ignore_cycles_with_specific_files(prepare_mocks2: None) -> None:
    graph = {
        Path("/path/to/package1/module1.py"): [Path("/path/to/package2/module2.py")],
        Path("/path/to/package2/module2.py"): [Path("/path/to/package3/module3.py")],  # Cycle here
    }
    cycles = find_cycles(
        graph,
        build_virtual_dependency_graph(DirectoryParameters(dir_path=Path("path")), existing_graph=graph),
        excluded_paths=[],
        ignore_cycles_in=[],
        ignore_specific_files=[Path("/path/to/package1/module1.py"), Path("/path/to/package2/module2.py")],
        ignore_specific_edges=[],
    )
    assert len(cycles) == 0


# Sample function to use with the decorator for testing
@click.command("blah")
@config
def sample_function(config: Config) -> None:
    print(config)


# Helper function to create a temporary YAML configuration file
@pytest.fixture
def create_yaml_config(tmp_path: Path) -> Callable[[dict[str, Any]], Path]:
    def _create_yaml_config(content: dict[str, Any]) -> Path:
        path = tmp_path / "config.yaml"
        with open(path, "w") as f:
            yaml.dump(content, f)
        return path

    return _create_yaml_config


def test_config_with_yaml(create_yaml_config: Callable[[dict[str, Any]], Path]) -> None:
    # Create a temporary YAML configuration file
    yaml_config = {
        "exclude_paths": ["path/to/exclude"],
        "ignore": {
            "packages": ["ignored.package"],
            "files": ["ignored_file.py"],
            "edges": ["ignored_parent -> ignored_child"],
        },
    }
    config_path = create_yaml_config(yaml_config)

    runner = CliRunner()

    # Invoke the CLI with the --config option
    result = runner.invoke(sample_function, ["--directory", ".", "--config", str(config_path)])

    # Check if the command ran successfully
    assert result.exit_code == 0

    # Verify the config object created by the decorator
    config = result.output
    path_type = config_path.__class__.__name__
    assert config == (
        "Config("
        "directory_parameters=DirectoryParameters("
        f"dir_path={path_type}('.'), "
        f"excluded_paths=[{path_type}('path/to/exclude')]"
        "), "
        "ignore_cycles_in=['ignored.package'], "
        f"ignore_specific_files=[{path_type}('ignored_file.py')], "
        f"ignore_specific_edges=[(Package(name='ignored_child', is_file=False), "
        f"Package(name='ignored_parent', is_file=False))]"
        ")\n"
    )


def test_parse_edges(tmp_path: Path) -> None:
    chik_dir = tmp_path / "chik"
    chik_dir.mkdir()

    # Create some files within the chik package
    create_python_file(
        chik_dir,
        "module1.py",
        textwrap.dedent(
            """
            # Package: one
            def func1(): pass
            from chik.module2 import func2
            from chik.module3 import func3
            """
        ),
    )
    create_python_file(
        chik_dir,
        "module2.py",
        textwrap.dedent(
            """
            # Package: two
            def func2(): pass
            """
        ),
    )
    create_python_file(
        chik_dir,
        "module3.py",
        textwrap.dedent(
            """
            # Package: three
            def func3(): pass
            """
        ),
    )

    # Run the command
    runner = CliRunner()
    result = runner.invoke(
        cli,
        [
            "print_edges",
            "--directory",
            str(chik_dir),
            "--dependent-package",
            "one",
            "--provider-package",
            "two",
        ],
    )
    assert result.output.strip() == f"{chik_dir / 'module1.py'!s} (one) -> {chik_dir / 'module2.py'!s} (two)"
