"""Tests for lib/loosefolder.py — pure helpers for loose custom song folders."""

import json
from pathlib import Path

import pytest

import loosefolder


_MIN_XML = """<?xml version="1.0" encoding="utf-8"?>
<song>
  <title>{title}</title>
  <artistName>{artist}</artistName>
  <albumName>{album}</albumName>
  <albumYear>{year}</albumYear>
  <songLength>{duration}</songLength>
  <arrangement>{arrangement}</arrangement>
  <offset>0.0</offset>
  <tuning string0="0" string1="0" string2="0" string3="0" string4="0" string5="0"/>
</song>
"""


def _write_min_xml(path: Path, *, title="T", artist="A", album="Alb",
                   year="2024", duration="123.4", arrangement="Lead"):
    path.write_text(_MIN_XML.format(
        title=title, artist=artist, album=album, year=year,
        duration=duration, arrangement=arrangement,
    ), encoding="utf-8")


def test_is_loose_song_requires_audio_and_xml(tmp_path):
    assert loosefolder.is_loose_song(tmp_path) is False  # empty

    (tmp_path / "audio.wem").write_bytes(b"RIFF\x00")
    assert loosefolder.is_loose_song(tmp_path) is False  # no xml

    _write_min_xml(tmp_path / "lead.xml")
    assert loosefolder.is_loose_song(tmp_path) is True

    not_a_dir = tmp_path / "lead.xml"
    assert loosefolder.is_loose_song(not_a_dir) is False


def test_is_loose_song_ignores_preview_wem(tmp_path):
    (tmp_path / "preview_song.wem").write_bytes(b"RIFF\x00")
    _write_min_xml(tmp_path / "lead.xml")
    # No non-preview WEM: not a loose song.
    assert loosefolder.is_loose_song(tmp_path) is False


def test_is_loose_song_rejects_vocals_only_folder(tmp_path):
    """A folder with audio + only a vocals/showlights XML has no playable
    arrangement and must not be classified as a loose song."""
    (tmp_path / "audio.wem").write_bytes(b"RIFF\x00")
    (tmp_path / "vocals.xml").write_text("<vocals/>", encoding="utf-8")
    (tmp_path / "showlights.xml").write_text("<showlights/>", encoding="utf-8")
    assert loosefolder.is_loose_song(tmp_path) is False


def test_is_loose_song_rejects_external_symlinked_xml(tmp_path):
    """An XML symlinked outside the folder must not count as an
    in-folder arrangement (folder-boundary check)."""
    outside = tmp_path / "outside"
    outside.mkdir()
    real_xml = outside / "real.xml"
    _write_min_xml(real_xml)

    song = tmp_path / "song"
    song.mkdir()
    (song / "audio.wem").write_bytes(b"\0")
    try:
        (song / "lead.xml").symlink_to(real_xml)
    except (OSError, NotImplementedError):
        pytest.skip("symlink creation not permitted on this host")
    assert loosefolder.is_loose_song(song) is False


def test_is_loose_song_keeps_song_xml_with_vocals_in_filename(tmp_path):
    """Classification uses the XML root tag, not the filename — a custom
    named `lead_vocals_fix.xml` whose root is <song> is still a real
    playable arrangement."""
    (tmp_path / "audio.wem").write_bytes(b"RIFF\x00")
    _write_min_xml(tmp_path / "lead_vocals_fix.xml")
    assert loosefolder.is_loose_song(tmp_path) is True


def test_find_audio_prefers_known_names(tmp_path):
    big = tmp_path / "other.wem"
    big.write_bytes(b"\0" * 4096)
    canonical = tmp_path / "audio.wem"
    canonical.write_bytes(b"\0" * 8)
    assert loosefolder.find_audio(tmp_path) == canonical


def test_find_audio_skips_directories_named_audio_wem(tmp_path):
    """A directory named audio.wem must not be returned by find_audio
    or counted by is_loose_song (would later crash convert_wem)."""
    (tmp_path / "audio.wem").mkdir()  # directory, not a file
    _write_min_xml(tmp_path / "lead.xml")
    assert loosefolder.find_audio(tmp_path) is None
    assert loosefolder.is_loose_song(tmp_path) is False


def test_find_audio_falls_back_to_largest_non_preview(tmp_path):
    small = tmp_path / "tiny.wem"
    small.write_bytes(b"\0" * 16)
    big = tmp_path / "full.wem"
    big.write_bytes(b"\0" * 4096)
    (tmp_path / "preview_clip.wem").write_bytes(b"\0" * 8192)  # ignored
    assert loosefolder.find_audio(tmp_path) == big


def test_find_art_picks_first_recognised_name(tmp_path):
    (tmp_path / "album_art.jpg").write_bytes(b"\xff\xd8\xff\xe0")
    assert loosefolder.find_art(tmp_path).name == "album_art.jpg"


def test_find_art_matches_jpeg_and_webp_extensions(tmp_path):
    """get_song_art serves .jpeg and .webp media types, so find_art
    must discover those extensions too."""
    (tmp_path / "cover.webp").write_bytes(b"RIFF")
    assert loosefolder.find_art(tmp_path).name == "cover.webp"

    other = tmp_path / "elsewhere"
    other.mkdir()
    (other / "album_art.jpeg").write_bytes(b"\xff\xd8\xff\xe0")
    assert loosefolder.find_art(other).name == "album_art.jpeg"


def test_arr_type_from_filename():
    assert loosefolder._arr_type_from_filename("lead_v1")[0] == "lead"
    assert loosefolder._arr_type_from_filename("rhythm")[0] == "rhythm"
    assert loosefolder._arr_type_from_filename("bass_final")[0] == "bass"
    assert loosefolder._arr_type_from_filename("combo")[0] == "combo"
    assert loosefolder._arr_type_from_filename("chord_v2")[0] == "combo"
    # Unknown stem defaults to lead.
    assert loosefolder._arr_type_from_filename("mystery")[0] == "lead"


def test_extract_meta_priority_chain_manifest_wins(tmp_path):
    (tmp_path / "audio.wem").write_bytes(b"\0")
    _write_min_xml(tmp_path / "lead.xml",
                   title="XmlTitle", artist="XmlArtist", album="XmlAlbum",
                   year="2024", arrangement="Lead")
    (tmp_path / "manifest.json").write_text(json.dumps({
        "title": "ManifestTitle",
        "artist": "ManifestArtist",
        "year": "1999",
    }), encoding="utf-8")

    meta = loosefolder.extract_meta(tmp_path)
    assert meta["title"] == "ManifestTitle"
    assert meta["artist"] == "ManifestArtist"
    # Manifest didn't override album, so XML wins.
    assert meta["album"] == "XmlAlbum"
    assert meta["year"] == "1999"


def test_extract_meta_falls_back_to_xml(tmp_path):
    (tmp_path / "audio.wem").write_bytes(b"\0")
    _write_min_xml(tmp_path / "lead.xml",
                   title="XmlTitle", artist="XmlArtist", album="XmlAlbum",
                   year="2024", duration="200.0", arrangement="Lead")

    meta = loosefolder.extract_meta(tmp_path)
    assert meta["title"] == "XmlTitle"
    assert meta["artist"] == "XmlArtist"
    assert meta["album"] == "XmlAlbum"
    assert meta["year"] == "2024"
    assert meta["duration"] == 200.0
    assert meta["has_lyrics"] is False
    assert meta["audio_path"] and meta["audio_path"].endswith("audio.wem")


def test_extract_meta_detects_arrangements_sorted_by_priority(tmp_path):
    (tmp_path / "audio.wem").write_bytes(b"\0")
    _write_min_xml(tmp_path / "bass.xml", arrangement="Bass")
    _write_min_xml(tmp_path / "rhythm.xml", arrangement="Rhythm")
    _write_min_xml(tmp_path / "lead.xml", arrangement="Lead")

    meta = loosefolder.extract_meta(tmp_path)
    types = [a["type"] for a in meta["arrangements"]]
    assert types == ["lead", "rhythm", "bass"]


_BASS_TUNED_XML = """<?xml version="1.0" encoding="utf-8"?>
<song>
  <title>Same</title>
  <artistName>A</artistName>
  <albumName>Alb</albumName>
  <albumYear>2024</albumYear>
  <songLength>200</songLength>
  <arrangement>Bass</arrangement>
  <offset>0</offset>
  <tuning string0="-4" string1="-4" string2="-4" string3="-4" string4="0" string5="0"/>
</song>
"""

_LEAD_STD_XML = """<?xml version="1.0" encoding="utf-8"?>
<song>
  <title>Same</title>
  <artistName>A</artistName>
  <albumName>Alb</albumName>
  <albumYear>2024</albumYear>
  <songLength>200</songLength>
  <arrangement>Lead</arrangement>
  <offset>0</offset>
  <tuning string0="0" string1="0" string2="0" string3="0" string4="0" string5="0"/>
</song>
"""


def test_extract_meta_coerces_non_string_text_fields(tmp_path):
    """Non-string manifest title/artist/album/year values must fall
    back to XML/folder defaults instead of being stored verbatim."""
    (tmp_path / "audio.wem").write_bytes(b"\0")
    _write_min_xml(tmp_path / "lead.xml",
                   title="XmlTitle", artist="XmlArtist", album="XmlAlbum",
                   year="2024")
    (tmp_path / "manifest.json").write_text(json.dumps({
        "title": ["not", "a", "string"],
        "artist": None,
        "album": 1999,           # number masquerading as album
        "year": 2024,            # numeric year is allowed, becomes "2024"
    }), encoding="utf-8")

    meta = loosefolder.extract_meta(tmp_path)
    assert meta["title"] == "XmlTitle"
    assert meta["artist"] == "XmlArtist"
    assert meta["album"] == "XmlAlbum"
    assert meta["year"] == "2024"


def test_extract_meta_rejects_non_finite_manifest_duration(tmp_path):
    """A manifest with duration=Infinity / NaN must not poison metadata
    because Starlette refuses to JSON-encode non-finite floats and would
    later crash /api/song responses."""
    (tmp_path / "audio.wem").write_bytes(b"\0")
    _write_min_xml(tmp_path / "lead.xml", duration="120")
    (tmp_path / "manifest.json").write_text(json.dumps({
        "duration": "Infinity",
    }), encoding="utf-8")

    meta = loosefolder.extract_meta(tmp_path)
    assert meta["duration"] == 120.0  # falls back to XML, not float('inf')


def test_extract_meta_survives_malformed_manifest_duration(tmp_path):
    """A manifest.json with a non-numeric duration must not crash the
    scan — the value should be coerced to 0.0 and XML/folder fallback
    should still produce a row."""
    (tmp_path / "audio.wem").write_bytes(b"\0")
    _write_min_xml(tmp_path / "lead.xml", duration="200.0")
    (tmp_path / "manifest.json").write_text(json.dumps({
        "duration": "abc",
        "tuning_offsets": "not a list",
        "arrangements": "garbage",
    }), encoding="utf-8")

    meta = loosefolder.extract_meta(tmp_path)
    # Bad manifest duration falls back to XML's 200.0
    assert meta["duration"] == 200.0
    # Bad tuning_offsets falls back to XML's [0]*6
    assert meta["tuning_offsets"] == [0, 0, 0, 0, 0, 0]
    # Bad arrangements payload is ignored; detected arrangements survive
    assert len(meta["arrangements"]) == 1


def test_extract_meta_does_not_leak_absolute_path_as_artist(tmp_path):
    """Without a dlc_root, artist/album must not pull from absolute path
    components (e.g. user's home dir name)."""
    (tmp_path / "audio.wem").write_bytes(b"\0")
    _write_min_xml(tmp_path / "lead.xml", title="T", artist="", album="")

    meta = loosefolder.extract_meta(tmp_path)  # no dlc_root
    assert meta["artist"] == ""
    assert meta["album"] == ""


def test_extract_meta_infers_artist_album_from_dlc_relative_path(tmp_path):
    """With a dlc_root, artist/album are taken from the dlc-relative
    grandparent / parent dirs when XML and manifest are silent."""
    song_dir = tmp_path / "MyArtist" / "MyAlbum" / "MySong"
    song_dir.mkdir(parents=True)
    (song_dir / "audio.wem").write_bytes(b"\0")
    _write_min_xml(song_dir / "lead.xml", artist="", album="")

    meta = loosefolder.extract_meta(song_dir, dlc_root=tmp_path)
    assert meta["artist"] == "MyArtist"
    assert meta["album"] == "MyAlbum"


def test_extract_meta_uses_lead_tuning_when_bass_sorts_first(tmp_path):
    """bass.xml sorts before lead.xml alphabetically; the guitar tuning
    should still win shared_meta so the library shows E Standard rather
    than the bass's down-tuned offsets."""
    (tmp_path / "audio.wem").write_bytes(b"\0")
    (tmp_path / "bass.xml").write_text(_BASS_TUNED_XML, encoding="utf-8")
    (tmp_path / "lead.xml").write_text(_LEAD_STD_XML, encoding="utf-8")

    meta = loosefolder.extract_meta(tmp_path)
    assert meta["tuning_offsets"] == [0, 0, 0, 0, 0, 0]
