from pathlib import Path

import pytest

from vcti.config.errors import ConfigErrors, DecodeError, MissingFieldError, ValidationError
from vcti.config.field import Field
from vcti.config.schema import ConfigSchema


class TestSchemaBasicResolution:
    """Tests for basic field resolution from env."""

    def test_resolves_from_env(self):
        class Cfg(ConfigSchema):
            ENV_PREFIX = "TEST_"
            port = Field.int(default=3000)

        config = Cfg.load(env={"TEST_PORT": "8080"})
        assert config.port == 8080

    def test_uses_default_when_env_missing(self):
        class Cfg(ConfigSchema):
            ENV_PREFIX = "TEST_"
            host = Field.str(default="localhost")

        config = Cfg.load(env={})
        assert config.host == "localhost"

    def test_all_field_types(self):
        class Cfg(ConfigSchema):
            ENV_PREFIX = "APP_"
            debug = Field.bool(default=False)
            port = Field.int(default=3000)
            rate = Field.float(default=1.0)
            name = Field.str(default="app")
            data = Field.path(default=Path("/data"))
            meta = Field.json(default={})

        env = {
            "APP_DEBUG": "true",
            "APP_PORT": "9090",
            "APP_RATE": "2.5",
            "APP_NAME": "myapp",
            "APP_DATA": "/var/data",
            "APP_META": '{"k": "v"}',
        }
        config = Cfg.load(env=env)
        assert config.debug is True
        assert config.port == 9090
        assert config.rate == 2.5
        assert config.name == "myapp"
        assert config.data == Path("/var/data")
        assert config.meta == {"k": "v"}


class TestSchemaOverrides:
    """Tests for the overrides parameter."""

    def test_override_takes_precedence_over_env(self):
        class Cfg(ConfigSchema):
            ENV_PREFIX = "TEST_"
            port = Field.int(default=3000)

        config = Cfg.load(overrides={"port": 9999}, env={"TEST_PORT": "8080"})
        assert config.port == 9999

    def test_override_takes_precedence_over_default(self):
        class Cfg(ConfigSchema):
            ENV_PREFIX = "TEST_"
            host = Field.str(default="localhost")

        config = Cfg.load(overrides={"host": "0.0.0.0"}, env={})
        assert config.host == "0.0.0.0"

    def test_override_skips_decoding(self):
        """Override values are already typed — no decoder is called."""

        class Cfg(ConfigSchema):
            port = Field.int(default=3000)

        config = Cfg.load(overrides={"port": 8080}, env={})
        assert config.port == 8080


class TestSchemaOverrideNone:
    """Tests for override with None values."""

    def test_override_none_falls_through_to_env(self):
        """overrides={"field": None} is treated as 'not provided',
        falling through to env value."""

        class Cfg(ConfigSchema):
            ENV_PREFIX = "TEST_"
            port = Field.int(default=3000)

        config = Cfg.load(overrides={"port": None}, env={"TEST_PORT": "8080"})
        assert config.port == 8080

    def test_override_none_falls_through_to_default(self):
        """overrides={"field": None} falls through to default when env is also missing."""

        class Cfg(ConfigSchema):
            ENV_PREFIX = "TEST_"
            host = Field.str(default="localhost")

        config = Cfg.load(overrides={"host": None}, env={})
        assert config.host == "localhost"


class TestSchemaPrefix:
    """Tests for ENV_PREFIX behavior."""

    def test_no_prefix(self):
        class Cfg(ConfigSchema):
            timeout = Field.int(default=30)

        config = Cfg.load(env={"TIMEOUT": "60"})
        assert config.timeout == 60

    def test_prefix_applied(self):
        class Cfg(ConfigSchema):
            ENV_PREFIX = "SVC_"
            name = Field.str(default="default")

        config = Cfg.load(env={"SVC_NAME": "myservice"})
        assert config.name == "myservice"


class TestSchemaEnvNameOverride:
    """Tests for Field.env_name override."""

    def test_env_name_overrides_auto_generated_name(self):
        class Cfg(ConfigSchema):
            ENV_PREFIX = "APP_"
            port = Field.int(default=3000, env_name="CUSTOM_PORT")

        config = Cfg.load(env={"CUSTOM_PORT": "7777"})
        assert config.port == 7777

    def test_env_name_prefix_not_applied(self):
        """When env_name is set, ENV_PREFIX is not prepended."""

        class Cfg(ConfigSchema):
            ENV_PREFIX = "APP_"
            port = Field.int(default=3000, env_name="CUSTOM_PORT")

        # APP_PORT should be ignored when env_name is set
        config = Cfg.load(env={"APP_PORT": "9999"})
        assert config.port == 3000  # falls through to default


class TestSchemaValidation:
    """Tests for field validation."""

    def test_valid_passes(self):
        class Cfg(ConfigSchema):
            port = Field.int(default=8080, validator=lambda p: 1 <= p <= 65535)

        config = Cfg.load(env={"PORT": "443"})
        assert config.port == 443

    def test_invalid_collected(self):
        class Cfg(ConfigSchema):
            port = Field.int(default=8080, validator=lambda p: 1 <= p <= 65535)

        with pytest.raises(ConfigErrors) as exc_info:
            Cfg.load(env={"PORT": "0"})

        errors = exc_info.value.errors
        assert len(errors) == 1
        assert isinstance(errors[0], ValidationError)
        assert errors[0].field_name == "port"

    def test_validator_on_default(self):
        """Validator runs on defaults too."""

        class Cfg(ConfigSchema):
            port = Field.int(default=0, validator=lambda p: p > 0)

        with pytest.raises(ConfigErrors) as exc_info:
            Cfg.load(env={})

        assert isinstance(exc_info.value.errors[0], ValidationError)

    def test_validator_exception_becomes_validation_error(self):
        """If the validator raises, it's wrapped in ValidationError."""

        class Cfg(ConfigSchema):
            name = Field.str(default="ok", validator=lambda v: v.some_method())

        with pytest.raises(ConfigErrors) as exc_info:
            Cfg.load(env={})

        assert isinstance(exc_info.value.errors[0], ValidationError)
        assert "AttributeError" in str(exc_info.value.errors[0])


class TestSchemaErrorCollection:
    """Tests for collecting multiple errors."""

    def test_multiple_errors_collected(self):
        class Cfg(ConfigSchema):
            ENV_PREFIX = "APP_"
            port = Field.int()
            host = Field.str()

        with pytest.raises(ConfigErrors) as exc_info:
            Cfg.load(env={})

        errors = exc_info.value.errors
        assert len(errors) == 2
        names = {e.field_name for e in errors}
        assert names == {"port", "host"}

    def test_mixed_error_types(self):
        class Cfg(ConfigSchema):
            ENV_PREFIX = "APP_"
            port = Field.int()  # missing → MissingFieldError
            rate = Field.float(default=1.0)  # present but bad → DecodeError

        with pytest.raises(ConfigErrors) as exc_info:
            Cfg.load(env={"APP_RATE": "not_a_float"})

        errors = exc_info.value.errors
        assert len(errors) == 2
        types = {type(e) for e in errors}
        assert types == {MissingFieldError, DecodeError}

    def test_decode_and_validation_errors(self):
        class Cfg(ConfigSchema):
            ENV_PREFIX = "APP_"
            port = Field.int(validator=lambda p: 1 <= p <= 65535)
            name = Field.str()

        with pytest.raises(ConfigErrors) as exc_info:
            Cfg.load(env={"APP_PORT": "99999"})

        errors = exc_info.value.errors
        assert len(errors) == 2
        types = {type(e) for e in errors}
        assert types == {ValidationError, MissingFieldError}


class TestSchemaMissingField:
    """Tests for MissingFieldError."""

    def test_no_default_no_env_raises(self):
        class Cfg(ConfigSchema):
            ENV_PREFIX = "APP_"
            host = Field.str()

        with pytest.raises(ConfigErrors) as exc_info:
            Cfg.load(env={})

        errors = exc_info.value.errors
        assert len(errors) == 1
        assert isinstance(errors[0], MissingFieldError)
        assert errors[0].field_name == "host"
        assert errors[0].env_name == "APP_HOST"

    def test_none_default_is_valid(self):
        """default=None is a valid default, so no error."""

        class Cfg(ConfigSchema):
            meta = Field.json(default=None)

        config = Cfg.load(env={})
        assert config.meta is None


class TestSchemaDecodeError:
    """Tests for decode errors during load."""

    def test_invalid_int(self):
        class Cfg(ConfigSchema):
            port = Field.int(default=3000)

        with pytest.raises(ConfigErrors) as exc_info:
            Cfg.load(env={"PORT": "abc"})

        errors = exc_info.value.errors
        assert len(errors) == 1
        assert isinstance(errors[0], DecodeError)
        assert errors[0].field_name == "port"

    def test_invalid_bool(self):
        class Cfg(ConfigSchema):
            debug = Field.bool(default=False)

        with pytest.raises(ConfigErrors) as exc_info:
            Cfg.load(env={"DEBUG": "maybe"})

        assert isinstance(exc_info.value.errors[0], DecodeError)


class TestSchemaSensitiveField:
    """Tests for sensitive field masking in errors."""

    def test_decode_error_masks_raw_value(self):
        """Sensitive field's raw value and message are masked in DecodeError."""

        class Cfg(ConfigSchema):
            api_key = Field.int(sensitive=True)

        with pytest.raises(ConfigErrors) as exc_info:
            Cfg.load(env={"API_KEY": "not-an-int-secret"})

        err = exc_info.value.errors[0]
        assert isinstance(err, DecodeError)
        assert err.raw_value == "***"
        assert "not-an-int-secret" not in str(err)
        assert "value masked" in str(err)

    def test_validation_error_masks_value(self):
        """Sensitive field's value is replaced with '***' in ValidationError."""

        class Cfg(ConfigSchema):
            secret = Field.str(sensitive=True, validator=lambda v: len(v) >= 32)

        with pytest.raises(ConfigErrors) as exc_info:
            Cfg.load(env={"SECRET": "short"})

        err = exc_info.value.errors[0]
        assert isinstance(err, ValidationError)
        assert err.value == "***"
        assert "short" not in str(err)

    def test_non_sensitive_decode_error_shows_raw_value(self):
        """Non-sensitive field's raw value is preserved in DecodeError."""

        class Cfg(ConfigSchema):
            port = Field.int()

        with pytest.raises(ConfigErrors) as exc_info:
            Cfg.load(env={"PORT": "abc"})

        err = exc_info.value.errors[0]
        assert isinstance(err, DecodeError)
        assert err.raw_value == "abc"

    def test_non_sensitive_validation_error_shows_value(self):
        """Non-sensitive field's value is preserved in ValidationError."""

        class Cfg(ConfigSchema):
            port = Field.int(default=0, validator=lambda p: p > 0)

        with pytest.raises(ConfigErrors) as exc_info:
            Cfg.load(env={})

        err = exc_info.value.errors[0]
        assert isinstance(err, ValidationError)
        assert err.value == 0

    def test_repr_masks_sensitive_field(self):
        """Sensitive field values are masked as '***' in repr."""

        class Cfg(ConfigSchema):
            host = Field.str(default="localhost")
            token = Field.str(default="secret-abc", sensitive=True)

        config = Cfg.load(env={})
        r = repr(config)
        assert "host='localhost'" in r
        assert "token='***'" in r
        assert "secret-abc" not in r


class TestSchemaRepr:
    """Tests for ConfigSchema.__repr__."""

    def test_repr(self):
        class Cfg(ConfigSchema):
            host = Field.str(default="localhost")
            port = Field.int(default=3000)

        config = Cfg.load(env={})
        r = repr(config)
        assert "Cfg(" in r
        assert "host='localhost'" in r
        assert "port=3000" in r


class TestSchemaInheritance:
    """Tests for schema inheritance."""

    def test_inherits_fields(self):
        class Base(ConfigSchema):
            ENV_PREFIX = "APP_"
            debug = Field.bool(default=False)

        class Extended(Base):
            port = Field.int(default=3000)

        config = Extended.load(env={"APP_DEBUG": "true", "APP_PORT": "9090"})
        assert config.debug is True
        assert config.port == 9090

    def test_child_overrides_field(self):
        class Base(ConfigSchema):
            port = Field.int(default=3000)

        class Child(Base):
            port = Field.int(default=9090)

        config = Child.load(env={})
        assert config.port == 9090


class TestSchemaEmpty:
    """Tests for schema with no fields."""

    def test_empty_schema_loads(self):
        class Cfg(ConfigSchema):
            pass

        config = Cfg.load(env={})
        assert isinstance(config, Cfg)

    def test_empty_schema_repr(self):
        class Cfg(ConfigSchema):
            pass

        config = Cfg.load(env={})
        assert repr(config) == "Cfg()"
