"""Tests for transitions-fx — build_fx_track() and FX_TRANSITIONS map.

Strict TDD: tests written BEFORE implementation (per TDD cycle).
"""

import sys
from pathlib import Path
from unittest.mock import MagicMock

sys.path.insert(0, str(Path(__file__).parents[1]))

import pytest


# ============================================================================
# Phase 1: RED — FX_TRANSITIONS dict + FX_ROLE constant
# ============================================================================

class TestFxTransitionsDict:
    """1.1 FX_TRANSITIONS dict: 7 boundaries → 8 clips."""

    def test_fx_transitions_has_correct_boundary_count(self):
        """FX_TRANSITIONS covers 7 section boundaries (indices 2-8)."""
        from scripts.compose import FX_TRANSITIONS
        assert isinstance(FX_TRANSITIONS, dict), "FX_TRANSITIONS must be a dict"
        # 7 boundaries: indices 2,3,4,5,6,7,8 (NOT 0,1 — intro/verse have no FX)
        expected_boundaries = {2, 3, 4, 5, 6, 7, 8}
        assert set(FX_TRANSITIONS.keys()) == expected_boundaries, (
            f"Expected boundaries {sorted(expected_boundaries)}, "
            f"got {sorted(FX_TRANSITIONS.keys())}"
        )

    def test_fx_transitions_total_clips_8(self):
        """FX_TRANSITIONS defines exactly 8 clips (one boundary has riser+impact)."""
        from scripts.compose import FX_TRANSITIONS
        total = 0
        for vals in FX_TRANSITIONS.values():
            if isinstance(vals, list):
                total += len(vals)
            else:
                total += 1
        assert total == 8, f"Expected 8 total clips, got {total}"

    def test_fx_transitions_boundary_2_sweep(self):
        """verse→pre-chorus: sweep at position 46, length 2, fade_in 0.3."""
        from scripts.compose import FX_TRANSITIONS
        entry = FX_TRANSITIONS[2]
        # Boundary 2 has a single tuple (not nested list for single-clip boundaries)
        assert isinstance(entry, tuple), f"Boundary 2 should be a single tuple, got {type(entry)}"
        fx_type, offset, length, fade_in, fade_out = entry
        assert fx_type == "sweep"
        assert offset == -2, f"Expected offset -2 (boundary beat 48 - 2 = 46), got {offset}"
        assert length == 2
        assert fade_in == 0.3
        assert fade_out == 0.0

    def test_fx_transitions_boundary_3_has_riser_and_impact(self):
        """pre-chorus→chorus: list of 2 tuples (riser + impact)."""
        from scripts.compose import FX_TRANSITIONS
        entries = FX_TRANSITIONS[3]
        assert isinstance(entries, list), f"Boundary 3 must be a list for riser+impact, got {type(entries)}"
        assert len(entries) == 2, f"Boundary 3 must have 2 entries, got {len(entries)}"

        # Riser
        fx_type, offset, length, fade_in, fade_out = entries[0]
        assert fx_type == "riser"
        assert offset == -4, f"Expected offset -4 (boundary beat 64 - 4 = 60), got {offset}"
        assert length == 4
        assert fade_in == 1.5
        assert fade_out == 0.0

        # Impact
        fx_type, offset, length, fade_in, fade_out = entries[1]
        assert fx_type == "impact"
        assert offset == 0, f"Expected offset 0 (boundary beat 64 + 0 = 64), got {offset}"
        assert length == 2
        assert fade_in == 0.0
        assert fade_out == 0.3

    def test_fx_transitions_boundary_4_transition(self):
        """chorus→verse2: transition at position 94, length 2, fades 0.2/0.2."""
        from scripts.compose import FX_TRANSITIONS
        entry = FX_TRANSITIONS[4]
        fx_type, offset, length, fade_in, fade_out = entry
        assert fx_type == "transition"
        assert offset == -2, f"Expected offset -2 (boundary beat 96 - 2 = 94), got {offset}"
        assert length == 2
        assert fade_in == 0.2
        assert fade_out == 0.2

    def test_fx_transitions_boundary_5_riser(self):
        """verse2→chorus2: riser at position 124, length 4, fade_in 1.0."""
        from scripts.compose import FX_TRANSITIONS
        entry = FX_TRANSITIONS[5]
        fx_type, offset, length, fade_in, fade_out = entry
        assert fx_type == "riser"
        assert offset == -4, f"Expected offset -4 (boundary beat 128 - 4 = 124), got {offset}"
        assert length == 4
        assert fade_in == 1.0
        assert fade_out == 0.0

    def test_fx_transitions_boundary_6_sweep(self):
        """chorus2→bridge: sweep at position 158, length 2, fades 0.2/0.2."""
        from scripts.compose import FX_TRANSITIONS
        entry = FX_TRANSITIONS[6]
        fx_type, offset, length, fade_in, fade_out = entry
        assert fx_type == "sweep"
        assert offset == -2, f"Expected offset -2 (boundary beat 160 - 2 = 158), got {offset}"
        assert length == 2
        assert fade_in == 0.2
        assert fade_out == 0.2

    def test_fx_transitions_boundary_7_riser(self):
        """bridge→final: riser at position 172, length 4, fade_in 1.0."""
        from scripts.compose import FX_TRANSITIONS
        entry = FX_TRANSITIONS[7]
        fx_type, offset, length, fade_in, fade_out = entry
        assert fx_type == "riser"
        assert offset == -4, f"Expected offset -4 (boundary beat 176 - 4 = 172), got {offset}"
        assert length == 4
        assert fade_in == 1.0
        assert fade_out == 0.0

    def test_fx_transitions_boundary_8_sweep(self):
        """final→outro: sweep at position 206, length 2, fades 0.3/0.5."""
        from scripts.compose import FX_TRANSITIONS
        entry = FX_TRANSITIONS[8]
        fx_type, offset, length, fade_in, fade_out = entry
        assert fx_type == "sweep"
        assert offset == -2, f"Expected offset -2 (boundary beat 208 - 2 = 206), got {offset}"
        assert length == 2
        assert fade_in == 0.3
        assert fade_out == 0.5


class TestFxRoleConstant:
    """1.2 FX_ROLE = 'fx' constant referencing ATONAL_ROLES."""

    def test_fx_role_equals_fx(self):
        """FX_ROLE is the string 'fx'."""
        from scripts.compose import FX_ROLE
        assert FX_ROLE == "fx"

    def test_fx_role_in_atonal_roles(self):
        """FX_ROLE value is in ATONAL_ROLES (skip key scoring)."""
        from scripts.compose import FX_ROLE
        from src.selector import ATONAL_ROLES
        assert FX_ROLE in ATONAL_ROLES, "fx must be in ATONAL_ROLES for neutral key scoring"


# ============================================================================
# Phase 2: RED — build_fx_track() function
# ============================================================================

def _make_fx_selector(samples=None):
    """Build a mock SampleSelector that returns FX samples via select_one."""
    if samples is None:
        samples = [
            {"original_path": f"fx_sample_{i}.wav", "original_name": f"FX {i}"}
            for i in range(5)
        ]
    mock = MagicMock()
    mock.select_one.return_value = samples[0] if samples else None
    return mock


def _make_sections_and_offsets():
    """Return sections and offsets matching production SECTIONS layout."""
    from scripts.compose import SECTIONS
    from src.core.schema import SectionDef

    sections = []
    for name, bars, energy, _has_lead in SECTIONS:
        sections.append(SectionDef(name=name, bars=bars, energy=energy))

    offsets = []
    off = 0.0
    for sec in sections:
        offsets.append(off)
        off += sec.bars
    return sections, offsets


class TestBuildFxTrack:
    """4.1-4.3 Unit tests for build_fx_track()."""

    def test_returns_trackdef_with_8_clips(self):
        """build_fx_track produces a TrackDef with exactly 8 clips."""
        from scripts.compose import build_fx_track, build_section_structure
        sections, offsets = build_section_structure()
        selector = _make_fx_selector()
        track = build_fx_track(sections, offsets, selector, seed=0)
        assert track.name == "Transition FX"
        assert len(track.clips) == 8, f"Expected 8 clips, got {len(track.clips)}"

    def test_clip_positions_match_design(self):
        """Clip positions match the design boundary map values."""
        from scripts.compose import build_fx_track, build_section_structure
        sections, offsets = build_section_structure()
        selector = _make_fx_selector()
        track = build_fx_track(sections, offsets, selector, seed=0)

        positions = sorted(c.position for c in track.clips)
        expected = [46.0, 60.0, 64.0, 94.0, 124.0, 158.0, 172.0, 206.0]
        assert positions == expected, (
            f"Expected positions {expected}, got {positions}"
        )

    def test_all_clips_have_audio_path(self):
        """All 8 clips have audio_path set (not None)."""
        from scripts.compose import build_fx_track, build_section_structure
        sections, offsets = build_section_structure()
        selector = _make_fx_selector()
        track = build_fx_track(sections, offsets, selector, seed=0)

        for i, clip in enumerate(track.clips):
            assert clip.audio_path is not None, (
                f"Clip {i} (pos={clip.position}) has None audio_path"
            )
            assert isinstance(clip.audio_path, str), (
                f"Clip {i} audio_path must be a string"
            )
            assert len(clip.audio_path) > 0, (
                f"Clip {i} audio_path must not be empty"
            )

    def test_fade_values_match_design(self):
        """Fade in/out values match the design table for each clip."""
        from scripts.compose import build_fx_track, build_section_structure
        sections, offsets = build_section_structure()
        selector = _make_fx_selector()
        track = build_fx_track(sections, offsets, selector, seed=0)

        # Build expected map: position → (fade_in, fade_out)
        expected_fades = {
            46.0:  (0.3, 0.0),   # sweep
            60.0:  (1.5, 0.0),   # riser
            64.0:  (0.0, 0.3),   # impact
            94.0:  (0.2, 0.2),   # transition
            124.0: (1.0, 0.0),   # riser
            158.0: (0.2, 0.2),   # sweep
            172.0: (1.0, 0.0),   # riser
            206.0: (0.3, 0.5),   # sweep
        }

        for clip in track.clips:
            exp_fi, exp_fo = expected_fades.get(clip.position, (None, None))
            assert exp_fi is not None, f"Unexpected clip position: {clip.position}"
            assert clip.fade_in == pytest.approx(exp_fi), (
                f"Clip at {clip.position}: fade_in={clip.fade_in}, expected {exp_fi}"
            )
            assert clip.fade_out == pytest.approx(exp_fo), (
                f"Clip at {clip.position}: fade_out={clip.fade_out}, expected {exp_fo}"
            )

    def test_riser_has_fade_in_gt_zero(self):
        """Spec requirement: riser clips have fade_in > 0."""
        from scripts.compose import build_fx_track, build_section_structure
        from scripts.compose import FX_TRANSITIONS
        sections, offsets = build_section_structure()
        selector = _make_fx_selector()
        track = build_fx_track(sections, offsets, selector, seed=0)

        # Find which entries are risers
        riser_positions = set()
        for bound_idx, entries in FX_TRANSITIONS.items():
            items = entries if isinstance(entries, list) else [entries]
            for fx_type, offset, length, fi, fo in items:
                if fx_type == "riser":
                    boundary_beat = offsets[bound_idx] * 4.0
                    riser_positions.add(boundary_beat + offset)

        for clip in track.clips:
            if clip.position in riser_positions:
                assert clip.fade_in > 0, (
                    f"Riser at {clip.position} must have fade_in > 0"
                )

    def test_impact_has_fade_out_gt_zero(self):
        """Spec requirement: impact clips have fade_out > 0."""
        from scripts.compose import build_fx_track, build_section_structure
        from scripts.compose import FX_TRANSITIONS
        sections, offsets = build_section_structure()
        selector = _make_fx_selector()
        track = build_fx_track(sections, offsets, selector, seed=0)

        # Find which entries are impacts
        impact_positions = set()
        for bound_idx, entries in FX_TRANSITIONS.items():
            items = entries if isinstance(entries, list) else [entries]
            for fx_type, offset, length, fi, fo in items:
                if fx_type == "impact":
                    boundary_beat = offsets[bound_idx] * 4.0
                    impact_positions.add(boundary_beat + offset)

        for clip in track.clips:
            if clip.position in impact_positions:
                assert clip.fade_out > 0, (
                    f"Impact at {clip.position} must have fade_out > 0"
                )

    def test_track_volume_is_0_72(self):
        """Spec requirement: FX track volume = 0.72."""
        from scripts.compose import build_fx_track, build_section_structure
        sections, offsets = build_section_structure()
        selector = _make_fx_selector()
        track = build_fx_track(sections, offsets, selector, seed=0)
        assert track.volume == 0.72, f"Expected volume 0.72, got {track.volume}"

    def test_track_has_send_level(self):
        """Spec requirement: FX track sends to Reverb (0.08) and Delay (0.05)."""
        from scripts.compose import build_fx_track, build_section_structure
        sections, offsets = build_section_structure()
        selector = _make_fx_selector()
        track = build_fx_track(sections, offsets, selector, seed=0)
        assert track.send_level == {0: 0.08, 1: 0.05}, (
            f"Expected send_level {{0: 0.08, 1: 0.05}}, got {track.send_level}"
        )

    def test_deterministic_with_same_seed(self):
        """Same seed produces identical output (deterministic via select_one)."""
        from scripts.compose import build_fx_track, build_section_structure
        sections, offsets = build_section_structure()
        selector1 = _make_fx_selector()
        selector2 = _make_fx_selector()
        track1 = build_fx_track(sections, offsets, selector1, seed=42)
        track2 = build_fx_track(sections, offsets, selector2, seed=42)
        paths1 = [c.audio_path for c in track1.clips]
        paths2 = [c.audio_path for c in track2.clips]
        assert paths1 == paths2, "Same seed must produce same sample paths"

    def test_different_seed_may_produce_different(self):
        """Different seeds use different calls to select_one (variation)."""
        from scripts.compose import build_fx_track, build_section_structure
        sections, offsets = build_section_structure()
        selector = _make_fx_selector()
        track = build_fx_track(sections, offsets, selector, seed=0)
        # Verify that select_one was called with the correct role
        all_calls = selector.select_one.call_args_list
        for call in all_calls:
            assert call[1]["role"] == "fx", (
                f"select_one must be called with role='fx', got {call}"
            )


# ============================================================================
# Phase 3: RED — Integration (build_fx_track in main)
# ============================================================================

class TestFxTrackIntegration:
    """3.1-3.2 Integration: build_fx_track called in main(), send wiring works."""

    def test_transition_fx_track_in_main_output(self, tmp_path):
        """main() produces RPP output containing 'Transition FX' track."""
        output = _mock_main_fx(tmp_path)
        assert output.exists(), f"Expected {output} to exist"
        content = output.read_text(encoding="utf-8")
        assert "Transition FX" in content, (
            "Expected 'Transition FX' track in RPP output"
        )

    def test_fx_track_has_audio_source(self, tmp_path):
        """FX track clips produce SOURCE WAVE entries."""
        output = _mock_main_fx(tmp_path)
        content = output.read_text(encoding="utf-8")
        # Count SOURCE WAVE entries — should include FX clips
        wave_count = content.count("SOURCE WAVE")
        assert wave_count > 2, f"Expected multiple WAVE sources, got {wave_count}"

    def test_fx_track_has_aux_sends(self, tmp_path):
        """FX track has AUXRECV for reverb/delay sends."""
        output = _mock_main_fx(tmp_path)
        content = output.read_text(encoding="utf-8")
        assert "AUXRECV" in content, "Expected send routing for FX track"

    def test_fx_track_after_clap_before_pad(self, tmp_path):
        """Track ordering: Clap → FX → Pad."""
        output = _mock_main_fx(tmp_path)
        content = output.read_text(encoding="utf-8")

        # RPP format: <TRACK opens on one line, NAME on the next line
        # Names with spaces are quoted: NAME "808 Bass", single-word: NAME Drumloop
        import re
        track_names = re.findall(
            r'<TRACK \{[^}]+\}\s*\n\s*NAME "?([^"\n]+)"?',
            content,
        )
        assert "Clap" in track_names, f"Expected Clap in tracks: {track_names}"
        assert "Transition FX" in track_names, f"Expected Transition FX in tracks: {track_names}"
        assert "Pad" in track_names, f"Expected Pad in tracks: {track_names}"

        clap_pos = track_names.index("Clap")
        fx_pos = track_names.index("Transition FX")
        pad_pos = track_names.index("Pad")
        assert clap_pos < fx_pos < pad_pos, (
            f"Expected Clap ({clap_pos}) < FX ({fx_pos}) < Pad ({pad_pos})"
        )

    def test_calibrate_does_not_break_fx_track(self, tmp_path):
        """Calibrator.apply() does not crash on FX track."""
        output = _mock_main_fx(tmp_path)
        content = output.read_text(encoding="utf-8")
        assert "Transition FX" in content, (
            "FX track must survive calibration"
        )


# ============================================================================
# Helpers for integration tests
# ============================================================================

def _mock_main_fx(tmp_path, extra_args=None):
    """Mock compose.py main() with FX-capable selector."""
    import sys as _sys
    from unittest.mock import patch, MagicMock

    output = tmp_path / "track.rpp"

    # Build mock with FX samples that select_one will return
    fx_samples = [
        {
            "role": "fx",
            "perceptual": {"tempo": 0},
            "musical": {"key": "X"},
            "character": "dark",
            "original_path": f"fx_sample_{i}.wav",
            "original_name": f"Transition_FX_{i}.wav",
            "file_hash": f"fx{i:04d}",
        }
        for i in range(10)
    ]

    with patch("scripts.compose.SampleSelector") as mock_cls:
        mock_sel = MagicMock()
        mock_sel._samples = fx_samples + [
            {
                "role": "snare",
                "perceptual": {"tempo": 0},
                "musical": {"key": "X"},
                "character": "sharp",
                "original_path": "fake_clap.wav",
                "original_name": "fake_clap.wav",
                "file_hash": "clap123",
            },
        ]

        # Mock select for clap builder
        mock_sel.select.return_value = [
            MagicMock(sample={
                "original_path": "fake_clap.wav",
                "file_hash": "clap123",
            }),
        ]
        # Mock select_one for FX builder — returns real dicts
        mock_sel.select_one.return_value = fx_samples[0]

        mock_sel.select_diverse.return_value = [
            {
                "original_path": "fake_vocal.wav",
                "file_hash": "vox123",
            },
        ]
        mock_cls.return_value = mock_sel

        from scripts.compose import main
        original_argv = _sys.argv
        try:
            argv = ["compose", "--output", str(output)]
            if extra_args:
                argv.extend(extra_args)
            _sys.argv = argv
            main()
        finally:
            _sys.argv = original_argv

    return output
