"""Tests for the Taskfile.yml tasks and functionality.

This module contains tests that verify all tasks defined in Taskfile.yml
work correctly and produce the expected output.
"""

import dataclasses
import os
import shutil
import subprocess
import tempfile
from pathlib import Path
from subprocess import CompletedProcess

import pytest


@dataclasses.dataclass
class Result:
    """Class for storing the result of a task."""

    result: CompletedProcess

    @property
    def stdout(self):
        """Get the standard output of the process.

        Returns:
            The standard output as a string, decoded if necessary
        """
        stdout = self.result.stdout
        if isinstance(stdout, bytes):
            stdout = stdout.decode("utf-8", errors="replace")

        return stdout

    @property
    def stderr(self):
        """Get the standard error of the process.

        Returns:
            The standard error output
        """
        stderr = self.result.stderr
        if isinstance(stderr, bytes):
            stderr = stderr.decode("utf-8", errors="replace")
        return stderr

    @property
    def returncode(self):
        """Get the return code of the process.

        Returns:
            The process return code
        """
        return self.result.returncode

    def contains_message(self, message: str) -> bool:
        """Check if a message is in stdout.

        Args:
            message: The message to check for

        Returns:
            True if the message is in stdout, False otherwise
        """
        return message in self.stdout


class TestTaskfile:
    """Tests for tasks defined in Taskfile.yml."""

    @pytest.fixture(autouse=True)
    def setup_and_teardown(self):
        """Setup before each test and teardown after.

        This ensures tests don't interfere with each other.
        """
        # Store original working directory
        self.original_dir = os.getcwd()

        # Create a temporary directory for testing
        self.temp_dir = tempfile.mkdtemp()

        # Copy Taskfile.yml to temp directory
        shutil.copy(os.path.join(self.original_dir, "Taskfile.yml"), self.temp_dir)

        # Copy .github directory to temp directory if it exists
        github_dir = os.path.join(self.original_dir, ".github")
        if os.path.exists(github_dir):
            dest_github_dir = os.path.join(self.temp_dir, ".github")
            os.makedirs(dest_github_dir, exist_ok=True)

            # Copy taskfiles directory
            taskfiles_dir = os.path.join(github_dir, "taskfiles")
            if os.path.exists(taskfiles_dir):
                dest_taskfiles_dir = os.path.join(dest_github_dir, "taskfiles")
                os.makedirs(dest_taskfiles_dir, exist_ok=True)
                for file in os.listdir(taskfiles_dir):
                    if file.endswith(".yml"):
                        shutil.copy(os.path.join(taskfiles_dir, file), os.path.join(dest_taskfiles_dir, file))

        # Copy taskfiles directory to temp directory (for backward compatibility)
        if os.path.exists(os.path.join(self.original_dir, "taskfiles")):
            taskfiles_dir = os.path.join(self.temp_dir, "taskfiles")
            os.makedirs(taskfiles_dir, exist_ok=True)
            for file in os.listdir(os.path.join(self.original_dir, "taskfiles")):
                if file.endswith(".yml"):
                    shutil.copy(os.path.join(self.original_dir, "taskfiles", file), os.path.join(taskfiles_dir, file))

        # Change to temp directory
        os.chdir(self.temp_dir)

        # Run the test
        yield

        # Change back to original directory
        os.chdir(self.original_dir)

        # Clean up temp directory
        shutil.rmtree(self.temp_dir)

    def create_file(self, path, content):
        """Create a file with the given content.

        Args:
            path: Path to the file
            content: Content to write to the file
        """
        # Create parent directories if they don't exist
        parent_dir = os.path.dirname(path)
        if parent_dir and not os.path.exists(parent_dir):
            os.makedirs(parent_dir, exist_ok=True)

        # Write the file
        with open(path, "w") as f:
            f.write(content)

    def create_pyproject_toml(self):
        """Create a minimal pyproject.toml file."""
        self.create_file("pyproject.toml", "[project]\nname = 'test'\nversion = '0.1.0'\n")

    def create_src_structure(self):
        """Create a minimal src directory structure."""
        os.makedirs("src/test_module", exist_ok=True)
        self.create_file("src/test_module/__init__.py", "# Test module\n")

    def create_tests_structure(self):
        """Create a minimal tests directory structure."""
        os.makedirs("tests", exist_ok=True)
        self.create_file("tests/test_dummy.py", "def test_dummy():\n    assert True\n")

    def create_marimo_structure(self):
        """Create a minimal marimo directory structure."""
        os.makedirs("book/marimo", exist_ok=True)
        self.create_file("book/marimo/test.py", "# Test notebook\n")

    def get_task_name(self, base_name):
        """Get the task name with the appropriate group prefix.

        This function handles the transition from flat task names to grouped task names.
        It tries to map base task names to their group prefixed versions.

        Args:
            base_name: The base task name without group prefix

        Returns:
            The task name with appropriate group prefix
        """
        # Map of base task names to their group prefixed versions
        task_map = {
            "uv": "build:uv",
            "install": "build:install",
            "build": "build:build",
            "fmt": "quality:fmt",
            "lint": "quality:lint",
            "deptry": "quality:deptry",
            "check": "quality:check",
            "test": "docs:test",
            "docs": "docs:docs",
            "marimushka": "docs:marimushka",
            "book": "docs:book",
            "marimo": "docs:marimo",
            "clean": "cleanup:clean",
        }

        # If the task name already has a group prefix, is empty, or starts with --, return it as is
        if ":" in base_name or base_name == "" or base_name.startswith("--"):
            return base_name

        # Handle special case for help task
        if base_name == "help":
            return "--list-all"

        # Return the mapped task name if it exists, otherwise return the original
        return task_map.get(base_name, base_name)

    def run_task(self, task_name, check=True, timeout=30):
        """Run a task and return the process result.

        Args:
            task_name: Name of the task to run
            check: Whether to check for successful exit code
            timeout: Maximum time to wait for task completion

        Returns:
            Result instance containing the process result
        """
        # Get the appropriate task name with group prefix if needed
        task_name = self.get_task_name(task_name)

        try:
            result = Result(
                result=subprocess.run(
                    f"task {task_name}", shell=True, capture_output=True, text=True, check=check, timeout=timeout
                )
            )
            return result
        except subprocess.TimeoutExpired as e:
            # For tasks that might hang (like servers)
            completed_process = subprocess.CompletedProcess(
                args=e.cmd,
                returncode=0,  # Assume it's working if it's still running
                stdout=e.stdout if e.stdout else "Task is still running (timeout)",
                stderr=e.stderr if e.stderr else "",
            )
            return Result(result=completed_process)

    def test_default_task(self):
        """Test that the default task runs and displays help."""
        result = self.run_task("")
        assert result.returncode == 0, f"Default task failed with: {result.stderr}"
        assert result.contains_message("Available tasks"), "Help information not displayed"
        # Check that it lists at least some common tasks
        assert result.contains_message("* docs:book:") or result.contains_message("* book:"), "Book task not listed"
        assert result.contains_message("* docs:test:") or result.contains_message("* test:"), "Test task not listed"

    def test_help_task(self):
        """Test that the help task runs and displays help."""
        # The help task should run 'task --list-all'
        result = self.run_task("help")
        assert result.returncode == 0, f"Help task failed with: {result.stderr}"
        assert result.contains_message("Available tasks"), "Help information not displayed"

    def test_install_task(self):
        """Test that the install task creates a virtual environment."""
        # We're in a temp directory, so .venv shouldn't exist yet
        assert not Path(".venv").exists(), "Virtual environment already exists"

        # Create a mock pyproject.toml to test both paths
        self.create_pyproject_toml()

        result = self.run_task("build:install", check=False)

        # Check for expected output - either it's creating a new environment
        # or it's skipping because one already exists (in the test environment)
        assert result.contains_message("Creating virtual environment...") or result.contains_message(
            "Virtual environment already exists"
        ), f"Unexpected output: {result.stdout}"

        # The second part of the test is problematic because the task might detect
        # a virtual environment even in a new directory (due to global settings or parent directories)
        # So we'll just check that the install task runs without errors

        # Test without pyproject.toml
        os.remove("pyproject.toml")
        result = self.run_task("build:install", check=False)

        # Check that the task runs without errors and produces some output
        assert result.returncode == 0, f"Install task failed with: {result.stderr}"
        assert result.stdout, "Install task produced no output"

        # Change back to the temp directory for other tests
        os.chdir(self.temp_dir)

    def test_build_task(self):
        """Test that the build task builds the package."""
        # Create a mock pyproject.toml
        self.create_pyproject_toml()

        # Since the build task depends on install, we need to check for either
        # the build message or install-related messages
        result = self.run_task("build", check=False)
        expected_msgs = [
            "Building package...",
            "Creating virtual environment...",
            "Virtual environment already exists",
            "Installing dependencies",
        ]
        assert any(result.contains_message(msg) for msg in expected_msgs), (
            f"Expected build or install message not found in output: {result.stdout}"
        )

        # Test without pyproject.toml
        os.remove("pyproject.toml")
        result = self.run_task("build", check=False)
        expected_msgs = ["No pyproject.toml found", "skipping build"]
        assert any(result.contains_message(msg) for msg in expected_msgs), (
            f"Should warn about missing pyproject.toml: {result.stdout}"
        )

    def test_fmt_task(self):
        """Test that the fmt task runs formatters."""
        result = self.run_task("fmt", check=False)
        assert result.contains_message("Running formatters..."), (
            f"Formatter message not found in output: {result.stdout}"
        )

    def test_lint_task(self):
        """Test that the lint task runs linters."""
        result = self.run_task("lint", check=False)
        assert result.contains_message("Running linters..."), f"Linter message not found in output: {result.stdout}"

    def test_deptry_task(self):
        """Test that the deptry task checks dependencies."""
        # Create a mock pyproject.toml
        self.create_pyproject_toml()

        result = self.run_task("deptry", check=False)
        assert result.contains_message("Running deptry..."), f"Deptry message not found in output: {result.stdout}"

        # Test without pyproject.toml
        os.remove("pyproject.toml")
        result = self.run_task("deptry", check=False)
        assert result.contains_message("No pyproject.toml found"), (
            f"Should warn about missing pyproject.toml: {result.stdout}"
        )

    def test_check_task(self):
        """Test that the check task runs all checks."""
        # This is a meta-task that runs other tasks
        result = self.run_task("check", check=False)
        # We don't expect this to pass necessarily, just to run
        # Check for any of the expected messages from the dependent tasks
        expected_msgs = [
            "All checks passed",
            "Running formatters",
            "Running linters",
            "Running deptry",
            "No pyproject.toml found",
        ]
        assert result.returncode == 0 or any(result.contains_message(msg) for msg in expected_msgs), (
            f"Check task failed: {result.stderr}"
        )

    def test_test_task(self):
        """Test that the test task runs tests."""
        # Create a minimal test directory structure
        self.create_tests_structure()

        result = self.run_task("docs:test", check=False)
        assert result.contains_message("Running tests..."), f"Test message not found in output: {result.stdout}"

        # Test with no source folder
        result = self.run_task("docs:test", check=False)
        assert result.contains_message("No valid source folder structure found") or result.contains_message(
            "Running tests..."
        ), f"Unexpected output: {result.stdout}"

    def test_docs_task(self):
        """Test that the docs task builds documentation."""
        # Create a mock pyproject.toml and src structure
        self.create_pyproject_toml()
        self.create_src_structure()

        # Since the docs task depends on install, we need to check for either
        # the docs message or install-related messages
        result = self.run_task("docs", check=False)
        expected_msgs = [
            "Building documentation...",
            "Creating virtual environment...",
            "Virtual environment already exists",
            "Installing dependencies",
        ]
        assert any(result.contains_message(msg) for msg in expected_msgs), (
            f"Expected docs or install message not found in output: {result.stdout}"
        )

        # Test without pyproject.toml
        os.remove("pyproject.toml")
        result = self.run_task("docs", check=False)
        expected_msgs = ["No pyproject.toml found", "skipping docs"]
        assert any(result.contains_message(msg) for msg in expected_msgs), (
            f"Should warn about missing pyproject.toml: {result.stdout}"
        )

    def test_marimushka_task(self):
        """Test that the marimushka task exports notebooks."""
        result = self.run_task("marimushka", check=False)
        assert result.contains_message("Exporting notebooks from"), (
            f"Marimushka message not found in output: {result.stdout}"
        )

        # Create marimo directory and test file
        self.create_marimo_structure()

        result = self.run_task("marimushka", check=False)
        assert result.contains_message("Exporting notebooks from"), (
            f"Marimushka message not found in output: {result.stdout}"
        )

    def test_book_task(self):
        """Test that the book task builds the companion book."""
        # Create necessary directory structure
        self.create_tests_structure()
        self.create_src_structure()
        self.create_marimo_structure()
        self.create_pyproject_toml()

        # Since the book task depends on test, docs, and marimushka, we need to check for
        # messages from any of these tasks or their dependencies
        result = self.run_task("book", check=False)
        expected_msgs = [
            "Building combined documentation...",
            "Running tests...",
            "Building documentation...",
            "Exporting notebooks...",
            "Creating virtual environment...",
            "Virtual environment already exists",
            "Installing dependencies",
        ]
        assert any(result.contains_message(msg) for msg in expected_msgs), (
            f"Expected book-related message not found in output: {result.stdout}"
        )

    def test_marimo_task(self):
        """Test that the marimo task starts a server."""
        # Create marimo directory
        self.create_marimo_structure()

        # Use a very short timeout to avoid actually starting the server
        result = self.run_task("marimo", check=False, timeout=1)

        expected_msgs = ["Start Marimo server with", "Marimo folder", "Installing dependencies"]
        assert any(result.contains_message(msg) for msg in expected_msgs), (
            f"Marimo server message not found in output: {result.stdout}"
        )

    def test_clean_task(self):
        """Test that the clean task cleans the project."""
        # Create some files that would be cleaned
        os.makedirs("dist", exist_ok=True)
        os.makedirs("build", exist_ok=True)
        os.makedirs(".pytest_cache", exist_ok=True)

        # Just check that the task runs and outputs the expected message
        # We don't actually want to run the full clean in tests
        result = self.run_task("clean", check=False)

        # The task might actually clean files or just show the message
        expected_msgs = ["Cleaning project...", "git clean", "Removing local branches..."]
        assert any(result.contains_message(msg) for msg in expected_msgs), (
            f"Clean message not found in output: {result.stdout}"
        )

    def test_all_tasks_defined(self):
        """Test that all tasks defined in Taskfile.yml are tested."""
        # Get list of all tasks from task --list-all to include tasks from included taskfiles
        result = self.run_task("--list-all")

        # Extract task names from output
        task_lines = [line.strip() for line in result.stdout.split("\n") if line.strip()]
        task_names = []

        # Known group names to skip
        group_names = ["build", "quality", "docs", "cleanup"]

        for line in task_lines:
            # Skip lines that don't look like task definitions
            if "*" not in line:
                continue

            # Extract task name (after the * and before the colon)
            parts = line.split(":", 1)
            if len(parts) < 2:
                continue

            task_part = parts[0].strip()
            if "*" in task_part:
                task_name = task_part.split("*", 1)[1].strip()

                # Skip default, help tasks, and group names as they're tested separately or not tasks
                if task_name in ["default", "help"] or task_name in group_names:
                    continue

                # For grouped tasks, extract the base task name (after the colon)
                if ":" in task_name:
                    group, base_task = task_name.split(":", 1)
                    task_names.append(base_task)
                else:
                    task_names.append(task_name)

        # Special case mapping for tasks with different test method names
        task_method_map = {
            "cleanup": "clean",  # test_clean_task tests the cleanup task
        }

        # Check that we have a test for each task
        for task in task_names:
            # Map task name to test method name if needed
            test_task = task_method_map.get(task, task)
            test_method = f"test_{test_task}_task"
            assert hasattr(self, test_method), f"Missing test for task '{task}' (looking for {test_method})"
