"""Tests for the specs module."""

from pathlib import Path

import pytest

from gaol.specs import (
    ContainerSpec,
    SpecLoader,
    SpecNotFoundError,
    SpecParseError,
    SpecValidationError,
    load_env_file,
    load_spec,
    parse_cpu,
    parse_memory,
    parse_port_mapping,
    parse_volume,
    spec_to_yaml,
)


class TestParseMemory:
    """Tests for memory parsing."""

    def test_integer_passthrough(self) -> None:
        """Test integer values are passed through as MB."""
        assert parse_memory(1024) == 1024
        assert parse_memory(512) == 512

    def test_megabytes(self) -> None:
        """Test megabyte units."""
        assert parse_memory("512M") == 512
        assert parse_memory("512MB") == 512
        assert parse_memory("1024m") == 1024

    def test_gigabytes(self) -> None:
        """Test gigabyte units."""
        assert parse_memory("1G") == 1024
        assert parse_memory("2GB") == 2048
        assert parse_memory("4g") == 4096

    def test_kilobytes(self) -> None:
        """Test kilobyte units."""
        assert parse_memory("1024K") == 1
        assert parse_memory("1024KB") == 1
        assert parse_memory("2048k") == 2

    def test_terabytes(self) -> None:
        """Test terabyte units."""
        assert parse_memory("1T") == 1024 * 1024
        assert parse_memory("1TB") == 1024 * 1024

    def test_bare_number(self) -> None:
        """Test bare numbers default to MB."""
        assert parse_memory("512") == 512

    def test_none(self) -> None:
        """Test None returns None."""
        assert parse_memory(None) is None

    def test_invalid(self) -> None:
        """Test invalid format raises error."""
        with pytest.raises(ValueError):
            parse_memory("invalid")

    def test_float_values(self) -> None:
        """Test float values are handled."""
        assert parse_memory("1.5G") == 1536
        assert parse_memory("0.5G") == 512


class TestParseCpu:
    """Tests for CPU parsing."""

    def test_integer(self) -> None:
        """Test integer values."""
        assert parse_cpu(2) == 2.0
        assert parse_cpu(4) == 4.0

    def test_float(self) -> None:
        """Test float values."""
        assert parse_cpu(1.5) == 1.5
        assert parse_cpu(0.5) == 0.5

    def test_string_number(self) -> None:
        """Test string numbers."""
        assert parse_cpu("2") == 2.0
        assert parse_cpu("1.5") == 1.5

    def test_cpu_suffix(self) -> None:
        """Test cpu suffix."""
        assert parse_cpu("2cpu") == 2.0
        assert parse_cpu("1.5cpu") == 1.5

    def test_millicores(self) -> None:
        """Test millicore notation."""
        assert parse_cpu("500m") == 0.5
        assert parse_cpu("1000m") == 1.0
        assert parse_cpu("250m") == 0.25

    def test_none(self) -> None:
        """Test None returns None."""
        assert parse_cpu(None) is None


class TestParsePortMapping:
    """Tests for port mapping parsing."""

    def test_simple_mapping(self) -> None:
        """Test simple host:container format."""
        result = parse_port_mapping("8080:80")
        assert result["host"] == 8080
        assert result["container"] == 80
        assert result["protocol"] == "tcp"
        assert result["host_ip"] == "0.0.0.0"

    def test_with_protocol(self) -> None:
        """Test port mapping with protocol."""
        result = parse_port_mapping("53:53/udp")
        assert result["host"] == 53
        assert result["container"] == 53
        assert result["protocol"] == "udp"

    def test_with_host_ip(self) -> None:
        """Test port mapping with host IP."""
        result = parse_port_mapping("127.0.0.1:8080:80")
        assert result["host_ip"] == "127.0.0.1"
        assert result["host"] == 8080
        assert result["container"] == 80

    def test_full_format(self) -> None:
        """Test full format with IP and protocol."""
        result = parse_port_mapping("0.0.0.0:443:8443/tcp")
        assert result["host_ip"] == "0.0.0.0"
        assert result["host"] == 443
        assert result["container"] == 8443
        assert result["protocol"] == "tcp"

    def test_dict_format(self) -> None:
        """Test dict format input."""
        result = parse_port_mapping({"host": 8080, "container": 80, "protocol": "tcp"})
        assert result["host"] == 8080
        assert result["container"] == 80
        assert result["protocol"] == "tcp"

    def test_dict_with_port_names(self) -> None:
        """Test dict with host_port/container_port names."""
        result = parse_port_mapping({"host_port": 8080, "container_port": 80})
        assert result["host"] == 8080
        assert result["container"] == 80


class TestParseVolume:
    """Tests for volume parsing."""

    def test_simple_volume(self) -> None:
        """Test simple host:container format."""
        result = parse_volume("~/projects:/workspace")
        assert result["host"] == "~/projects"
        assert result["container"] == "/workspace"
        assert result["readonly"] is False

    def test_readonly_volume(self) -> None:
        """Test readonly volume."""
        result = parse_volume("~/.ssh:/home/user/.ssh:ro")
        assert result["host"] == "~/.ssh"
        assert result["container"] == "/home/user/.ssh"
        assert result["readonly"] is True

    def test_dict_format(self) -> None:
        """Test dict format input."""
        result = parse_volume({
            "host": "~/data",
            "container": "/data",
            "readonly": True,
        })
        assert result["host"] == "~/data"
        assert result["container"] == "/data"
        assert result["readonly"] is True

    def test_dict_with_source_target(self) -> None:
        """Test dict with source/target names."""
        result = parse_volume({
            "source": "~/data",
            "target": "/data",
        })
        assert result["host"] == "~/data"
        assert result["container"] == "/data"


class TestContainerSpec:
    """Tests for ContainerSpec model."""

    def test_minimal_spec(self) -> None:
        """Test minimal spec with only name."""
        spec = ContainerSpec(name="test")
        assert spec.name == "test"
        assert spec.runtime == "docker"
        assert spec.distro == "trixie"

    def test_full_spec(self) -> None:
        """Test full spec with all fields."""
        spec = ContainerSpec(
            name="full-test",
            runtime="nspawn",
            distro="bookworm",
            profiles=["base", "dev-python"],
            resources={"memory": "4G", "cpus": 2},
            network={"mode": "bridged", "ports": ["8080:80"]},
            volumes=["~/data:/data"],
            environment={"EDITOR": "vim"},
            command=["bash", "-l"],
            working_dir="/workspace",
            privileged=True,
        )
        assert spec.name == "full-test"
        assert spec.runtime == "nspawn"
        assert spec.profiles == ["base", "dev-python"]
        assert spec.resources.memory == "4G"
        assert spec.network.mode == "bridged"

    def test_command_string_conversion(self) -> None:
        """Test command string is converted to list."""
        spec = ContainerSpec(name="test", command="bash -l -c 'echo hello'")
        assert spec.command == ["bash", "-l", "-c", "echo hello"]

    def test_invalid_name_rejected(self) -> None:
        """Test invalid container names are rejected."""
        with pytest.raises(ValueError):
            ContainerSpec(name="-invalid")

        with pytest.raises(ValueError):
            ContainerSpec(name="has space")

    def test_to_container_config(self) -> None:
        """Test conversion to ContainerConfig."""
        spec = ContainerSpec(
            name="test",
            runtime="docker",
            resources={"memory": "2G", "cpus": 1},
            network={"ports": ["8080:80"]},
            volumes=["~/data:/data"],
        )

        config = spec.to_container_config()

        assert config.name == "test"
        assert config.runtime == "docker"
        assert config.resources.memory_mb == 2048
        assert config.resources.cpu_count == 1.0
        assert config.network.ports == {8080: 80}
        assert config.volumes == {"~/data": "/data"}

    def test_from_container_config(self) -> None:
        """Test creation from ContainerConfig."""
        from gaol.models.container import ContainerConfig, NetworkConfig, ResourceLimits

        config = ContainerConfig(
            name="test",
            runtime="docker",
            distro="trixie",
            resources=ResourceLimits(memory_mb=4096, cpu_count=2),
            network=NetworkConfig(ports={8080: 80}),
            volumes={"~/data": "/data"},
        )

        spec = ContainerSpec.from_container_config(config)

        assert spec.name == "test"
        assert spec.resources.memory == "4G"
        assert spec.resources.cpus == 2
        assert "8080:80" in spec.network.ports
        assert "~/data:/data" in spec.volumes


class TestSpecLoader:
    """Tests for SpecLoader."""

    def test_load_yaml(self, tmp_path: Path) -> None:
        """Test loading YAML spec file."""
        spec_file = tmp_path / "test.yaml"
        spec_file.write_text("""
name: test-container
runtime: docker
profiles:
  - base
""")

        loader = SpecLoader(base_dir=tmp_path)
        spec = loader.load(spec_file)

        assert spec.name == "test-container"
        assert spec.runtime == "docker"
        assert spec.profiles == ["base"]

    def test_load_not_found(self, tmp_path: Path) -> None:
        """Test loading non-existent file raises error."""
        loader = SpecLoader(base_dir=tmp_path)

        with pytest.raises(SpecNotFoundError):
            loader.load("nonexistent.yaml")

    def test_load_invalid_yaml(self, tmp_path: Path) -> None:
        """Test loading invalid YAML raises error."""
        spec_file = tmp_path / "invalid.yaml"
        spec_file.write_text("name: [invalid yaml")

        loader = SpecLoader(base_dir=tmp_path)

        with pytest.raises(SpecParseError):
            loader.load(spec_file)

    def test_load_validation_error(self, tmp_path: Path) -> None:
        """Test loading spec with validation errors."""
        spec_file = tmp_path / "invalid.yaml"
        spec_file.write_text("""
name: -invalid-name
""")

        loader = SpecLoader(base_dir=tmp_path)

        with pytest.raises(SpecValidationError):
            loader.load(spec_file)

    def test_env_expansion(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
        """Test environment variable expansion."""
        monkeypatch.setenv("TEST_VAR", "expanded_value")

        spec_file = tmp_path / "test.yaml"
        spec_file.write_text("""
name: test
environment:
  MY_VAR: $TEST_VAR
  DEFAULT_VAR: ${MISSING_VAR:-default}
""")

        loader = SpecLoader(base_dir=tmp_path)
        spec = loader.load(spec_file)

        assert spec.environment["MY_VAR"] == "expanded_value"
        assert spec.environment["DEFAULT_VAR"] == "default"

    def test_env_file_loading(self, tmp_path: Path) -> None:
        """Test loading .env file."""
        env_file = tmp_path / ".env"
        env_file.write_text("""
DATABASE_URL=postgres://localhost/db
SECRET_KEY="my-secret"
export API_KEY=abc123
""")

        spec_file = tmp_path / "test.yaml"
        spec_file.write_text("""
name: test
env_file: .env
environment:
  DB: $DATABASE_URL
""")

        loader = SpecLoader(base_dir=tmp_path)
        spec = loader.load(spec_file)

        assert spec.environment["DB"] == "postgres://localhost/db"

    def test_load_config(self, tmp_path: Path) -> None:
        """Test load_config returns ContainerConfig."""
        spec_file = tmp_path / "test.yaml"
        spec_file.write_text("""
name: test
resources:
  memory: 2G
""")

        loader = SpecLoader(base_dir=tmp_path)
        config = loader.load_config(spec_file)

        assert config.name == "test"
        assert config.resources.memory_mb == 2048


class TestLoadEnvFile:
    """Tests for load_env_file function."""

    def test_basic_env_file(self, tmp_path: Path) -> None:
        """Test loading basic .env file."""
        env_file = tmp_path / ".env"
        env_file.write_text("""
KEY1=value1
KEY2=value2
""")

        env = load_env_file(env_file)

        assert env["KEY1"] == "value1"
        assert env["KEY2"] == "value2"

    def test_quoted_values(self, tmp_path: Path) -> None:
        """Test quoted values."""
        env_file = tmp_path / ".env"
        env_file.write_text('''
DOUBLE="double quoted"
SINGLE='single quoted'
''')

        env = load_env_file(env_file)

        assert env["DOUBLE"] == "double quoted"
        assert env["SINGLE"] == "single quoted"

    def test_comments_ignored(self, tmp_path: Path) -> None:
        """Test comments are ignored."""
        env_file = tmp_path / ".env"
        env_file.write_text("""
# This is a comment
KEY=value
# Another comment
""")

        env = load_env_file(env_file)

        assert "KEY" in env
        assert len(env) == 1

    def test_export_prefix(self, tmp_path: Path) -> None:
        """Test export prefix is handled."""
        env_file = tmp_path / ".env"
        env_file.write_text("""
export EXPORTED=yes
NORMAL=also
""")

        env = load_env_file(env_file)

        assert env["EXPORTED"] == "yes"
        assert env["NORMAL"] == "also"

    def test_nonexistent_file(self, tmp_path: Path) -> None:
        """Test non-existent file returns empty dict."""
        env = load_env_file(tmp_path / "nonexistent")
        assert env == {}


class TestSpecToYaml:
    """Tests for spec_to_yaml function."""

    def test_minimal_spec(self) -> None:
        """Test YAML output for minimal spec."""
        spec = ContainerSpec(name="test")
        yaml_str = spec_to_yaml(spec)

        assert "name: test" in yaml_str
        # runtime is a default so not included when exclude_defaults=True (default)
        assert "privileged" not in yaml_str

    def test_excludes_defaults(self) -> None:
        """Test defaults are excluded."""
        spec = ContainerSpec(name="test")
        yaml_str = spec_to_yaml(spec, include_defaults=False)

        # Empty lists should be excluded
        assert "profiles:" not in yaml_str
        assert "volumes:" not in yaml_str

    def test_includes_defaults(self) -> None:
        """Test defaults are included when requested."""
        spec = ContainerSpec(name="test")
        yaml_str = spec_to_yaml(spec, include_defaults=True)

        # Should include defaults
        assert "runtime: docker" in yaml_str


class TestLoadSpec:
    """Tests for load_spec convenience function."""

    def test_load_spec(self, tmp_path: Path) -> None:
        """Test load_spec function."""
        spec_file = tmp_path / "test.yaml"
        spec_file.write_text("name: test")

        spec = load_spec(spec_file)
        assert spec.name == "test"


class TestExampleSpecs:
    """Tests for example spec files."""

    @pytest.fixture
    def specs_dir(self) -> Path:
        """Get the specs directory."""
        return Path(__file__).parent.parent / "specs"

    def test_dev_env_spec(self, specs_dir: Path) -> None:
        """Test dev-env.yaml is valid."""
        spec_file = specs_dir / "dev-env.yaml"
        if spec_file.exists():
            spec = load_spec(spec_file)
            assert spec.name == "dev-env"
            assert "base" in spec.profiles

    def test_minimal_spec(self, specs_dir: Path) -> None:
        """Test minimal.yaml is valid."""
        spec_file = specs_dir / "minimal.yaml"
        if spec_file.exists():
            spec = load_spec(spec_file)
            assert spec.name == "minimal-example"

    def test_web_server_spec(self, specs_dir: Path) -> None:
        """Test web-server.yaml is valid."""
        spec_file = specs_dir / "web-server.yaml"
        if spec_file.exists():
            spec = load_spec(spec_file)
            assert spec.name == "web-server"
            assert len(spec.network.ports) > 0
