"""Tests for container diff functionality."""

from __future__ import annotations

import json
from datetime import datetime
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch

import pytest

from gaol.diff import (
    ConsoleFormatter,
    DiffEngine,
    DiffOptions,
    DiffResult,
    DiffSummary,
    FileChange,
    FileChangeType,
    FileEntry,
    FilesystemManifest,
    FileType,
    JsonFormatter,
    PackageChange,
    PackageDiff,
    PackageInfo,
    get_formatter,
)
from gaol.diff.engine import DiffEngine
from gaol.diff.exceptions import (
    DiffError,
    DiffFilesystemAccessError,
    DiffSnapshotNotFoundError,
    DiffSourceNotFoundError,
)
from gaol.diff.package import (
    ApkParser,
    DpkgParser,
    PackageDetector,
    PacmanParser,
    detect_distro_from_packages,
)


# =============================================================================
# Model Tests
# =============================================================================


class TestFileEntry:
    """Tests for FileEntry model."""

    def test_file_entry_defaults(self):
        """Test FileEntry default values."""
        entry = FileEntry(path="/test")
        assert entry.path == "/test"
        assert entry.file_type == FileType.FILE
        assert entry.size == 0
        assert entry.mode == 0o644
        assert entry.uid == 0
        assert entry.gid == 0

    def test_file_entry_mode_str(self):
        """Test mode_str property."""
        entry = FileEntry(path="/test", mode=0o755)
        assert entry.mode_str == "rwxr-xr-x"

        entry = FileEntry(path="/test", mode=0o644)
        assert entry.mode_str == "rw-r--r--"

        entry = FileEntry(path="/test", mode=0o600)
        assert entry.mode_str == "rw-------"

    def test_file_entry_with_all_fields(self):
        """Test FileEntry with all fields set."""
        now = datetime.now()
        entry = FileEntry(
            path="/etc/config",
            file_type=FileType.FILE,
            size=1024,
            mode=0o640,
            uid=1000,
            gid=1000,
            mtime=now,
            link_target=None,
            checksum="abc123",
        )
        assert entry.size == 1024
        assert entry.checksum == "abc123"


class TestFileChange:
    """Tests for FileChange model."""

    def test_file_change_added(self):
        """Test FileChange for added file."""
        target = FileEntry(path="/new/file", size=100)
        change = FileChange(
            path="/new/file",
            change_type=FileChangeType.ADDED,
            target_entry=target,
            size_delta=100,
        )
        assert change.is_content_change
        assert change.size_delta == 100

    def test_file_change_deleted(self):
        """Test FileChange for deleted file."""
        source = FileEntry(path="/old/file", size=200)
        change = FileChange(
            path="/old/file",
            change_type=FileChangeType.DELETED,
            source_entry=source,
            size_delta=-200,
        )
        assert change.is_content_change
        assert change.size_delta == -200

    def test_file_change_permissions_not_content(self):
        """Test permissions change is not content change."""
        change = FileChange(
            path="/file",
            change_type=FileChangeType.PERMISSIONS,
        )
        assert not change.is_content_change


class TestFilesystemManifest:
    """Tests for FilesystemManifest model."""

    def test_manifest_add_entry(self):
        """Test adding entries to manifest."""
        manifest = FilesystemManifest(
            container_name="test",
            runtime="nspawn",
            source_type="container",
            source_id="test",
        )

        # Add file
        file_entry = FileEntry(path="/test/file", size=100)
        manifest.add_entry(file_entry)
        assert manifest.total_files == 1
        assert manifest.total_size_bytes == 100

        # Add directory
        dir_entry = FileEntry(
            path="/test/dir", file_type=FileType.DIRECTORY, size=0
        )
        manifest.add_entry(dir_entry)
        assert manifest.total_dirs == 1
        assert manifest.total_files == 1

    def test_manifest_get_entry(self):
        """Test getting entry by path."""
        manifest = FilesystemManifest(
            container_name="test",
            runtime="nspawn",
            source_type="container",
            source_id="test",
        )
        entry = FileEntry(path="/test/file")
        manifest.add_entry(entry)

        assert manifest.get_entry("/test/file") == entry
        assert manifest.get_entry("/nonexistent") is None


class TestPackageInfo:
    """Tests for PackageInfo model."""

    def test_package_info_basic(self):
        """Test basic PackageInfo creation."""
        pkg = PackageInfo(
            name="nginx",
            version="1.24.0-1",
            architecture="amd64",
            source="dpkg",
        )
        assert pkg.name == "nginx"
        assert pkg.version == "1.24.0-1"


class TestPackageChange:
    """Tests for PackageChange model."""

    def test_version_diff_added(self):
        """Test version_diff for added package."""
        change = PackageChange(
            package_name="curl",
            change_type=FileChangeType.ADDED,
            target_version="8.0.1",
        )
        assert change.version_diff == "(none) -> 8.0.1"

    def test_version_diff_deleted(self):
        """Test version_diff for removed package."""
        change = PackageChange(
            package_name="wget",
            change_type=FileChangeType.DELETED,
            source_version="1.21",
        )
        assert change.version_diff == "1.21 -> (removed)"

    def test_version_diff_modified(self):
        """Test version_diff for upgraded package."""
        change = PackageChange(
            package_name="vim",
            change_type=FileChangeType.MODIFIED,
            source_version="9.0",
            target_version="9.1",
        )
        assert change.version_diff == "9.0 -> 9.1"


class TestPackageDiff:
    """Tests for PackageDiff model."""

    def test_package_diff_counts(self):
        """Test package diff counting."""
        diff = PackageDiff(
            added=[
                PackageInfo(name="a", version="1"),
                PackageInfo(name="b", version="1"),
            ],
            removed=[PackageInfo(name="c", version="1")],
            upgraded=[
                PackageChange(
                    package_name="d",
                    change_type=FileChangeType.MODIFIED,
                )
            ],
            downgraded=[],
        )
        assert diff.total_changes == 4
        assert diff.has_changes


class TestDiffSummary:
    """Tests for DiffSummary model."""

    def test_summary_totals(self):
        """Test summary total calculations."""
        summary = DiffSummary(
            files_added=10,
            files_deleted=5,
            files_modified=3,
            files_permissions_changed=2,
            packages_added=4,
            packages_removed=1,
            packages_upgraded=2,
            packages_downgraded=0,
        )
        assert summary.total_file_changes == 20
        assert summary.total_package_changes == 7


class TestDiffOptions:
    """Tests for DiffOptions model."""

    def test_default_exclusions(self):
        """Test default path exclusions."""
        options = DiffOptions()
        assert "/proc/*" in options.exclude_paths
        assert "/sys/*" in options.exclude_paths
        assert "/dev/*" in options.exclude_paths
        assert "/run/*" in options.exclude_paths

    def test_custom_options(self):
        """Test custom diff options."""
        options = DiffOptions(
            include_paths=["/etc/*"],
            compare_content=True,
            compare_ownership=True,
        )
        assert options.include_paths == ["/etc/*"]
        assert options.compare_content
        assert options.compare_ownership


# =============================================================================
# Engine Tests
# =============================================================================


class TestDiffEngine:
    """Tests for DiffEngine."""

    def _create_manifest(
        self, container: str, entries: list[FileEntry]
    ) -> FilesystemManifest:
        """Helper to create manifest."""
        manifest = FilesystemManifest(
            container_name=container,
            runtime="nspawn",
            source_type="container",
            source_id=container,
        )
        for entry in entries:
            manifest.add_entry(entry)
        return manifest

    def test_compute_diff_added_files(self):
        """Test detecting added files."""
        engine = DiffEngine()

        source = self._create_manifest("source", [
            FileEntry(path="/existing", size=100),
        ])
        target = self._create_manifest("target", [
            FileEntry(path="/existing", size=100),
            FileEntry(path="/new", size=50),
        ])

        result = engine.compute_diff(source, target)

        assert len(result.file_changes) == 1
        assert result.file_changes[0].change_type == FileChangeType.ADDED
        assert result.file_changes[0].path == "/new"
        assert result.summary.files_added == 1

    def test_compute_diff_deleted_files(self):
        """Test detecting deleted files."""
        engine = DiffEngine()

        source = self._create_manifest("source", [
            FileEntry(path="/keep", size=100),
            FileEntry(path="/remove", size=200),
        ])
        target = self._create_manifest("target", [
            FileEntry(path="/keep", size=100),
        ])

        result = engine.compute_diff(source, target)

        assert len(result.file_changes) == 1
        assert result.file_changes[0].change_type == FileChangeType.DELETED
        assert result.file_changes[0].path == "/remove"
        assert result.summary.files_deleted == 1

    def test_compute_diff_modified_files(self):
        """Test detecting modified files."""
        engine = DiffEngine()

        source = self._create_manifest("source", [
            FileEntry(path="/file", size=100),
        ])
        target = self._create_manifest("target", [
            FileEntry(path="/file", size=150),
        ])

        result = engine.compute_diff(source, target)

        assert len(result.file_changes) == 1
        assert result.file_changes[0].change_type == FileChangeType.MODIFIED
        assert result.file_changes[0].size_delta == 50
        assert result.summary.files_modified == 1

    def test_compute_diff_permission_changes(self):
        """Test detecting permission changes."""
        engine = DiffEngine()
        options = DiffOptions(compare_permissions=True)

        source = self._create_manifest("source", [
            FileEntry(path="/file", size=100, mode=0o644),
        ])
        target = self._create_manifest("target", [
            FileEntry(path="/file", size=100, mode=0o755),
        ])

        result = engine.compute_diff(source, target, options)

        assert len(result.file_changes) == 1
        assert result.file_changes[0].change_type == FileChangeType.PERMISSIONS

    def test_compute_diff_type_change(self):
        """Test detecting type changes (file -> directory)."""
        engine = DiffEngine()

        source = self._create_manifest("source", [
            FileEntry(path="/item", file_type=FileType.FILE, size=100),
        ])
        target = self._create_manifest("target", [
            FileEntry(path="/item", file_type=FileType.DIRECTORY, size=0),
        ])

        result = engine.compute_diff(source, target)

        assert len(result.file_changes) == 1
        assert result.file_changes[0].change_type == FileChangeType.TYPE_CHANGED

    def test_compute_diff_exclusion_patterns(self):
        """Test path exclusion patterns."""
        engine = DiffEngine()
        options = DiffOptions(exclude_paths=["/proc/*", "/var/log/*"])

        source = self._create_manifest("source", [
            FileEntry(path="/etc/config", size=100),
            FileEntry(path="/proc/1/status", size=50),
            FileEntry(path="/var/log/syslog", size=1000),
        ])
        target = self._create_manifest("target", [
            FileEntry(path="/etc/config", size=200),
            FileEntry(path="/proc/1/status", size=60),
            FileEntry(path="/var/log/syslog", size=2000),
        ])

        result = engine.compute_diff(source, target, options)

        # Only /etc/config should be in results
        assert len(result.file_changes) == 1
        assert result.file_changes[0].path == "/etc/config"

    def test_compute_diff_inclusion_patterns(self):
        """Test path inclusion patterns."""
        engine = DiffEngine()
        options = DiffOptions(
            include_paths=["/etc/*"],
            exclude_paths=[],  # Clear defaults
        )

        source = self._create_manifest("source", [
            FileEntry(path="/etc/config", size=100),
            FileEntry(path="/var/data", size=500),
        ])
        target = self._create_manifest("target", [
            FileEntry(path="/etc/config", size=200),
            FileEntry(path="/var/data", size=600),
        ])

        result = engine.compute_diff(source, target, options)

        assert len(result.file_changes) == 1
        assert result.file_changes[0].path == "/etc/config"

    def test_compute_package_diff(self):
        """Test package diff computation."""
        engine = DiffEngine()

        source_packages = [
            PackageInfo(name="vim", version="9.0"),
            PackageInfo(name="wget", version="1.21"),
            PackageInfo(name="old-pkg", version="1.0"),
        ]
        target_packages = [
            PackageInfo(name="vim", version="9.1"),
            PackageInfo(name="wget", version="1.21"),
            PackageInfo(name="new-pkg", version="2.0"),
        ]

        diff = engine.compute_package_diff(source_packages, target_packages)

        assert len(diff.added) == 1
        assert diff.added[0].name == "new-pkg"
        assert len(diff.removed) == 1
        assert diff.removed[0].name == "old-pkg"
        assert len(diff.upgraded) == 1
        assert diff.upgraded[0].package_name == "vim"

    def test_version_comparison(self):
        """Test version upgrade/downgrade detection."""
        engine = DiffEngine()

        # Upgrade
        assert engine._is_version_upgrade("1.0", "2.0")
        assert engine._is_version_upgrade("1.0.0", "1.0.1")
        assert engine._is_version_upgrade("1.9", "1.10")

        # Not upgrade
        assert not engine._is_version_upgrade("2.0", "1.0")
        assert not engine._is_version_upgrade("1.0", "1.0")


# =============================================================================
# Package Parser Tests
# =============================================================================


class TestDpkgParser:
    """Tests for dpkg parser."""

    def test_parse_dpkg_status(self):
        """Test parsing dpkg status file."""
        content = b"""Package: bash
Status: install ok installed
Architecture: amd64
Version: 5.2-2

Package: vim
Status: install ok installed
Architecture: amd64
Version: 2:9.0.1000-1

Package: removed-pkg
Status: deinstall ok config-files
Version: 1.0
"""

        parser = DpkgParser()
        packages = parser.parse(content)

        assert len(packages) == 2
        assert packages[0].name == "bash"
        assert packages[0].version == "5.2-2"
        assert packages[1].name == "vim"

    def test_database_path(self):
        """Test dpkg database path."""
        parser = DpkgParser()
        assert parser.database_path == "/var/lib/dpkg/status"


class TestApkParser:
    """Tests for apk parser."""

    def test_parse_apk_installed(self):
        """Test parsing APK installed file."""
        content = b"""P:busybox
V:1.36.1-r0
A:x86_64
T:Size optimized toolbox

P:alpine-baselayout
V:3.4.3-r1
A:x86_64
"""

        parser = ApkParser()
        packages = parser.parse(content)

        assert len(packages) == 2
        assert packages[0].name == "busybox"
        assert packages[0].version == "1.36.1-r0"
        assert packages[1].name == "alpine-baselayout"

    def test_database_path(self):
        """Test apk database path."""
        parser = ApkParser()
        assert parser.database_path == "/lib/apk/db/installed"


class TestPacmanParser:
    """Tests for pacman parser."""

    def test_parse_pacman_desc(self):
        """Test parsing pacman desc file."""
        content = b"""%NAME%
bash

%VERSION%
5.2.015-1

%ARCH%
x86_64

%DESC%
The GNU Bourne Again shell
"""

        parser = PacmanParser()
        packages = parser.parse(content)

        assert len(packages) == 1
        assert packages[0].name == "bash"
        assert packages[0].version == "5.2.015-1"
        assert packages[0].architecture == "x86_64"


class TestDistroDetection:
    """Tests for distro detection from packages."""

    def test_detect_debian(self):
        """Test detecting Debian from packages."""
        packages = [
            PackageInfo(name="dpkg", version="1.0"),
            PackageInfo(name="apt", version="2.0"),
        ]
        assert detect_distro_from_packages(packages) == "debian"

    def test_detect_ubuntu(self):
        """Test detecting Ubuntu from packages."""
        packages = [
            PackageInfo(name="dpkg", version="1.0"),
            PackageInfo(name="ubuntu-minimal", version="1.0"),
        ]
        assert detect_distro_from_packages(packages) == "ubuntu"

    def test_detect_alpine(self):
        """Test detecting Alpine from packages."""
        packages = [
            PackageInfo(name="apk-tools", version="2.0"),
            PackageInfo(name="alpine-baselayout", version="3.0"),
        ]
        assert detect_distro_from_packages(packages) == "alpine"

    def test_detect_arch(self):
        """Test detecting Arch from packages."""
        packages = [
            PackageInfo(name="pacman", version="6.0"),
            PackageInfo(name="archlinux-keyring", version="1.0"),
        ]
        assert detect_distro_from_packages(packages) == "arch"


# =============================================================================
# Formatter Tests
# =============================================================================


class TestConsoleFormatter:
    """Tests for console formatter."""

    def _create_result(self, changes: list[FileChange] = None) -> DiffResult:
        """Helper to create diff result."""
        return DiffResult(
            source_name="source",
            source_type="container",
            target_name="target",
            target_type="container",
            runtime="nspawn",
            file_changes=changes or [],
            summary=DiffSummary(
                files_added=1,
                files_deleted=1,
                bytes_added=100,
            ),
        )

    def test_format_basic(self):
        """Test basic formatting."""
        formatter = ConsoleFormatter(color=False)
        result = self._create_result()

        output = formatter.format(result)

        assert "Container Diff" in output
        assert "source" in output
        assert "target" in output

    def test_format_with_changes(self):
        """Test formatting with file changes."""
        changes = [
            FileChange(
                path="/new/file",
                change_type=FileChangeType.ADDED,
                target_entry=FileEntry(path="/new/file", size=100),
                size_delta=100,
            ),
        ]
        formatter = ConsoleFormatter(color=False)
        result = self._create_result(changes)

        output = formatter.format(result)

        assert "/new/file" in output
        assert "File Changes" in output

    def test_format_with_max_changes(self):
        """Test truncating changes."""
        changes = [
            FileChange(
                path=f"/file{i}",
                change_type=FileChangeType.ADDED,
                size_delta=10,
            )
            for i in range(10)
        ]
        formatter = ConsoleFormatter(color=False, max_changes=3)
        result = self._create_result(changes)

        output = formatter.format(result)

        assert "... and 7 more changes" in output

    def test_format_package_changes(self):
        """Test formatting package changes."""
        formatter = ConsoleFormatter(color=False, show_packages=True)
        result = self._create_result()
        result.package_diff = PackageDiff(
            added=[PackageInfo(name="curl", version="8.0")],
            removed=[PackageInfo(name="wget", version="1.0")],
        )

        output = formatter.format(result)

        assert "Package Changes" in output
        assert "curl" in output
        assert "wget" in output

    def test_color_disabled(self):
        """Test that colors can be disabled."""
        formatter = ConsoleFormatter(color=False)
        result = self._create_result()

        output = formatter.format(result)

        # Should not contain ANSI codes
        assert "\033[" not in output


class TestJsonFormatter:
    """Tests for JSON formatter."""

    def test_format_json(self):
        """Test JSON output."""
        formatter = JsonFormatter()
        result = DiffResult(
            source_name="source",
            source_type="container",
            target_name="target",
            target_type="container",
            runtime="nspawn",
            file_changes=[],
            summary=DiffSummary(),
        )

        output = formatter.format(result)
        data = json.loads(output)

        assert data["source"]["name"] == "source"
        assert data["target"]["name"] == "target"
        assert data["runtime"] == "nspawn"
        assert "summary" in data

    def test_format_json_with_changes(self):
        """Test JSON output with file changes."""
        formatter = JsonFormatter()
        result = DiffResult(
            source_name="source",
            source_type="container",
            target_name="target",
            target_type="container",
            runtime="nspawn",
            file_changes=[
                FileChange(
                    path="/test",
                    change_type=FileChangeType.ADDED,
                    size_delta=100,
                ),
            ],
            summary=DiffSummary(files_added=1),
        )

        output = formatter.format(result)
        data = json.loads(output)

        assert len(data["file_changes"]) == 1
        assert data["file_changes"][0]["path"] == "/test"
        assert data["file_changes"][0]["change_type"] == "added"


class TestGetFormatter:
    """Tests for formatter factory."""

    def test_get_console_formatter(self):
        """Test getting console formatter."""
        formatter = get_formatter("console")
        assert isinstance(formatter, ConsoleFormatter)

    def test_get_json_formatter(self):
        """Test getting JSON formatter."""
        formatter = get_formatter("json")
        assert isinstance(formatter, JsonFormatter)

    def test_get_formatter_with_options(self):
        """Test passing options to formatter."""
        formatter = get_formatter("console", color=False, max_changes=10)
        assert isinstance(formatter, ConsoleFormatter)
        assert not formatter.color
        assert formatter.max_changes == 10


# =============================================================================
# Exception Tests
# =============================================================================


class TestExceptions:
    """Tests for diff exceptions."""

    def test_diff_source_not_found(self):
        """Test DiffSourceNotFoundError."""
        err = DiffSourceNotFoundError("container", "missing")
        assert "Container 'missing' not found" in str(err)

    def test_diff_snapshot_not_found(self):
        """Test DiffSnapshotNotFoundError."""
        err = DiffSnapshotNotFoundError("mycontainer", "snap1")
        assert "Snapshot 'snap1' not found" in str(err)
        assert "mycontainer" in str(err)

    def test_diff_filesystem_access_error(self):
        """Test DiffFilesystemAccessError."""
        err = DiffFilesystemAccessError("test", "permission denied")
        assert "permission denied" in str(err)
        assert err.container_name == "test"


# =============================================================================
# Integration Tests (Mocked)
# =============================================================================


class TestHighLevelAPI:
    """Tests for high-level API functions."""

    @patch("gaol.diff.get_reader")
    def test_compare_containers(self, mock_get_reader):
        """Test compare_containers function."""
        from gaol.diff import compare_containers

        # Setup mock reader
        mock_reader = MagicMock()
        mock_reader.read_container.side_effect = [
            FilesystemManifest(
                container_name="source",
                runtime="nspawn",
                source_type="container",
                source_id="source",
            ),
            FilesystemManifest(
                container_name="target",
                runtime="nspawn",
                source_type="container",
                source_id="target",
            ),
        ]
        mock_reader.read_file_content.return_value = None
        mock_get_reader.return_value = mock_reader

        result = compare_containers(
            "source",
            "target",
            runtime="nspawn",
            include_packages=False,
        )

        assert result.source_name == "source"
        assert result.target_name == "target"
        mock_reader.read_container.assert_called()

    @patch("gaol.diff.get_reader")
    def test_compare_snapshots(self, mock_get_reader):
        """Test compare_snapshots function."""
        from gaol.diff import compare_snapshots

        mock_reader = MagicMock()
        mock_reader.read_snapshot.side_effect = [
            FilesystemManifest(
                container_name="test",
                runtime="nspawn",
                source_type="snapshot",
                source_id="snap1",
            ),
            FilesystemManifest(
                container_name="test",
                runtime="nspawn",
                source_type="snapshot",
                source_id="snap2",
            ),
        ]
        mock_get_reader.return_value = mock_reader

        result = compare_snapshots(
            "test",
            "snap1",
            "snap2",
            runtime="nspawn",
        )

        assert result.source_name == "snap1"
        assert result.target_name == "snap2"
        mock_reader.read_snapshot.assert_called()
