"""Unit tests for the calibrator module — role-based mix calibration."""
from __future__ import annotations

import pytest


# ---------------------------------------------------------------------------
# Phase 1.1: Presets data structure
# ---------------------------------------------------------------------------

class TestPresets:
    """Verify that all preset dictionaries exist with correct structure."""

    def test_volume_presets_has_all_roles(self):
        """VOLUME_PRESETS must contain all 7 roles with float values."""
        from src.calibrator.presets import VOLUME_PRESETS

        assert isinstance(VOLUME_PRESETS, dict)
        expected_roles = {"drumloop", "bass", "chords", "lead", "clap", "pad", "perc"}
        assert set(VOLUME_PRESETS.keys()) == expected_roles
        for role, vol in VOLUME_PRESETS.items():
            assert isinstance(vol, float), f"Volume for {role} should be float"
            assert 0.0 <= vol <= 1.0, f"Volume for {role} must be 0-1, got {vol}"

    def test_volume_presets_correct_values(self):
        """VOLUME_PRESETS values must match the calibrated targets."""
        from src.calibrator.presets import VOLUME_PRESETS

        assert VOLUME_PRESETS["drumloop"] == 0.85
        assert VOLUME_PRESETS["bass"] == 0.82
        assert VOLUME_PRESETS["chords"] == 0.75
        assert VOLUME_PRESETS["lead"] == 0.80
        assert VOLUME_PRESETS["clap"] == 0.78
        assert VOLUME_PRESETS["pad"] == 0.70
        assert VOLUME_PRESETS["perc"] == 0.80

    def test_eq_presets_has_all_roles(self):
        """EQ_PRESETS must contain the 7 roles with ReaEQ param dicts."""
        from src.calibrator.presets import EQ_PRESETS

        expected_roles = {"drumloop", "bass", "chords", "lead", "clap", "pad", "perc"}
        assert set(EQ_PRESETS.keys()) == expected_roles
        for role, params in EQ_PRESETS.items():
            assert isinstance(params, dict), f"EQ for {role} should be dict"
            assert 0 in params, f"EQ for {role} must have slot 0 (enabled)"
            assert 1 in params, f"EQ for {role} must have slot 1 (filter type)"
            assert 2 in params, f"EQ for {role} must have slot 2 (frequency)"

    def test_eq_presets_hpf_lpf(self):
        """EQ_PRESETS must have correct HPF/LPF assignments."""
        from src.calibrator.presets import EQ_PRESETS

        # HPF at 60Hz for drums
        assert EQ_PRESETS["drumloop"][1] == 1  # HPF type
        assert EQ_PRESETS["drumloop"][2] == 60.0

        # LPF at 300Hz for bass
        assert EQ_PRESETS["bass"][1] == 0  # LPF type
        assert EQ_PRESETS["bass"][2] == 300.0

        # HPF at 200Hz for chords, lead, clap
        for role in ("chords", "lead", "clap", "perc"):
            assert EQ_PRESETS[role][1] == 1, f"{role} should be HPF"
            assert EQ_PRESETS[role][2] == 200.0, f"{role} HPF should be 200Hz"

        # HPF at 100Hz for pad
        assert EQ_PRESETS["pad"][1] == 1  # HPF type
        assert EQ_PRESETS["pad"][2] == 100.0

    def test_pan_presets_has_all_roles(self):
        """PAN_PRESETS must contain all 7 roles with float pan values."""
        from src.calibrator.presets import PAN_PRESETS

        expected_roles = {"drumloop", "bass", "chords", "lead", "clap", "pad", "perc"}
        assert set(PAN_PRESETS.keys()) == expected_roles
        for role, pan in PAN_PRESETS.items():
            assert isinstance(pan, float), f"Pan for {role} should be float"
            assert -1.0 <= pan <= 1.0, f"Pan for {role} must be -1 to 1, got {pan}"

    def test_pan_presets_correct_values(self):
        """PAN_PRESETS must match spec values."""
        from src.calibrator.presets import PAN_PRESETS

        assert PAN_PRESETS["drumloop"] == 0.0
        assert PAN_PRESETS["bass"] == 0.0
        assert PAN_PRESETS["chords"] == 0.5
        assert PAN_PRESETS["lead"] == 0.3
        assert PAN_PRESETS["clap"] == -0.15
        assert PAN_PRESETS["pad"] == -0.5
        assert PAN_PRESETS["perc"] == 0.12

    def test_send_presets_has_all_roles(self):
        """SEND_PRESETS must contain all 7 roles with (reverb, delay) tuples."""
        from src.calibrator.presets import SEND_PRESETS

        expected_roles = {"drumloop", "bass", "chords", "lead", "clap", "pad", "perc"}
        assert set(SEND_PRESETS.keys()) == expected_roles
        for role, sends in SEND_PRESETS.items():
            assert isinstance(sends, tuple), f"Sends for {role} should be tuple"
            assert len(sends) == 2, f"Sends for {role} should be (reverb, delay)"
            assert all(0.0 <= s <= 1.0 for s in sends), f"Sends out of range for {role}"

    def test_send_presets_correct_values(self):
        """SEND_PRESETS must match task values with spec fallback."""
        from src.calibrator.presets import SEND_PRESETS

        assert SEND_PRESETS["drumloop"] == (0.10, 0.00)
        assert SEND_PRESETS["bass"] == (0.05, 0.00)     # task override: delay 0.0
        assert SEND_PRESETS["chords"] == (0.40, 0.10)    # task value
        assert SEND_PRESETS["lead"] == (0.30, 0.15)      # task override: reverb 0.30
        assert SEND_PRESETS["clap"] == (0.10, 0.00)
        assert SEND_PRESETS["pad"] == (0.50, 0.20)       # task value
        assert SEND_PRESETS["perc"] == (0.10, 0.00)


# ---------------------------------------------------------------------------
# Phase 1.2: SongMeta.calibrate field
# ---------------------------------------------------------------------------

class TestSongMetaCalibrate:
    """Verify SongMeta has calibrate: bool = True field."""

    def test_song_meta_default_calibrate_true(self):
        """SongMeta should default calibrate to True."""
        from src.core.schema import SongMeta

        meta = SongMeta(bpm=95, key="Am")
        assert meta.calibrate is True

    def test_song_meta_calibrate_false(self):
        """SongMeta should accept calibrate=False."""
        from src.core.schema import SongMeta

        meta = SongMeta(bpm=95, key="Am", calibrate=False)
        assert meta.calibrate is False

    def test_song_meta_serialization_includes_calibrate(self):
        """to_json should include the calibrate field."""
        from src.core.schema import SongMeta

        meta = SongMeta(bpm=95, key="Am")
        json_str = meta.__dict__
        assert "calibrate" in json_str
        assert json_str["calibrate"] is True


# ---------------------------------------------------------------------------
# Phase 1.3: Calibrator._resolve_role()
# ---------------------------------------------------------------------------

class TestResolveRole:
    """Verify track name → role key mapping."""

    @staticmethod
    def _cal():
        from src.calibrator import Calibrator
        return Calibrator

    def test_drumloop_roles(self):
        """Drumloop track names should resolve to drumloop role."""
        C = self._cal()
        assert C._resolve_role("Drumloop") == "drumloop"
        assert C._resolve_role("drumloop") == "drumloop"

    def test_bass_roles(self):
        """Bass track names should resolve to bass role."""
        C = self._cal()
        assert C._resolve_role("808 Bass") == "bass"
        assert C._resolve_role("808 bass") == "bass"
        assert C._resolve_role("bass") == "bass"

    def test_chords_role(self):
        C = self._cal()
        assert C._resolve_role("Chords") == "chords"
        assert C._resolve_role("chords") == "chords"

    def test_lead_role(self):
        C = self._cal()
        assert C._resolve_role("Lead") == "lead"
        assert C._resolve_role("lead") == "lead"

    def test_clap_role(self):
        C = self._cal()
        assert C._resolve_role("Clap") == "clap"
        assert C._resolve_role("clap") == "clap"

    def test_pad_role(self):
        C = self._cal()
        assert C._resolve_role("Pad") == "pad"
        assert C._resolve_role("pad") == "pad"

    def test_perc_role(self):
        C = self._cal()
        assert C._resolve_role("Perc") == "perc"
        assert C._resolve_role("perc") == "perc"

    def test_return_tracks_return_none(self):
        """Return tracks (Reverb, Delay) should return None."""
        C = self._cal()
        assert C._resolve_role("Reverb") is None
        assert C._resolve_role("Delay") is None

    def test_unknown_track_returns_none(self):
        """Unknown track names should return None."""
        C = self._cal()
        assert C._resolve_role("Unknown") is None
        assert C._resolve_role("Vocals") is None
        assert C._resolve_role("") is None


# ---------------------------------------------------------------------------
# Phase 2: Core Calibrator methods
# ---------------------------------------------------------------------------

import pytest as _pytest
from src.core.schema import (
    SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote, PluginDef,
)


def _make_fixture_song() -> SongDefinition:
    """Create a SongDefinition with representative tracks."""
    meta = SongMeta(bpm=95, key="Am")
    tracks = [
        TrackDef(name="Drumloop", volume=0.85, plugins=[]),
        TrackDef(name="Perc", volume=0.78, plugins=[]),
        TrackDef(name="808 Bass", volume=0.72, plugins=[]),
        TrackDef(name="Chords", volume=0.70, plugins=[]),
        TrackDef(name="Lead", volume=0.75, plugins=[]),
        TrackDef(name="Clap", volume=0.80, plugins=[]),
        TrackDef(name="Pad", volume=0.65, plugins=[]),
        TrackDef(name="Reverb", volume=0.80, plugins=[]),
        TrackDef(name="Delay", volume=0.80, plugins=[]),
    ]
    return SongDefinition(
        meta=meta,
        tracks=tracks,
        master_plugins=["Pro-Q_3", "Pro-C_2", "Pro-L_2"],
    )


class TestCalibrateVolumes:
    def test_volumes_calibrated_to_presets(self):
        """_calibrate_volumes should set volume from VOLUME_PRESETS by role."""
        from src.calibrator import Calibrator

        song = _make_fixture_song()
        Calibrator._calibrate_volumes(song)

        volumes = {t.name: t.volume for t in song.tracks}
        assert volumes["Drumloop"] == 0.85
        assert volumes["808 Bass"] == 0.82
        assert volumes["Chords"] == 0.75
        assert volumes["Lead"] == 0.80
        assert volumes["Clap"] == 0.78
        assert volumes["Pad"] == 0.70
        assert volumes["Perc"] == 0.80

    def test_return_tracks_volume_unchanged(self):
        """Return tracks should not have their volume modified."""
        from src.calibrator import Calibrator

        song = _make_fixture_song()
        Calibrator._calibrate_volumes(song)

        rev = [t for t in song.tracks if t.name == "Reverb"][0]
        dly = [t for t in song.tracks if t.name == "Delay"][0]
        assert rev.volume == 0.80  # unchanged
        assert dly.volume == 0.80  # unchanged

    def test_unknown_role_volume_unchanged(self):
        """Tracks with unknown role should keep their original volume."""
        from src.calibrator import Calibrator

        meta = SongMeta(bpm=95, key="Am")
        song = SongDefinition(
            meta=meta,
            tracks=[TrackDef(name="UnknownThing", volume=0.42)],
        )
        Calibrator._calibrate_volumes(song)
        assert song.tracks[0].volume == 0.42  # unchanged


class TestCalibratePans:
    def test_pans_calibrated_to_presets(self):
        """_calibrate_pans should set pan from PAN_PRESETS."""
        from src.calibrator import Calibrator

        song = _make_fixture_song()
        Calibrator._calibrate_pans(song)

        pans = {t.name: t.pan for t in song.tracks}
        assert pans["Drumloop"] == 0.0
        assert pans["808 Bass"] == 0.0
        assert pans["Chords"] == 0.5
        assert pans["Lead"] == 0.3
        assert pans["Clap"] == -0.15
        assert pans["Pad"] == -0.5
        assert pans["Perc"] == 0.12

    def test_return_tracks_pan_unchanged(self):
        """Return tracks should keep their original pan."""
        from src.calibrator import Calibrator

        song = _make_fixture_song()
        # Give return tracks non-zero pans to verify they're preserved
        for t in song.tracks:
            if t.name in ("Reverb", "Delay"):
                t.pan = 0.5
        Calibrator._calibrate_pans(song)

        rev = [t for t in song.tracks if t.name == "Reverb"][0]
        dly = [t for t in song.tracks if t.name == "Delay"][0]
        assert rev.pan == 0.5
        assert dly.pan == 0.5

    def test_unknown_role_pan_unchanged(self):
        """Unknown roles should keep original pan."""
        from src.calibrator import Calibrator

        meta = SongMeta(bpm=95, key="Am")
        song = SongDefinition(
            meta=meta,
            tracks=[TrackDef(name="Vocals", pan=0.75)],
        )
        Calibrator._calibrate_pans(song)
        assert song.tracks[0].pan == 0.75


class TestCalibrateSends:
    def test_sends_calibrated_to_presets(self):
        """_calibrate_sends should set send_level dict from SEND_PRESETS."""
        from src.calibrator import Calibrator

        song = _make_fixture_song()
        # 7 content tracks + 2 return tracks = 9 total
        # Reverb is at index 7, Delay at index 8
        Calibrator._calibrate_sends(song)

        drum = [t for t in song.tracks if t.name == "Drumloop"][0]
        assert drum.send_level.get(7) == 0.10
        assert drum.send_level.get(8) == 0.00

        bass = [t for t in song.tracks if t.name == "808 Bass"][0]
        assert bass.send_level.get(7) == 0.05
        assert bass.send_level.get(8) == 0.00

        chords = [t for t in song.tracks if t.name == "Chords"][0]
        assert chords.send_level.get(7) == 0.40
        assert chords.send_level.get(8) == 0.10

        lead = [t for t in song.tracks if t.name == "Lead"][0]
        assert lead.send_level.get(7) == 0.30
        assert lead.send_level.get(8) == 0.15

        clap = [t for t in song.tracks if t.name == "Clap"][0]
        assert clap.send_level.get(7) == 0.10
        assert clap.send_level.get(8) == 0.00

        pad = [t for t in song.tracks if t.name == "Pad"][0]
        assert pad.send_level.get(7) == 0.50
        assert pad.send_level.get(8) == 0.20

        perc = [t for t in song.tracks if t.name == "Perc"][0]
        assert perc.send_level.get(7) == 0.10
        assert perc.send_level.get(8) == 0.00

    def test_return_tracks_sends_unchanged(self):
        """Return tracks should not have sends set."""
        from src.calibrator import Calibrator

        song = _make_fixture_song()
        Calibrator._calibrate_sends(song)

        rev = [t for t in song.tracks if t.name == "Reverb"][0]
        dly = [t for t in song.tracks if t.name == "Delay"][0]
        assert rev.send_level == {}
        assert dly.send_level == {}


class TestCalibrateEq:
    def test_reaeq_plugin_prepended(self):
        """_calibrate_eq should prepend ReaEQ with HPF/LPF params to non-return tracks."""
        from src.calibrator import Calibrator

        song = _make_fixture_song()
        Calibrator._calibrate_eq(song)

        # Drumloop: HPF 60Hz
        drum = [t for t in song.tracks if t.name == "Drumloop"][0]
        assert len(drum.plugins) >= 1
        eq = drum.plugins[0]
        assert eq.name == "ReaEQ"
        assert eq.params[0] == 1
        assert eq.params[1] == 1  # HPF
        assert eq.params[2] == 60.0

        # Bass: LPF 300Hz
        bass = [t for t in song.tracks if t.name == "808 Bass"][0]
        eq = bass.plugins[0]
        assert eq.name == "ReaEQ"
        assert eq.params[1] == 0  # LPF
        assert eq.params[2] == 300.0

        # Chords: HPF 200Hz
        chords = [t for t in song.tracks if t.name == "Chords"][0]
        eq = chords.plugins[0]
        assert eq.params[1] == 1  # HPF
        assert eq.params[2] == 200.0

        # Pad: HPF 100Hz
        pad = [t for t in song.tracks if t.name == "Pad"][0]
        eq = pad.plugins[0]
        assert eq.params[1] == 1  # HPF
        assert eq.params[2] == 100.0

    def test_return_tracks_no_reaeq(self):
        """Return tracks should not get ReaEQ plugins."""
        from src.calibrator import Calibrator

        song = _make_fixture_song()
        Calibrator._calibrate_eq(song)

        rev = [t for t in song.tracks if t.name == "Reverb"][0]
        dly = [t for t in song.tracks if t.name == "Delay"][0]
        assert rev.plugins == []
        assert dly.plugins == []

    def test_reaeq_index_zero(self):
        """ReaEQ must be at index 0 (prepended to existing plugins)."""
        from src.calibrator import Calibrator

        song = _make_fixture_song()
        # Add an existing plugin to a track
        lead = [t for t in song.tracks if t.name == "Lead"][0]
        lead.plugins = [PluginDef(name="Serum 2", path="Serum2.vst3", index=0)]

        Calibrator._calibrate_eq(song)

        assert len(lead.plugins) == 2
        assert lead.plugins[0].name == "ReaEQ"
        assert lead.plugins[0].index == 0
        assert lead.plugins[1].name == "Serum 2"

    def test_unknown_role_no_reaeq(self):
        """Tracks with unknown role should not get ReaEQ."""
        from src.calibrator import Calibrator

        meta = SongMeta(bpm=95, key="Am")
        song = SongDefinition(
            meta=meta,
            tracks=[TrackDef(name="Vocals", plugins=[])],
        )
        Calibrator._calibrate_eq(song)
        assert song.tracks[0].plugins == []


class TestSwapMasterChain:
    def test_ozone12_master_chain(self):
        """_swap_master_chain should replace master_plugins with Ozone 12 triplet."""
        from src.calibrator import Calibrator

        song = _make_fixture_song()
        Calibrator._swap_master_chain(song)
        assert song.master_plugins == [
            "Ozone_12_Equalizer",
            "Ozone_12_Dynamics",
            "Ozone_12_Maximizer",
        ]

    def test_fallback_to_fabfilter(self):
        """When Ozone is not in PLUGIN_REGISTRY, fall back to FabFilter trio."""
        from src.calibrator import Calibrator
        from src.reaper_builder import PLUGIN_REGISTRY

        # Make a copy of the registry without Ozone entries
        original = dict(PLUGIN_REGISTRY)
        try:
            for k in list(PLUGIN_REGISTRY.keys()):
                if k.startswith("Ozone_12_"):
                    del PLUGIN_REGISTRY[k]  # type: ignore

            song = _make_fixture_song()
            Calibrator._swap_master_chain(song)
            assert song.master_plugins == ["Pro-Q_3", "Pro-C_2", "Pro-L_2"]
        finally:
            # Restore
            PLUGIN_REGISTRY.clear()
            PLUGIN_REGISTRY.update(original)


class TestCalibratorApply:
    def test_apply_orchestrates_all_calibrations(self):
        """Calibrator.apply() should run all _calibrate_* and _swap_master_chain."""
        from src.calibrator import Calibrator

        song = _make_fixture_song()
        result = Calibrator.apply(song)

        # Same object returned (in-place mutation)
        assert result is song

        # Volumes applied
        drum = [t for t in song.tracks if t.name == "Drumloop"][0]
        assert drum.volume == 0.85

        # Pans applied
        assert drum.pan == 0.0

        # Sends applied
        assert drum.send_level.get(7) == 0.10

        # EQ applied (ReaEQ present)
        assert drum.plugins[0].name == "ReaEQ"

        # Master chain upgraded
        assert song.master_plugins == [
            "Ozone_12_Equalizer",
            "Ozone_12_Dynamics",
            "Ozone_12_Maximizer",
        ]

    def test_apply_skips_bass_lpf_eq(self):
        """Bass track should get LPF, not HPF."""
        from src.calibrator import Calibrator

        song = _make_fixture_song()
        Calibrator.apply(song)

        bass = [t for t in song.tracks if t.name == "808 Bass"][0]
        eq = bass.plugins[0]
        assert eq.params[1] == 0  # LPF type
        assert eq.params[2] == 300.0


# ---------------------------------------------------------------------------
# Phase 3: Builder integration + compose wiring
# ---------------------------------------------------------------------------

class TestBuildPluginWithParams:
    """Verify _build_plugin populates VST2 param slots from PluginDef.params."""

    def test_build_plugin_with_params_sets_param_slots(self):
        """PluginDef with params for built-in VST2 should populate param slots."""
        from src.reaper_builder import RPPBuilder
        from src.core.schema import SongDefinition, SongMeta, TrackDef, PluginDef

        plugin = PluginDef(
            name="ReaEQ",
            path="reaeq.dll",
            index=0,
            params={0: 1, 1: 1, 2: 200.0},
        )
        meta = SongMeta(bpm=95, key="Am")
        song = SongDefinition(meta=meta, tracks=[TrackDef(name="Test", plugins=[plugin])])
        builder = RPPBuilder(song, seed=0)
        elem = builder._build_plugin(plugin)

        # When params is non-empty for a built-in VST2, use built-in path
        # Format: [display_name, filename, "0", "", *param_slots]
        attrs = elem.attrib
        assert attrs[0] == "ReaEQ"
        assert attrs[2] == "0"
        # attrs[4:] are the 19 param slots
        param_slots = attrs[4:]
        assert len(param_slots) == 19
        assert param_slots[0] == "1"     # band enabled
        assert param_slots[1] == "1"     # filter type (HPF)
        assert param_slots[2] == "200.0"  # frequency

    def test_build_plugin_without_params_uses_zeros(self):
        """PluginDef without params for built-in VST2 should use default zeros."""
        from src.reaper_builder import RPPBuilder
        from src.core.schema import SongDefinition, SongMeta, TrackDef, PluginDef

        plugin = PluginDef(
            name="FakeBuiltin",
            path="fakebuiltin.dll",
            index=0,
            params={},
        )
        meta = SongMeta(bpm=95, key="Am")
        song = SongDefinition(meta=meta, tracks=[TrackDef(name="Test", plugins=[plugin])])
        builder = RPPBuilder(song, seed=0)
        elem = builder._build_plugin(plugin)

        param_slots = elem.attrib[4:]
        assert all(s == "0" for s in param_slots)


class TestNoCalibrateFlag:
    """Verify --no-calibrate CLI flag behavior."""

    def test_no_calibrate_preserves_original_master(self):
        """When calibrator is skipped, master_plugins should stay unchanged."""
        from src.core.schema import SongDefinition, SongMeta

        meta = SongMeta(bpm=95, key="Am", calibrate=False)
        song = SongDefinition(
            meta=meta,
            tracks=[],
            master_plugins=["Pro-Q_3", "Pro-C_2", "Pro-L_2"],
        )

        if meta.calibrate:
            from src.calibrator import Calibrator
            Calibrator.apply(song)

        assert song.master_plugins == ["Pro-Q_3", "Pro-C_2", "Pro-L_2"]

    def test_calibrate_flag_is_true_default(self):
        """SongMeta.calibrate should default to True (calibration enabled)."""
        from src.core.schema import SongMeta
        meta = SongMeta(bpm=95, key="Am")
        assert meta.calibrate is True

    def test_no_calibrate_skips_volume_changes(self):
        """When calibrate=False, volumes should not be touched."""
        from src.core.schema import SongDefinition, SongMeta, TrackDef

        meta = SongMeta(bpm=95, key="Am", calibrate=False)
        song = SongDefinition(
            meta=meta,
            tracks=[TrackDef(name="Drumloop", volume=0.5)],
        )

        if meta.calibrate:
            from src.calibrator import Calibrator
            Calibrator.apply(song)

        assert song.tracks[0].volume == 0.5  # unchanged
