"""End-to-end test for the sloppak loader recognising a `drum_tab:` manifest
key and surfacing the parsed payload on the LoadedSloppak."""

from __future__ import annotations

import json
from pathlib import Path

import yaml

import sloppak as sloppak_mod


def _write_dir_sloppak(root: Path, manifest_extras: dict, drum_tab_payload: dict | None) -> Path:
    """Build a minimal directory-form sloppak that load_song will accept.

    Uses the tmp_path leaf name to make the sloppak filename unique per test,
    avoiding the module-level ``resolve_source_dir`` cache being poisoned by
    a previous test that happened to share the same "song.sloppak" filename.
    """
    pak = root / f"{root.name}.sloppak"
    pak.mkdir()
    arr_dir = pak / "arrangements"
    arr_dir.mkdir()

    # One trivial guitar arrangement — load_song bails on empty arrangements
    # for non-drum-only sloppaks, and the wire format demands at least the
    # standard keys exist (an empty list is fine for each).
    arr = {
        "name": "Lead",
        "tuning": [0, 0, 0, 0, 0, 0],
        "capo": 0,
        "notes": [],
        "chords": [],
        "anchors": [],
        "handshapes": [],
        "templates": [],
        "beats": [],
        "sections": [],
    }
    (arr_dir / "lead.json").write_text(json.dumps(arr))

    manifest = {
        "title": "Test",
        "artist": "Tester",
        "album": "",
        "year": 2026,
        "duration": 10.0,
        "arrangements": [{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"}],
        "stems": [{"id": "full", "file": "stems/full.ogg", "default": True}],
    }
    manifest.update(manifest_extras)
    (pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))

    if drum_tab_payload is not None:
        (pak / "drum_tab.json").write_text(json.dumps(drum_tab_payload))

    return pak


def _load(pak_path: Path, tmp_path: Path):
    """Invoke sloppak_mod.load_song using its DLC-relative API."""
    dlc_root = pak_path.parent
    cache = tmp_path / "cache"
    cache.mkdir()
    return sloppak_mod.load_song(pak_path.name, dlc_root, cache)


# ── Happy path ───────────────────────────────────────────────────────────────

def test_load_song_attaches_drum_tab_when_manifest_opts_in(tmp_path: Path):
    payload = {
        "version": 1,
        "name": "Drums",
        "kit": [{"id": "kick", "name": "Kick"}, {"id": "snare", "name": "Snare"}],
        "hits": [
            {"t": 0.5, "p": "kick", "v": 110},
            {"t": 0.75, "p": "snare", "v": 92},
        ],
    }
    pak = _write_dir_sloppak(tmp_path, {"drum_tab": "drum_tab.json"}, payload)
    loaded = _load(pak, tmp_path)
    assert loaded.drum_tab is not None
    assert len(loaded.drum_tab["hits"]) == 2
    assert loaded.drum_tab["kit"][0]["id"] == "kick"


def test_load_song_drum_tab_absent_when_manifest_silent(tmp_path: Path):
    pak = _write_dir_sloppak(tmp_path, {}, None)
    loaded = _load(pak, tmp_path)
    assert loaded.drum_tab is None


def test_load_song_drum_tab_absent_when_file_missing(tmp_path: Path):
    pak = _write_dir_sloppak(tmp_path, {"drum_tab": "nope.json"}, None)
    loaded = _load(pak, tmp_path)
    # Pointing at a missing file disables drums silently — don't crash the load.
    assert loaded.drum_tab is None


def test_load_song_drum_tab_absent_when_payload_invalid_json(tmp_path: Path):
    pak = _write_dir_sloppak(tmp_path, {"drum_tab": "drum_tab.json"}, None)
    (pak / "drum_tab.json").write_text("not json {{{")
    loaded = _load(pak, tmp_path)
    assert loaded.drum_tab is None


def test_load_song_drum_tab_absent_when_payload_fails_validation(tmp_path: Path):
    # `hits` must be a list per validate_drum_tab — this is a hard rejection.
    pak = _write_dir_sloppak(
        tmp_path,
        {"drum_tab": "drum_tab.json"},
        {"version": 1, "hits": "not a list"},  # type: ignore[arg-type]
    )
    loaded = _load(pak, tmp_path)
    assert loaded.drum_tab is None


def test_load_song_drum_tab_round_trips_unknown_pieces(tmp_path: Path):
    """A future piece-id must round-trip — older clients then render it as a
    generic rectangle rather than dropping it."""
    payload = {
        "version": 1,
        "kit": [{"id": "cowbell", "name": "Cowbell"}],
        "hits": [{"t": 1.0, "p": "cowbell", "v": 100}],
    }
    pak = _write_dir_sloppak(tmp_path, {"drum_tab": "drum_tab.json"}, payload)
    loaded = _load(pak, tmp_path)
    assert loaded.drum_tab is not None
    assert loaded.drum_tab["hits"][0]["p"] == "cowbell"


# ── Security / path-traversal branches ──────────────────────────────────────

def test_load_song_drum_tab_absent_when_path_escapes_sloppak(tmp_path: Path):
    """A manifest drum_tab value that escapes the sloppak directory via '../'
    must be silently rejected — drums disabled, load continues."""
    pak = _write_dir_sloppak(tmp_path, {"drum_tab": "../outside.json"}, None)
    loaded = _load(pak, tmp_path)
    assert loaded.drum_tab is None


def test_load_song_drum_tab_absent_when_path_is_absolute(tmp_path: Path):
    """An absolute drum_tab path in the manifest must be silently rejected."""
    pak = _write_dir_sloppak(tmp_path, {"drum_tab": "/etc/passwd"}, None)
    loaded = _load(pak, tmp_path)
    assert loaded.drum_tab is None


# ── drum-only (no guitar arrangements) ──────────────────────────────────────

def _write_drum_only_sloppak(root: Path, drum_tab_payload: dict) -> Path:
    """A sloppak with no guitar arrangements — only a drum_tab entry."""
    pak = root / f"{root.name}.sloppak"
    pak.mkdir()
    drum_tab_path = pak / "drum_tab.json"
    drum_tab_path.write_text(json.dumps(drum_tab_payload))
    manifest = {
        "title": "Drum Only",
        "artist": "Tester",
        "album": "",
        "year": 2026,
        "duration": 5.0,
        "arrangements": [],
        "drum_tab": "drum_tab.json",
        "stems": [],
    }
    (pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
    return pak


def test_drum_only_arrangement_ids_parallel_to_song_arrangements(tmp_path: Path):
    """arrangement_ids must stay parallel to song.arrangements for drum-only sloppaks.

    When load_song synthesises a placeholder Drums arrangement (because the
    manifest has no guitar arrangements but has a drum_tab key), it must also
    append a corresponding None to arrangement_ids so
    len(arrangement_ids) == len(song.arrangements) is preserved.
    """
    payload = {
        "version": 1,
        "name": "Drums",
        "kit": [{"id": "kick", "name": "Kick"}],
        "hits": [{"t": 0.5, "p": "kick", "v": 110}, {"t": 1.0, "p": "kick", "v": 90}],
    }
    pak = _write_drum_only_sloppak(tmp_path, payload)
    loaded = _load(pak, tmp_path)

    assert loaded.drum_tab is not None
    assert len(loaded.song.arrangements) == 1, (
        "expected the synthetic Drums placeholder to be present"
    )
    assert loaded.song.arrangements[0].name == "Drums", (
        f"synthesised placeholder must be named 'Drums', got {loaded.song.arrangements[0].name!r}"
    )
    assert len(loaded.arrangement_ids) == len(loaded.song.arrangements), (
        f"arrangement_ids ({loaded.arrangement_ids!r}) must be parallel to "
        f"song.arrangements (len={len(loaded.song.arrangements)})"
    )
    assert loaded.arrangement_ids == [None], (
        f"synthesised Drums placeholder must map to None in arrangement_ids, got {loaded.arrangement_ids!r}"
    )
