"""Functional integration tests for ALSmuse.

These tests exercise the full pipeline from ALS file parsing through to
formatted output, validating that all components work together correctly.
"""

import gzip
from pathlib import Path

from alsmuse.analyze import analyze_als


def create_als_file(tmp_path: Path, xml_content: str) -> Path:
    """Create a gzip-compressed ALS file with the given XML content."""
    als_file = tmp_path / "test.als"
    with gzip.open(als_file, "wt", encoding="utf-8") as f:
        f.write(xml_content)
    return als_file


MINIMAL_ALS_XML = """\
<?xml version="1.0" encoding="UTF-8"?>
<Ableton>
  <LiveSet>
    <MainTrack>
      <DeviceChain>
        <Mixer>
          <Tempo>
            <Manual Value="120"/>
          </Tempo>
        </Mixer>
      </DeviceChain>
    </MainTrack>
    <Tracks>
      <MidiTrack Id="0">
        <Name>
          <EffectiveName Value="STRUCTURE"/>
          <UserName Value=""/>
        </Name>
        <DeviceChain>
          <MainSequencer>
            <ClipTimeable>
              <ArrangerAutomation>
                <Events>
                  <MidiClip Id="0" Time="0">
                    <Name Value="INTRO"/>
                    <CurrentEnd Value="16"/>
                    <Notes><KeyTracks/></Notes>
                  </MidiClip>
                  <MidiClip Id="1" Time="16">
                    <Name Value="VERSE1"/>
                    <CurrentEnd Value="48"/>
                    <Notes><KeyTracks/></Notes>
                  </MidiClip>
                  <MidiClip Id="2" Time="48">
                    <Name Value="CHORUS"/>
                    <CurrentEnd Value="80"/>
                    <Notes><KeyTracks/></Notes>
                  </MidiClip>
                </Events>
              </ArrangerAutomation>
            </ClipTimeable>
          </MainSequencer>
        </DeviceChain>
      </MidiTrack>
    </Tracks>
  </LiveSet>
</Ableton>
"""


ALS_WITH_MIDI_TRACK = """\
<?xml version="1.0" encoding="UTF-8"?>
<Ableton>
  <LiveSet>
    <MainTrack>
      <DeviceChain>
        <Mixer>
          <Tempo>
            <Manual Value="120"/>
          </Tempo>
        </Mixer>
      </DeviceChain>
    </MainTrack>
    <Tracks>
      <MidiTrack Id="0">
        <Name>
          <EffectiveName Value="STRUCTURE"/>
          <UserName Value=""/>
        </Name>
        <DeviceChain>
          <MainSequencer>
            <ClipTimeable>
              <ArrangerAutomation>
                <Events>
                  <MidiClip Id="0" Time="0">
                    <Name Value="INTRO"/>
                    <CurrentEnd Value="16"/>
                    <Notes><KeyTracks/></Notes>
                  </MidiClip>
                  <MidiClip Id="1" Time="16">
                    <Name Value="VERSE1"/>
                    <CurrentEnd Value="48"/>
                    <Notes><KeyTracks/></Notes>
                  </MidiClip>
                </Events>
              </ArrangerAutomation>
            </ClipTimeable>
          </MainSequencer>
        </DeviceChain>
      </MidiTrack>
      <MidiTrack Id="1">
        <Name>
          <EffectiveName Value="Bass"/>
          <UserName Value="Bass"/>
        </Name>
        <DeviceChain>
          <MainSequencer>
            <ClipTimeable>
              <ArrangerAutomation>
                <Events>
                  <MidiClip Id="10" Time="16">
                    <Name Value="Bass Pattern"/>
                    <CurrentEnd Value="48"/>
                    <Notes>
                      <KeyTracks>
                        <KeyTrack Id="0">
                          <MidiKey Value="36"/>
                          <Notes>
                            <MidiNoteEvent Time="0" Duration="0.5" Velocity="100"/>
                            <MidiNoteEvent Time="2" Duration="0.5" Velocity="100"/>
                            <MidiNoteEvent Time="4" Duration="0.5" Velocity="100"/>
                            <MidiNoteEvent Time="6" Duration="0.5" Velocity="100"/>
                          </Notes>
                        </KeyTrack>
                      </KeyTracks>
                    </Notes>
                  </MidiClip>
                </Events>
              </ArrangerAutomation>
            </ClipTimeable>
          </MainSequencer>
        </DeviceChain>
      </MidiTrack>
      <MidiTrack Id="2">
        <Name>
          <EffectiveName Value="Drums"/>
          <UserName Value="Drums"/>
        </Name>
        <DeviceChain>
          <MainSequencer>
            <ClipTimeable>
              <ArrangerAutomation>
                <Events>
                  <MidiClip Id="20" Time="0">
                    <Name Value="Drum Pattern"/>
                    <CurrentEnd Value="48"/>
                    <Notes>
                      <KeyTracks>
                        <KeyTrack Id="0">
                          <MidiKey Value="36"/>
                          <Notes>
                            <MidiNoteEvent Time="0" Duration="0.25" Velocity="100"/>
                            <MidiNoteEvent Time="1" Duration="0.25" Velocity="100"/>
                            <MidiNoteEvent Time="2" Duration="0.25" Velocity="100"/>
                            <MidiNoteEvent Time="3" Duration="0.25" Velocity="100"/>
                          </Notes>
                        </KeyTrack>
                      </KeyTracks>
                    </Notes>
                  </MidiClip>
                </Events>
              </ArrangerAutomation>
            </ClipTimeable>
          </MainSequencer>
        </DeviceChain>
      </MidiTrack>
    </Tracks>
  </LiveSet>
</Ableton>
"""


class TestAnalyzeAlsV2:
    """Integration tests for phrase-level analysis (v2 pipeline)."""

    def test_phrase_subdivision_produces_chunks(self, tmp_path: Path) -> None:
        """Sections are subdivided into phrase-sized chunks."""
        als_file = create_als_file(tmp_path, MINIMAL_ALS_XML)

        result = analyze_als(als_file, beats_per_phrase=8, show_events=False)

        # INTRO (16 beats) should produce 2 phrases at 8 beats each
        # VERSE1 (32 beats) should produce 4 phrases
        # CHORUS (32 beats) should produce 4 phrases
        lines = result.strip().split("\n")
        data_rows = [
            row for row in lines if row.startswith("|") and "Time" not in row and "---" not in row
        ]
        assert len(data_rows) == 10  # 2 + 4 + 4

    def test_phrase_output_shows_section_names(self, tmp_path: Path) -> None:
        """First phrase of each section shows section name."""
        als_file = create_als_file(tmp_path, MINIMAL_ALS_XML)

        result = analyze_als(als_file, beats_per_phrase=8, show_events=False)

        assert "INTRO" in result
        assert "VERSE1" in result
        assert "CHORUS" in result
        assert "..." in result  # Continuation markers

    def test_event_detection_shows_track_changes(self, tmp_path: Path) -> None:
        """Track enter/exit events are detected and shown."""
        als_file = create_als_file(tmp_path, ALS_WITH_MIDI_TRACK)

        result = analyze_als(als_file, beats_per_phrase=8, show_events=True)

        # Drums starts at beat 0, Bass enters at beat 16
        assert "Drums enters" in result
        assert "Bass enters" in result

    def test_event_detection_can_be_disabled(self, tmp_path: Path) -> None:
        """Events are hidden when show_events=False."""
        als_file = create_als_file(tmp_path, ALS_WITH_MIDI_TRACK)

        result = analyze_als(als_file, beats_per_phrase=8, show_events=False)

        assert "Events" not in result
        assert "enters" not in result

    def test_output_format_has_correct_columns(self, tmp_path: Path) -> None:
        """Output has Time, Audio, and Video columns."""
        als_file = create_als_file(tmp_path, ALS_WITH_MIDI_TRACK)

        result = analyze_als(als_file, beats_per_phrase=8, show_events=True)

        header_line = result.split("\n")[0]
        assert "Time" in header_line
        assert "Audio" in header_line
        assert "Video" in header_line


class TestLyricsIntegration:
    """Integration tests for lyrics functionality."""

    def test_lyrics_appear_in_output(self, tmp_path: Path) -> None:
        """Lyrics from file appear in the output table."""
        als_file = create_als_file(tmp_path, MINIMAL_ALS_XML)

        lyrics_file = tmp_path / "lyrics.txt"
        lyrics_file.write_text("[VERSE1]\nFirst verse line\nSecond verse line\n")

        result = analyze_als(
            als_file, beats_per_phrase=8, show_events=False, lyrics_path=lyrics_file
        )

        # Lyrics appear in the Audio column (combined with section cue)
        assert "Audio" in result
        assert "First verse line" in result

    def test_lyrics_distributed_across_section(self, tmp_path: Path) -> None:
        """Lyrics are distributed evenly within their section."""
        als_file = create_als_file(tmp_path, MINIMAL_ALS_XML)

        lyrics_file = tmp_path / "lyrics.txt"
        lyrics_file.write_text("[VERSE1]\nLine one\nLine two\n")

        result = analyze_als(
            als_file, beats_per_phrase=8, show_events=False, lyrics_path=lyrics_file
        )

        # Both lyrics should appear
        assert "Line one" in result
        assert "Line two" in result

    def test_sections_without_lyrics_show_empty(self, tmp_path: Path) -> None:
        """Sections not in lyrics file have empty lyric cells."""
        als_file = create_als_file(tmp_path, MINIMAL_ALS_XML)

        lyrics_file = tmp_path / "lyrics.txt"
        lyrics_file.write_text("[CHORUS]\nChorus lyric\n")

        result = analyze_als(
            als_file, beats_per_phrase=8, show_events=False, lyrics_path=lyrics_file
        )

        # INTRO and VERSE1 shouldn't have lyrics
        lines = result.split("\n")
        intro_line = next(line for line in lines if "INTRO" in line)
        # Empty lyrics cell should just have ""
        assert intro_line.count('""') == 0 or '| "" |' not in intro_line

    def test_lyrics_with_events(self, tmp_path: Path) -> None:
        """Lyrics and events can be shown together."""
        als_file = create_als_file(tmp_path, ALS_WITH_MIDI_TRACK)

        lyrics_file = tmp_path / "lyrics.txt"
        lyrics_file.write_text("[VERSE1]\nVerse with events\n")

        result = analyze_als(
            als_file, beats_per_phrase=8, show_events=True, lyrics_path=lyrics_file
        )

        # Events and lyrics appear in the Audio column (combined with <br>)
        assert "Audio" in result
        assert "Verse with events" in result
        # Events are included (via format_events)
        assert "enters" in result or "exits" in result or "VERSE1" in result


class TestEdgeCases:
    """Integration tests for edge cases and error handling."""

    def test_empty_section_handled(self, tmp_path: Path) -> None:
        """Very short sections are handled without crashing."""
        xml = """\
<?xml version="1.0" encoding="UTF-8"?>
<Ableton>
  <LiveSet>
    <MainTrack>
      <DeviceChain>
        <Mixer>
          <Tempo>
            <Manual Value="120"/>
          </Tempo>
        </Mixer>
      </DeviceChain>
    </MainTrack>
    <Tracks>
      <MidiTrack Id="0">
        <Name>
          <EffectiveName Value="STRUCTURE"/>
          <UserName Value=""/>
        </Name>
        <DeviceChain>
          <MainSequencer>
            <ClipTimeable>
              <ArrangerAutomation>
                <Events>
                  <MidiClip Id="0" Time="0">
                    <Name Value="SHORT"/>
                    <CurrentEnd Value="4"/>
                    <Notes><KeyTracks/></Notes>
                  </MidiClip>
                </Events>
              </ArrangerAutomation>
            </ClipTimeable>
          </MainSequencer>
        </DeviceChain>
      </MidiTrack>
    </Tracks>
  </LiveSet>
</Ableton>
"""
        als_file = create_als_file(tmp_path, xml)

        result = analyze_als(als_file, beats_per_phrase=8, show_events=False)

        assert "SHORT" in result

    def test_different_time_signatures_use_correct_timing(self, tmp_path: Path) -> None:
        """Time signature information is extracted (though calculation uses BPM)."""
        xml = """\
<?xml version="1.0" encoding="UTF-8"?>
<Ableton>
  <LiveSet>
    <MainTrack>
      <DeviceChain>
        <Mixer>
          <Tempo>
            <Manual Value="90"/>
          </Tempo>
        </Mixer>
      </DeviceChain>
    </MainTrack>
    <Tracks>
      <MidiTrack Id="0">
        <Name>
          <EffectiveName Value="STRUCTURE"/>
          <UserName Value=""/>
        </Name>
        <DeviceChain>
          <MainSequencer>
            <ClipTimeable>
              <ArrangerAutomation>
                <Events>
                  <MidiClip Id="0" Time="0">
                    <Name Value="INTRO"/>
                    <CurrentEnd Value="24"/>
                    <Notes><KeyTracks/></Notes>
                  </MidiClip>
                </Events>
              </ArrangerAutomation>
            </ClipTimeable>
          </MainSequencer>
        </DeviceChain>
      </MidiTrack>
    </Tracks>
  </LiveSet>
</Ableton>
"""
        als_file = create_als_file(tmp_path, xml)

        result = analyze_als(als_file, beats_per_phrase=8, show_events=False)

        # At 90 BPM, 24 beats should end at 16 seconds
        # First section starts at 0:00
        assert "0:00" in result

    def test_tracks_without_midi_notes_dont_crash(self, tmp_path: Path) -> None:
        """MIDI tracks without notes are handled gracefully."""
        als_file = create_als_file(tmp_path, MINIMAL_ALS_XML)

        # Should not crash even though structure track has no notes
        result = analyze_als(als_file, beats_per_phrase=8, show_events=True)

        assert "INTRO" in result


# ---------------------------------------------------------------------------
# Phase 4 Tests: Lyrics Alignment Integration
# ---------------------------------------------------------------------------


class TestDistributeTimedLyrics:
    """Tests for distribute_timed_lyrics function."""

    def test_empty_phrases_returns_empty(self) -> None:
        """Empty phrase list returns empty list."""
        from alsmuse.lyrics import distribute_timed_lyrics
        from alsmuse.models import TimedLine

        timed_lines = [TimedLine(text="Hello", start=0.0, end=1.0, words=())]

        result = distribute_timed_lyrics([], timed_lines, bpm=120.0)

        assert result == []

    def test_empty_timed_lines_returns_phrases_unchanged(self) -> None:
        """Empty timed lines list returns phrases unchanged."""
        from alsmuse.lyrics import distribute_timed_lyrics
        from alsmuse.models import Phrase

        phrases = [
            Phrase(start_beats=0, end_beats=8, section_name="V1", is_section_start=True),
        ]

        result = distribute_timed_lyrics(phrases, [], bpm=120.0)

        assert result == phrases

    def test_single_line_single_phrase(self) -> None:
        """Single timed line assigned to single phrase."""
        from alsmuse.lyrics import distribute_timed_lyrics
        from alsmuse.models import Phrase, TimedLine, TimedWord

        # At 120 BPM, 8 beats = 4 seconds
        phrases = [
            Phrase(start_beats=0, end_beats=8, section_name="V1", is_section_start=True),
        ]

        timed_lines = [
            TimedLine(
                text="Hello world",
                start=1.0,
                end=2.0,
                words=(
                    TimedWord(text="Hello", start=1.0, end=1.5),
                    TimedWord(text="world", start=1.5, end=2.0),
                ),
            ),
        ]

        result = distribute_timed_lyrics(phrases, timed_lines, bpm=120.0)

        assert len(result) == 1
        assert result[0].lyric == "Hello world"

    def test_multiple_lines_same_phrase(self) -> None:
        """Multiple timed lines in same phrase are joined with ' / '."""
        from alsmuse.lyrics import distribute_timed_lyrics
        from alsmuse.models import Phrase, TimedLine, TimedWord

        # At 120 BPM, 8 beats = 4 seconds
        phrases = [
            Phrase(start_beats=0, end_beats=8, section_name="V1", is_section_start=True),
        ]

        timed_lines = [
            TimedLine(
                text="First line",
                start=0.5,
                end=1.0,
                words=(TimedWord(text="First", start=0.5, end=0.75),),
            ),
            TimedLine(
                text="Second line",
                start=2.0,
                end=2.5,
                words=(TimedWord(text="Second", start=2.0, end=2.25),),
            ),
        ]

        result = distribute_timed_lyrics(phrases, timed_lines, bpm=120.0)

        assert len(result) == 1
        assert result[0].lyric == "First line / Second line"

    def test_lines_distributed_across_phrases(self) -> None:
        """Lines are distributed to phrases based on midpoint timestamp."""
        from alsmuse.lyrics import distribute_timed_lyrics
        from alsmuse.models import Phrase, TimedLine, TimedWord

        # At 120 BPM:
        # Phrase 1: 0-8 beats = 0-4 seconds
        # Phrase 2: 8-16 beats = 4-8 seconds
        phrases = [
            Phrase(start_beats=0, end_beats=8, section_name="V1", is_section_start=True),
            Phrase(start_beats=8, end_beats=16, section_name="...", is_section_start=False),
        ]

        timed_lines = [
            TimedLine(
                text="First phrase",
                start=1.0,
                end=2.0,  # midpoint = 1.5, in phrase 1
                words=(TimedWord(text="First", start=1.0, end=2.0),),
            ),
            TimedLine(
                text="Second phrase",
                start=5.0,
                end=6.0,  # midpoint = 5.5, in phrase 2
                words=(TimedWord(text="Second", start=5.0, end=6.0),),
            ),
        ]

        result = distribute_timed_lyrics(phrases, timed_lines, bpm=120.0)

        assert len(result) == 2
        assert result[0].lyric == "First phrase"
        assert result[1].lyric == "Second phrase"

    def test_line_before_first_phrase_is_skipped(self) -> None:
        """Lines whose midpoint is before the first phrase are skipped."""
        from alsmuse.lyrics import distribute_timed_lyrics
        from alsmuse.models import Phrase, TimedLine, TimedWord

        # Phrase starts at 8 beats = 4 seconds at 120 BPM
        phrases = [
            Phrase(start_beats=8, end_beats=16, section_name="V1", is_section_start=True),
        ]

        timed_lines = [
            TimedLine(
                text="Too early",
                start=0.0,
                end=1.0,  # midpoint = 0.5, before phrase
                words=(TimedWord(text="Too", start=0.0, end=1.0),),
            ),
            TimedLine(
                text="In phrase",
                start=5.0,
                end=6.0,  # midpoint = 5.5, in phrase
                words=(TimedWord(text="In", start=5.0, end=6.0),),
            ),
        ]

        result = distribute_timed_lyrics(phrases, timed_lines, bpm=120.0)

        assert len(result) == 1
        assert result[0].lyric == "In phrase"

    def test_empty_timing_lines_skipped(self) -> None:
        """Lines with no timing (start=0, end=0, no words) are skipped."""
        from alsmuse.lyrics import distribute_timed_lyrics
        from alsmuse.models import Phrase, TimedLine, TimedWord

        phrases = [
            Phrase(start_beats=0, end_beats=8, section_name="V1", is_section_start=True),
        ]

        timed_lines = [
            TimedLine(text="No timing", start=0.0, end=0.0, words=()),
            TimedLine(
                text="Has timing",
                start=1.0,
                end=2.0,
                words=(TimedWord(text="Has", start=1.0, end=2.0),),
            ),
        ]

        result = distribute_timed_lyrics(phrases, timed_lines, bpm=120.0)

        assert len(result) == 1
        assert result[0].lyric == "Has timing"

    def test_preserves_phrase_attributes(self) -> None:
        """Phrase attributes other than lyric are preserved."""
        from alsmuse.lyrics import distribute_timed_lyrics
        from alsmuse.models import Phrase, TrackEvent

        event = TrackEvent(beat=0, track_name="Bass", event_type="enter", category="bass")
        phrases = [
            Phrase(
                start_beats=10,
                end_beats=18,
                section_name="VERSE",
                is_section_start=True,
                events=(event,),
            ),
        ]

        result = distribute_timed_lyrics(phrases, [], bpm=120.0)

        assert result[0].start_beats == 10
        assert result[0].end_beats == 18
        assert result[0].section_name == "VERSE"
        assert result[0].is_section_start is True
        assert result[0].events == (event,)


class TestAlignmentFallback:
    """Tests for graceful fallback when alignment fails."""

    def test_fallback_to_heuristic_on_no_vocal_tracks(self, tmp_path: Path) -> None:
        """Falls back to heuristic when no vocal tracks are found."""
        # Create ALS file with no audio tracks (only MIDI)
        als_file = create_als_file(tmp_path, MINIMAL_ALS_XML)

        lyrics_file = tmp_path / "lyrics.txt"
        lyrics_file.write_text("[VERSE1]\nTest lyric line\n")

        # Should fall back to heuristic and still show lyrics
        result = analyze_als(
            als_file,
            beats_per_phrase=8,
            show_events=False,
            lyrics_path=lyrics_file,
            align_vocals=True,
            use_all_vocals=True,
        )

        # Lyrics should still appear from fallback
        assert "Test lyric line" in result

    def test_heuristic_without_alignment_flag(self, tmp_path: Path) -> None:
        """When align_vocals=False, uses heuristic distribution."""
        als_file = create_als_file(tmp_path, MINIMAL_ALS_XML)

        lyrics_file = tmp_path / "lyrics.txt"
        lyrics_file.write_text("[VERSE1]\nHeuristic lyric\n")

        result = analyze_als(
            als_file,
            beats_per_phrase=8,
            show_events=False,
            lyrics_path=lyrics_file,
            align_vocals=False,  # Explicitly use heuristic
        )

        assert "Heuristic lyric" in result


class TestCLIOptions:
    """Tests for CLI option parsing."""

    def test_cli_accepts_align_vocals_flag(self) -> None:
        """CLI accepts --align-vocals flag."""
        from click.testing import CliRunner

        from alsmuse.cli import analyze

        runner = CliRunner()
        # Just verify the option is recognized (will fail on missing file)
        result = runner.invoke(analyze, ["--help"])

        assert "--align-vocals" in result.output
        assert "forced alignment" in result.output.lower()

    def test_cli_accepts_vocal_track_option(self) -> None:
        """CLI accepts --vocal-track option."""
        from click.testing import CliRunner

        from alsmuse.cli import analyze

        runner = CliRunner()
        result = runner.invoke(analyze, ["--help"])

        assert "--vocal-track" in result.output

    def test_cli_accepts_all_vocals_flag(self) -> None:
        """CLI accepts --all-vocals flag."""
        from click.testing import CliRunner

        from alsmuse.cli import analyze

        runner = CliRunner()
        result = runner.invoke(analyze, ["--help"])

        assert "--all-vocals" in result.output

    def test_cli_requires_lyrics_with_align_vocals(self, tmp_path: Path) -> None:
        """CLI shows error when --align-vocals used without --lyrics."""
        from click.testing import CliRunner

        from alsmuse.cli import main

        runner = CliRunner()

        # Create a minimal ALS file
        als_file = create_als_file(tmp_path, MINIMAL_ALS_XML)

        # Try to use --align-vocals without --lyrics
        result = runner.invoke(main, ["analyze", str(als_file), "--align-vocals"])

        assert result.exit_code != 0
        assert "--align-vocals requires --lyrics" in result.output

    def test_cli_mutual_exclusion_transcribe_and_lyrics(self, tmp_path: Path) -> None:
        """CLI errors when --transcribe and --lyrics are used together."""
        from click.testing import CliRunner

        from alsmuse.cli import main

        runner = CliRunner()

        # Create ALS file and lyrics file
        als_file = create_als_file(tmp_path, MINIMAL_ALS_XML)
        lyrics_file = tmp_path / "lyrics.txt"
        lyrics_file.write_text("[VERSE1]\nHello world\n")

        # Try to use both --transcribe and --lyrics
        result = runner.invoke(
            main,
            ["analyze", str(als_file), "--transcribe", "--lyrics", str(lyrics_file)],
        )

        assert result.exit_code != 0
        assert "mutually exclusive" in result.output

    def test_cli_accepts_language_option(self) -> None:
        """CLI accepts --language option."""
        from click.testing import CliRunner

        from alsmuse.cli import analyze

        runner = CliRunner()
        result = runner.invoke(analyze, ["--help"])

        assert "--language" in result.output
        assert "Language code" in result.output

    def test_cli_accepts_whisper_model_option(self) -> None:
        """CLI accepts --whisper-model option."""
        from click.testing import CliRunner

        from alsmuse.cli import analyze

        runner = CliRunner()
        result = runner.invoke(analyze, ["--help"])

        assert "--whisper-model" in result.output
        assert "tiny" in result.output
        assert "base" in result.output
        assert "medium" in result.output

    def test_cli_accepts_save_lyrics_option(self) -> None:
        """CLI accepts --save-lyrics option."""
        from click.testing import CliRunner

        from alsmuse.cli import analyze

        runner = CliRunner()
        result = runner.invoke(analyze, ["--help"])

        assert "--save-lyrics" in result.output
        assert "save lyrics" in result.output.lower()
        assert "lrc format" in result.output.lower()

    def test_cli_accepts_output_option(self) -> None:
        """CLI accepts --output / -o option."""
        from click.testing import CliRunner

        from alsmuse.cli import analyze

        runner = CliRunner()
        result = runner.invoke(analyze, ["--help"])

        assert "--output" in result.output or "-o" in result.output
        # Help text should mention markdown output format
        assert "markdown" in result.output.lower()

    def test_cli_output_writes_to_file(self, tmp_path: Path) -> None:
        """--output option writes the A/V table to file."""
        from click.testing import CliRunner

        from alsmuse.cli import main

        runner = CliRunner()

        # Create a minimal ALS file
        als_file = create_als_file(tmp_path, MINIMAL_ALS_XML)
        output_file = tmp_path / "av_table.md"

        # Run with --output option
        result = runner.invoke(main, ["analyze", str(als_file), "--output", str(output_file)])

        assert result.exit_code == 0
        assert output_file.exists()
        assert "A/V table saved to:" in result.output

        # Verify the file contains the markdown table
        content = output_file.read_text()
        assert "Audio" in content  # Table should have Audio column
        assert "Time" in content  # Table should have Time column


# ---------------------------------------------------------------------------
# Phase 3 Tests: Timestamped Lyrics Bypass Alignment
# ---------------------------------------------------------------------------


class TestTimestampedLyricsBypassAlignment:
    """Tests verifying that timestamped lyrics bypass forced alignment.

    When lyrics have embedded timestamps, the alignment code path should not
    be executed at all. We verify this by:
    1. Providing timestamped lyrics
    2. Setting align_vocals=True (which would normally trigger alignment)
    3. Verifying the analysis succeeds and lyrics appear correctly

    Since there are no audio tracks in MINIMAL_ALS_XML, if alignment were
    attempted it would fail. Successful execution proves alignment was bypassed.
    """

    def test_lrc_lyrics_bypass_alignment_entirely(self, tmp_path: Path) -> None:
        """LRC lyrics with timestamps do not trigger alignment at all.

        If alignment were attempted with no audio tracks, it would fail.
        Successful completion proves the LRC timestamps bypass alignment.
        """
        als_file = create_als_file(tmp_path, MINIMAL_ALS_XML)

        # Create LRC lyrics file with timestamps
        lyrics_file = tmp_path / "lyrics.lrc"
        lyrics_file.write_text("[00:08.00]First verse line\n[00:12.00]Second verse line\n")

        # With align_vocals=True but timestamped lyrics, alignment is bypassed
        # This would fail if alignment were attempted (no audio tracks)
        result = analyze_als(
            als_file,
            beats_per_phrase=8,
            show_events=False,
            lyrics_path=lyrics_file,
            align_vocals=True,  # Would fail if actually used - bypassed by timestamps
        )

        # Lyrics should appear in output (from timestamps, not alignment)
        assert "First verse line" in result
        assert "Second verse line" in result
        # Verify Audio column is present (lyrics are now combined into Audio)
        assert "Audio" in result

    def test_simple_timed_lyrics_bypass_alignment(self, tmp_path: Path) -> None:
        """Simple timed lyrics do not trigger alignment.

        If alignment were attempted with no audio tracks, it would fail.
        Successful completion proves the simple timestamps bypass alignment.
        """
        als_file = create_als_file(tmp_path, MINIMAL_ALS_XML)

        # Create simple timed lyrics file
        lyrics_file = tmp_path / "lyrics.txt"
        lyrics_file.write_text("0:08.00 First verse line\n0:12.00 Second verse line\n")

        # With align_vocals=True but timestamped lyrics, alignment is bypassed
        result = analyze_als(
            als_file,
            beats_per_phrase=8,
            show_events=False,
            lyrics_path=lyrics_file,
            align_vocals=True,  # Would fail if actually used - bypassed by timestamps
        )

        # Lyrics should appear in output
        assert "First verse line" in result
        assert "Second verse line" in result

    def test_enhanced_lrc_lyrics_bypass_alignment(self, tmp_path: Path) -> None:
        """Enhanced LRC with word timestamps does not trigger alignment.

        If alignment were attempted with no audio tracks, it would fail.
        Successful completion proves the enhanced LRC bypasses alignment.
        """
        als_file = create_als_file(tmp_path, MINIMAL_ALS_XML)

        # Create enhanced LRC lyrics file with word-level timestamps
        lyrics_file = tmp_path / "lyrics.lrc"
        lyrics_file.write_text(
            "[00:08.00]<00:08.00>First <00:08.50>verse <00:09.00>line\n"
            "[00:12.00]<00:12.00>Second <00:12.50>verse <00:13.00>line\n"
        )

        # With align_vocals=True but timestamped lyrics, alignment is bypassed
        result = analyze_als(
            als_file,
            beats_per_phrase=8,
            show_events=False,
            lyrics_path=lyrics_file,
            align_vocals=True,
        )

        # Lyrics should appear in output (reconstructed from word timestamps)
        assert "First verse line" in result
        assert "Second verse line" in result

    def test_plain_text_lyrics_with_align_triggers_alignment(self, tmp_path: Path) -> None:
        """Plain text lyrics with align_vocals=True attempts alignment.

        Since MINIMAL_ALS_XML has no audio tracks, alignment fails and
        falls back to heuristic distribution. This proves:
        1. Alignment WAS attempted (unlike timestamped formats)
        2. Fallback to heuristic works correctly
        """
        als_file = create_als_file(tmp_path, MINIMAL_ALS_XML)

        # Create plain text lyrics file (no timestamps)
        lyrics_file = tmp_path / "lyrics.txt"
        lyrics_file.write_text("[VERSE1]\nPlain text lyric\n")

        # With align_vocals=True and plain text, alignment is attempted
        # Since we have no audio tracks, it will fail and fall back to heuristic
        result = analyze_als(
            als_file,
            beats_per_phrase=8,
            show_events=False,
            lyrics_path=lyrics_file,
            align_vocals=True,
            use_all_vocals=True,  # Avoid prompting
        )

        # Lyrics should still appear (from fallback heuristic distribution)
        assert "Plain text lyric" in result


class TestLanguageAndModelPassing:
    """Tests verifying --language and --whisper-model are passed through correctly."""

    def test_language_option_passed_to_transcribe(self) -> None:
        """--language option is passed to transcribe_lyrics function."""
        from unittest.mock import patch

        from alsmuse.models import TimedSegment, TimedWord

        # Mock _transcribe_single_segment to capture language argument
        expected_segment = TimedSegment(
            text="hola",
            start=1.0,
            end=1.5,
            words=(TimedWord(text="hola", start=1.0, end=1.5),),
        )

        mock_audio_segments = [(Path("/fake/segment_0.wav"), 0.0, 5.0)]
        captured_calls: list[dict] = []

        def mock_transcribe(audio_path, language, model_size, time_offset):
            captured_calls.append(
                {
                    "language": language,
                    "model_size": model_size,
                    "time_offset": time_offset,
                }
            )
            return [expected_segment]

        with (
            patch(
                "alsmuse.lyrics_align.split_audio_on_silence",
                return_value=mock_audio_segments,
            ),
            patch(
                "alsmuse.lyrics_align._transcribe_single_segment",
                side_effect=mock_transcribe,
            ),
        ):
            from alsmuse import lyrics_align

            # Call transcribe_lyrics with Spanish language
            lyrics_align.transcribe_lyrics(
                audio_path=Path("/fake/audio.wav"),
                language="es",  # Spanish
                model_size="base",
            )

        # Verify _transcribe_single_segment was called with language="es"
        assert len(captured_calls) == 1
        assert captured_calls[0]["language"] == "es"

    def test_model_size_option_passed_to_load_model(self) -> None:
        """--whisper-model option is passed to transcription functions."""
        from unittest.mock import patch

        from alsmuse.models import TimedSegment, TimedWord

        # Mock _transcribe_single_segment to capture model_size argument
        expected_segment = TimedSegment(
            text="test",
            start=1.0,
            end=1.5,
            words=(TimedWord(text="test", start=1.0, end=1.5),),
        )

        mock_audio_segments = [(Path("/fake/segment_0.wav"), 0.0, 5.0)]
        captured_calls: list[dict] = []

        def mock_transcribe(audio_path, language, model_size, time_offset):
            captured_calls.append(
                {
                    "language": language,
                    "model_size": model_size,
                    "time_offset": time_offset,
                }
            )
            return [expected_segment]

        with (
            patch(
                "alsmuse.lyrics_align.split_audio_on_silence",
                return_value=mock_audio_segments,
            ),
            patch(
                "alsmuse.lyrics_align._transcribe_single_segment",
                side_effect=mock_transcribe,
            ),
        ):
            from alsmuse import lyrics_align

            # Call transcribe_lyrics with medium model
            lyrics_align.transcribe_lyrics(
                audio_path=Path("/fake/audio.wav"),
                language="en",
                model_size="medium",  # Medium model
            )

        # Verify _transcribe_single_segment was called with model_size="medium"
        assert len(captured_calls) == 1
        assert captured_calls[0]["model_size"] == "medium"

    def test_language_option_passed_to_align(self) -> None:
        """--language option is passed to align_lyrics function."""
        from unittest.mock import MagicMock, patch

        # Create mock model and result
        mock_word = MagicMock()
        mock_word.word = "bonjour"
        mock_word.start = 1.0
        mock_word.end = 1.5

        mock_segment = MagicMock()
        mock_segment.words = [mock_word]

        mock_result = MagicMock()
        mock_result.segments = [mock_segment]

        mock_model = MagicMock()
        mock_model.align.return_value = mock_result

        mock_stable_whisper = MagicMock()
        mock_stable_whisper.load_model.return_value = mock_model

        mock_torch = MagicMock()
        mock_torch.cuda.is_available.return_value = False
        mock_torch.backends.mps.is_available.return_value = False

        with patch.dict(
            "sys.modules", {"stable_whisper": mock_stable_whisper, "torch": mock_torch}
        ):
            import importlib
            from pathlib import Path

            from alsmuse import lyrics_align

            importlib.reload(lyrics_align)

            # Call align_lyrics with French language
            lyrics_align.align_lyrics(
                audio_path=Path("/fake/audio.wav"),
                lyrics_text="Bonjour",
                valid_ranges=[(0.0, 10.0)],
                language="fr",  # French
                model_size="base",
            )

        # Verify align was called with language="fr"
        mock_model.align.assert_called_once()
        call_kwargs = mock_model.align.call_args[1]
        assert call_kwargs["language"] == "fr"
