"""Unit tests for the Music DNA Builder.

Tests cover:
- MusicDNA and MusicDNAMetadata immutability
- Normalizer behavior (all strategies return value unchanged)
- MusicDNABuilder happy path, validation, and determinism
- Missing, unknown, duplicate, and invalid feature errors
- Builder metadata correctness
- Registry integration

Tests use synthetic TrackFeature-like objects — no real DB sessions.
"""

from __future__ import annotations

from dataclasses import FrozenInstanceError
from types import MappingProxyType
from unittest.mock import MagicMock

import pytest

from features.default_registry import build_default_feature_registry
from features.definition import (
    DataType,
    FeatureCategory,
    FeatureDefinition,
    NormalizationStrategy,
)
from features.registry import FeatureRegistry
from music_dna.builder import MusicDNABuilder
from music_dna.exceptions import (
    DuplicateFeatureError,
    InvalidValueError,
    MissingFeatureError,
    MusicDNAError,
    UnknownFeatureError,
)
from music_dna.music_dna import MusicDNA, MusicDNAMetadata
from music_dna.normalizer import Normalizer

# --- Helpers ---


def _make_track_feature(
    name: str,
    value: str,
    analyzer_name: str = "test_analyzer",
    analyzer_version: str = "1.0.0",
) -> MagicMock:
    """Create a mock object matching the TrackFeature interface."""
    tf = MagicMock()
    tf.name = name
    tf.value = value
    run = MagicMock()
    run.analyzer_name = analyzer_name
    run.analyzer_version = analyzer_version
    tf.analyzer_run = run
    return tf


def _make_all_features(
    registry: FeatureRegistry,
    analyzer_name: str = "test_analyzer",
    analyzer_version: str = "1.0.0",
) -> list[MagicMock]:
    """Create synthetic TrackFeature objects for every registered feature."""
    features = []
    for definition in registry.list():
        if definition.data_type == DataType.FLOAT:
            value = "0.5"
        elif definition.data_type == DataType.INT:
            value = "44100"
        elif definition.data_type == DataType.STRING:
            value = "A"
        elif definition.data_type == DataType.BOOL:
            value = "true"
        else:
            value = "0"
        features.append(
            _make_track_feature(
                definition.identifier,
                value,
                analyzer_name,
                analyzer_version,
            )
        )
    return features


# --- MusicDNA immutability tests ---


def test_music_dna_is_frozen() -> None:
    """Verify MusicDNA is immutable."""
    metadata = MusicDNAMetadata(
        builder_version="1.0.0",
        feature_count=1,
        normalization_version="1.0.0",
        source_analyzer_versions={"analyzer": "1.0.0"},
    )
    dna = MusicDNA(
        track_id="test-track",
        schema_version="1.0",
        created_at="2025-01-01T00:00:00Z",
        values={"signal.peak": 0.8},
        metadata=metadata,
    )
    with pytest.raises(FrozenInstanceError):
        dna.track_id = "other"  # type: ignore[misc]


def test_music_dna_values_is_readonly() -> None:
    """Verify the values mapping cannot be modified."""
    dna = MusicDNA(
        track_id="test-track",
        schema_version="1.0",
        created_at="2025-01-01T00:00:00Z",
        values={"signal.peak": 0.8},
        metadata=None,
    )
    assert isinstance(dna.values, MappingProxyType)
    with pytest.raises(TypeError):
        dna.values["signal.peak"] = 0.9  # type: ignore[index]


def test_music_dna_metadata_is_frozen() -> None:
    """Verify MusicDNAMetadata is immutable."""
    metadata = MusicDNAMetadata(
        builder_version="1.0.0",
        feature_count=1,
        normalization_version="1.0.0",
        source_analyzer_versions={"analyzer": "1.0.0"},
    )
    with pytest.raises(FrozenInstanceError):
        metadata.builder_version = "2.0.0"  # type: ignore[misc]


def test_music_dna_metadata_source_versions_is_readonly() -> None:
    """Verify the source_analyzer_versions mapping cannot be modified."""
    metadata = MusicDNAMetadata(
        builder_version="1.0.0",
        feature_count=1,
        normalization_version="1.0.0",
        source_analyzer_versions={"analyzer": "1.0.0"},
    )
    assert isinstance(metadata.source_analyzer_versions, MappingProxyType)
    with pytest.raises(TypeError):
        metadata.source_analyzer_versions["new"] = "2.0.0"  # type: ignore[index]


def test_music_dna_stores_all_fields() -> None:
    """Verify all MusicDNA fields are stored correctly."""
    metadata = MusicDNAMetadata(
        builder_version="1.0.0",
        feature_count=2,
        normalization_version="1.0.0",
        source_analyzer_versions={"analyzer": "1.0.0"},
    )
    dna = MusicDNA(
        track_id="track-123",
        schema_version="1.0",
        created_at="2025-01-01T00:00:00Z",
        values={"signal.peak": 0.8, "signal.rms": 0.5},
        metadata=metadata,
    )
    assert dna.track_id == "track-123"
    assert dna.schema_version == "1.0"
    assert dna.created_at == "2025-01-01T00:00:00Z"
    assert dna.values["signal.peak"] == 0.8
    assert dna.values["signal.rms"] == 0.5
    assert dna.metadata is not None
    assert dna.metadata.builder_version == "1.0.0"
    assert dna.metadata.feature_count == 2
    assert dna.metadata.normalization_version == "1.0.0"
    assert dna.metadata.source_analyzer_versions["analyzer"] == "1.0.0"


# --- Normalizer tests ---


@pytest.mark.parametrize(
    "strategy",
    [
        NormalizationStrategy.IDENTITY,
        NormalizationStrategy.CATEGORICAL,
        NormalizationStrategy.NONE,
        NormalizationStrategy.MINMAX,
        NormalizationStrategy.LOG,
        NormalizationStrategy.ZSCORE,
    ],
)
def test_normalizer_returns_value_unchanged(strategy: NormalizationStrategy) -> None:
    """Verify all strategies return the value unchanged (initial impl)."""
    normalizer = Normalizer()
    assert normalizer.normalize(0.5, strategy) == 0.5
    assert normalizer.normalize("A", strategy) == "A"
    assert normalizer.normalize(42, strategy) == 42
    assert normalizer.normalize(True, strategy) is True


def test_normalizer_version() -> None:
    """Verify the normalizer has a version string."""
    assert Normalizer.NORMALIZATION_VERSION == "1.0.0"


# --- Builder happy path tests ---


def test_builder_happy_path() -> None:
    """Verify the builder produces a valid MusicDNA from all features."""
    registry = build_default_feature_registry()
    builder = MusicDNABuilder(registry)
    features = _make_all_features(registry)

    dna = builder.build("track-1", features, "2025-01-01T00:00:00Z")

    assert dna.track_id == "track-1"
    assert dna.schema_version == "1.0"
    assert dna.created_at == "2025-01-01T00:00:00Z"
    assert len(dna.values) == registry.count()
    assert dna.metadata is not None
    assert dna.metadata.feature_count == registry.count()
    assert dna.metadata.builder_version == MusicDNABuilder.BUILDER_VERSION
    assert dna.metadata.normalization_version == Normalizer.NORMALIZATION_VERSION


def test_builder_deserializes_float_values() -> None:
    """Verify float values are deserialized from strings."""
    registry = build_default_feature_registry()
    builder = MusicDNABuilder(registry)
    features = _make_all_features(registry)

    dna = builder.build("track-1", features, "2025-01-01T00:00:00Z")

    peak = dna.values["signal.peak"]
    assert isinstance(peak, float)
    assert peak == 0.5


def test_builder_deserializes_int_values() -> None:
    """Verify int values are deserialized from strings."""
    registry = build_default_feature_registry()
    builder = MusicDNABuilder(registry)
    features = _make_all_features(registry)

    dna = builder.build("track-1", features, "2025-01-01T00:00:00Z")

    sample_rate = dna.values["signal.sample_rate"]
    assert isinstance(sample_rate, int)
    assert sample_rate == 44100


def test_builder_deserializes_string_values() -> None:
    """Verify string values are kept as strings."""
    registry = build_default_feature_registry()
    builder = MusicDNABuilder(registry)
    features = _make_all_features(registry)

    dna = builder.build("track-1", features, "2025-01-01T00:00:00Z")

    key = dna.values["harmony.key"]
    assert isinstance(key, str)
    assert key == "A"


def test_builder_source_analyzer_versions() -> None:
    """Verify source_analyzer_versions is populated from features."""
    registry = build_default_feature_registry()
    builder = MusicDNABuilder(registry)
    features = _make_all_features(
        registry,
        analyzer_name="basic_signal",
        analyzer_version="2.0.0",
    )

    dna = builder.build("track-1", features, "2025-01-01T00:00:00Z")

    assert dna.metadata is not None
    assert "basic_signal" in dna.metadata.source_analyzer_versions
    assert dna.metadata.source_analyzer_versions["basic_signal"] == "2.0.0"


def test_builder_deterministic_output() -> None:
    """Verify same input produces identical MusicDNA."""
    registry = build_default_feature_registry()
    builder = MusicDNABuilder(registry)
    features1 = _make_all_features(registry)
    features2 = _make_all_features(registry)

    dna1 = builder.build("track-1", features1, "2025-01-01T00:00:00Z")
    dna2 = builder.build("track-1", features2, "2025-01-01T00:00:00Z")

    assert dna1.track_id == dna2.track_id
    assert dna1.schema_version == dna2.schema_version
    assert dna1.created_at == dna2.created_at
    assert dict(dna1.values) == dict(dna2.values)
    assert dna1.metadata == dna2.metadata


# --- Builder validation tests ---


def test_builder_missing_feature_raises() -> None:
    """Verify MissingFeatureError when a registered feature is absent."""
    registry = build_default_feature_registry()
    builder = MusicDNABuilder(registry)
    features = _make_all_features(registry)
    features = [f for f in features if f.name != "signal.peak"]

    with pytest.raises(MissingFeatureError, match="signal.peak"):
        builder.build("track-1", features, "2025-01-01T00:00:00Z")


def test_builder_unknown_feature_raises() -> None:
    """Verify UnknownFeatureError for unregistered feature names."""
    registry = build_default_feature_registry()
    builder = MusicDNABuilder(registry)
    features = _make_all_features(registry)
    features.append(_make_track_feature("unknown.feature", "0.5"))

    with pytest.raises(UnknownFeatureError, match="unknown.feature"):
        builder.build("track-1", features, "2025-01-01T00:00:00Z")


def test_builder_duplicate_feature_raises() -> None:
    """Verify DuplicateFeatureError for repeated feature names."""
    registry = build_default_feature_registry()
    builder = MusicDNABuilder(registry)
    features = _make_all_features(registry)
    dup = _make_track_feature("signal.peak", "0.5")
    features.append(dup)

    with pytest.raises(DuplicateFeatureError, match="signal.peak"):
        builder.build("track-1", features, "2025-01-01T00:00:00Z")


def test_builder_none_value_raises() -> None:
    """Verify InvalidValueError when a feature value is None."""
    registry = build_default_feature_registry()
    builder = MusicDNABuilder(registry)
    features = _make_all_features(registry)
    for f in features:
        if f.name == "signal.peak":
            f.value = None
            break

    with pytest.raises(InvalidValueError, match="signal.peak"):
        builder.build("track-1", features, "2025-01-01T00:00:00Z")


def test_builder_invalid_float_raises() -> None:
    """Verify InvalidValueError when a float value can't be parsed."""
    registry = build_default_feature_registry()
    builder = MusicDNABuilder(registry)
    features = _make_all_features(registry)
    for f in features:
        if f.name == "signal.peak":
            f.value = "not_a_float"
            break

    with pytest.raises(InvalidValueError, match="signal.peak"):
        builder.build("track-1", features, "2025-01-01T00:00:00Z")


def test_builder_invalid_int_raises() -> None:
    """Verify InvalidValueError when an int value can't be parsed."""
    registry = build_default_feature_registry()
    builder = MusicDNABuilder(registry)
    features = _make_all_features(registry)
    for f in features:
        if f.name == "signal.sample_rate":
            f.value = "not_an_int"
            break

    with pytest.raises(InvalidValueError, match="signal.sample_rate"):
        builder.build("track-1", features, "2025-01-01T00:00:00Z")


def test_builder_all_errors_are_music_dna_errors() -> None:
    """Verify all builder exceptions inherit from MusicDNAError."""
    assert issubclass(MissingFeatureError, MusicDNAError)
    assert issubclass(UnknownFeatureError, MusicDNAError)
    assert issubclass(DuplicateFeatureError, MusicDNAError)
    assert issubclass(InvalidValueError, MusicDNAError)


# --- Builder with custom registry tests ---


def test_builder_with_small_custom_registry() -> None:
    """Verify builder works with a minimal custom registry."""
    registry = FeatureRegistry()
    registry.register(
        FeatureDefinition(
            identifier="test.foo",
            display_name="Test Foo",
            category=FeatureCategory.SIGNAL,
            description="A test feature.",
            unit=None,
            data_type=DataType.FLOAT,
            normalization=NormalizationStrategy.IDENTITY,
            version="1.0.0",
        )
    )
    builder = MusicDNABuilder(registry)
    features = [_make_track_feature("test.foo", "0.42")]

    dna = builder.build("track-x", features, "2025-01-01T00:00:00Z")

    assert dna.values["test.foo"] == 0.42
    assert dna.metadata is not None
    assert dna.metadata.feature_count == 1


def test_builder_no_features_raises_missing() -> None:
    """Verify builder with empty feature list raises MissingFeatureError."""
    registry = build_default_feature_registry()
    builder = MusicDNABuilder(registry)

    with pytest.raises(MissingFeatureError):
        builder.build("track-1", [], "2025-01-01T00:00:00Z")


def test_builder_default_normalizer_when_none_provided() -> None:
    """Verify builder creates a default Normalizer when none is passed."""
    registry = build_default_feature_registry()
    builder = MusicDNABuilder(registry)
    assert builder.normalizer is not None
    assert builder.normalizer.NORMALIZATION_VERSION == "1.0.0"


# --- Registry integration tests ---


def test_builder_values_match_registry_identifiers() -> None:
    """Verify every value key matches a registered feature identifier."""
    registry = build_default_feature_registry()
    builder = MusicDNABuilder(registry)
    features = _make_all_features(registry)

    dna = builder.build("track-1", features, "2025-01-01T00:00:00Z")

    registered_ids = {d.identifier for d in registry.list()}
    assert set(dna.values.keys()) == registered_ids
