"""loosefolder.py — treat a directory of raw RS2014 assets as a playable song.

Expected layout (artist/album/song_dir structure is optional but used for
metadata inference when manifest.json is absent):

    song_dir/
        audio.wem          (required)
        lead.xml           (at least one arrangement XML required)
        rhythm.xml
        bass.xml
        manifest.json      (optional)
        album_art.jpg / cover.jpg / album.png  (optional)

manifest.json fields (all optional — XML metadata fills in gaps):
    {
        "title":          "Song Name",
        "artist":         "Artist Name",
        "album":          "Album Name",
        "year":           "2024",
        "tuning_offsets": [0, 0, 0, 0, 0, 0]
    }
"""

import json
import math
import xml.etree.ElementTree as ET
from pathlib import Path

AUDIO_NAMES = ["audio.wem", "song.wem"]
# Match every extension server.get_song_art is prepared to serve
# (jpeg/png/webp). Without `.jpeg`/`.webp`, loose folders shipping
# `cover.jpeg` or `album_art.webp` would never have their art surfaced.
_ART_STEMS = ["album_art", "cover", "album", "art", "folder"]
_ART_EXTS  = [".jpg", ".jpeg", ".png", ".webp"]
ART_NAMES   = [f"{stem}{ext}" for stem in _ART_STEMS for ext in _ART_EXTS]

# Arrangement type detection from filename keywords or <arrangement> tag
# Format: keyword -> (type, display_name, sort_priority)
ARR_TYPE_MAP = {
    "lead":     ("lead",   "Lead",   0),
    "rhythm":   ("rhythm", "Rhythm", 2),
    "bass":     ("bass",   "Bass",   3),
    "combo":    ("combo",  "Combo",  1),
    "chord":    ("combo",  "Combo",  1),
    "humstrum": ("combo",  "Combo",  1),
}


def _iter_local(path: Path, pattern: str):
    """Yield regular files matching `pattern` in `path` that resolve
    inside `path`.

    Two guards in one helper:
      * Reject directories (a folder named `audio.wem` or `lead.xml`
        would otherwise be matched by glob and break downstream
        readers / converters).
      * Reject symlinks escaping the folder so a crafted custom song can't
        smuggle external content into the scan.
    """
    root = path.resolve()
    for match in path.glob(pattern):
        if not match.is_file():
            continue
        try:
            resolved = match.resolve()
            resolved.relative_to(root)
        except (OSError, ValueError):
            continue
        yield resolved


def _iter_local_xmls(path: Path):
    """Backwards-compatible wrapper for `_iter_local(path, '*.xml')`."""
    yield from _iter_local(path, "*.xml")


def is_loose_song(path: Path) -> bool:
    """True if this directory looks like a playable loose song folder.

    Requires both a non-preview WEM and at least one arrangement XML
    that isn't a vocals or showlights track — otherwise highway_ws would
    later fail when it tries to pick an arrangement from an empty list.

    Classification looks at the XML root element rather than the
    filename so a custom named `lead_vocals_fix.xml` (root `<song>`)
    still counts as a playable arrangement.
    """
    if not path.is_dir():
        return False
    # Require an actual file that resolves inside `path` — both
    # rejects directories named `audio.wem` and refuses symlinks
    # escaping the song folder.
    def _named_audio_ok(name: str) -> bool:
        p = path / name
        if not p.is_file():
            return False
        try:
            p.resolve().relative_to(path.resolve())
        except (OSError, ValueError):
            return False
        return True

    has_audio = (
        any(_named_audio_ok(a) for a in AUDIO_NAMES)
        or any("preview" not in f.stem.lower()
               for f in _iter_local(path, "*.wem"))
    )
    if not has_audio:
        return False
    for xml in _iter_local_xmls(path):
        try:
            root_tag = ET.parse(str(xml)).getroot().tag
        except Exception:
            continue
        if root_tag == "song":
            return True
    return False


def find_audio(path: Path) -> Path | None:
    """Return the path to the best audio file in the folder.
    Prefers known names, then falls back to any WEM that isn't a preview clip.
    Only returns regular files that resolve inside `path` — a directory,
    broken symlink, or symlink escaping the folder would otherwise be
    returned and either break convert_wem or read external content.
    """
    root = path.resolve()

    def _in_folder(p: Path) -> bool:
        try:
            p.resolve().relative_to(root)
        except (OSError, ValueError):
            return False
        return True

    # Check known names first
    for a in AUDIO_NAMES:
        cand = path / a
        if cand.is_file() and _in_folder(cand):
            return cand

    def _safe_size(f: Path) -> int:
        # Treat unreadable files (broken symlinks, permission errors)
        # as zero-byte so they sort last and never get picked.
        try:
            return f.stat().st_size
        except OSError:
            return 0

    candidates = sorted(
        [f for f in _iter_local(path, "*.wem")
         if "preview" not in f.stem.lower()],
        key=_safe_size,
        reverse=True,
    )
    return candidates[0] if candidates else None


def find_art(path: Path) -> Path | None:
    """Return the path to the first recognised album art file in the folder.

    Only matches regular files — a directory named `cover.jpg` would
    otherwise be returned and trip the FileResponse / containment
    checks downstream.
    """
    return next((path / a for a in ART_NAMES if (path / a).is_file()), None)


def _arr_type_from_filename(stem: str) -> tuple:
    """Infer arrangement type from filename keywords."""
    s = stem.lower()
    for key, val in ARR_TYPE_MAP.items():
        if key in s:
            return val
    return ("lead", "Lead", 0)  # fallback


def _parse_xml_meta(xml_path: Path) -> dict:
    """Parse a chart arrangement XML and return song-level metadata."""
    try:
        root = ET.parse(str(xml_path)).getroot()
        if root.tag != "song":
            return {}

        def txt(tag, default=""):
            el = root.find(tag)
            return el.text.strip() if el is not None and el.text else default

        # Tuning from attributes
        tuning_el = root.find("tuning")
        if tuning_el is not None:
            offsets = [int(tuning_el.get(f"string{i}", 0)) for i in range(6)]
        else:
            offsets = [0] * 6

        # Arrangement type — filename is more reliable than the XML tag
        # because some authoring tools write "Lead" for all arrangements.
        # We read the XML tag here and let _detect_arrangements decide
        # which source to trust.
        arr_tag = txt("arrangement", "").lower()
        arr_from_tag = ARR_TYPE_MAP.get(arr_tag, None)

        duration = 0.0
        try:
            duration = float(txt("songLength", "0"))
        except (ValueError, TypeError):
            pass

        return {
            "title":          txt("title"),
            "artist":         txt("artistName"),
            "album":          txt("albumName"),
            "year":           txt("albumYear", ""),
            "duration":       duration,
            "tuning_offsets": offsets,
            "arr_from_tag":   arr_from_tag,  # may be None
        }
    except Exception:
        return {}


def _detect_arrangements(path: Path) -> tuple[list[dict], dict]:
    """
    Parse all arrangement XMLs.
    Returns (arrangements_list, shared_meta).
    shared_meta contains title/artist/album/year/duration/tuning_offsets
    sourced from the highest-priority arrangement (lead > combo > rhythm >
    bass) — picking the guitar tuning when both bass and lead are present.
    """
    arrangements = []
    # Track which arrangement priority sourced shared_meta so a later,
    # higher-priority arrangement (lead < bass in sort order) overrides.
    shared_meta = {}
    shared_priority = None

    for xml in sorted(_iter_local_xmls(path)):
        # Trust the XML root over the filename — a custom named
        # `lead_vocals_fix.xml` is still a real arrangement.
        # `_parse_xml_meta` returns {} for any root other than <song>,
        # which is how vocals/showlights tracks get filtered out.
        stem = xml.stem.lower()

        meta = _parse_xml_meta(xml)
        if not meta:
            continue

        # Arrangement type: prefer filename keywords over the XML tag
        # because some tools write "Lead" for all arrangements regardless
        # of actual type. Only fall back to the XML tag when the filename
        # gives no useful signal (i.e. no recognisable keyword found).
        filename_type = _arr_type_from_filename(stem)
        if filename_type[0] != "lead" or "lead" in stem:
            # Filename gave a confident answer
            arr_type, arr_name, priority = filename_type
        else:
            # Filename wasn't specific — try the XML tag
            arr_from_tag = meta.get("arr_from_tag")
            if arr_from_tag:
                arr_type, arr_name, priority = arr_from_tag
            else:
                arr_type, arr_name, priority = filename_type

        # Take song-level fields from the highest-priority arrangement
        # (lowest `priority` number) so tuning reflects the main guitar
        # instead of whatever sorted first alphabetically.
        if meta.get("title") and (shared_priority is None or priority < shared_priority):
            shared_meta = {k: meta[k] for k in
                           ("title", "artist", "album", "year",
                            "duration", "tuning_offsets")}
            shared_priority = priority

        arrangements.append({
            "type":     arr_type,
            "name":     arr_name,
            "file":     xml.name,
            "priority": priority,
        })

    arrangements.sort(key=lambda a: a["priority"])
    for i, a in enumerate(arrangements):
        a["index"] = i
        del a["priority"]

    return arrangements, shared_meta


def _has_lyrics(path: Path) -> bool:
    """Return True if any XML in the folder is a vocals track."""
    for xml in _iter_local_xmls(path):
        try:
            if ET.parse(str(xml)).getroot().tag == "vocals":
                return True
        except Exception:
            pass
    return False


def _coerce_duration(raw, fallback) -> float:
    """Coerce a manifest duration to a finite float, falling back on bad input.

    Rejects NaN / Infinity so a manifest like `{"duration": "Infinity"}`
    can't poison `meta_db` and then crash Starlette's JSON encoder when
    the row is served back through `/api/song/...`.
    """
    try:
        v = float(raw)
        if math.isfinite(v):
            return v
    except (TypeError, ValueError):
        pass
    try:
        v = float(fallback or 0.0)
        return v if math.isfinite(v) else 0.0
    except (TypeError, ValueError):
        return 0.0


def _coerce_text(raw) -> str | None:
    """Return raw if it's a non-empty string, else None.

    Manifest fields like `title` / `artist` / `album` can arrive as
    JSON nulls, lists, or numbers (e.g. someone setting album to a
    year by mistake). Returning None lets the caller fall back to
    XML / folder inference instead of crashing the DB row write.
    """
    if isinstance(raw, str) and raw:
        return raw
    return None


def _coerce_tuning_offsets(raw, fallback) -> list[int]:
    """Validate manifest tuning_offsets: must be a list of 6 numeric values."""
    if isinstance(raw, list) and len(raw) == 6:
        try:
            return [int(v) for v in raw]
        except (TypeError, ValueError):
            pass
    if isinstance(fallback, list) and len(fallback) == 6:
        return list(fallback)
    return [0] * 6


def _validate_manifest_arrangements(raw) -> list[dict] | None:
    """Return raw if it's a well-formed arrangement list, else None.

    Each entry must be a dict carrying at least `type`, `name`, `file`
    (string-typed). Bad shapes get dropped so the parsed XML list wins.
    """
    if not isinstance(raw, list) or not raw:
        return None
    out = []
    for entry in raw:
        if not isinstance(entry, dict):
            return None
        if not all(isinstance(entry.get(k), str) and entry.get(k)
                   for k in ("type", "name", "file")):
            return None
        out.append(entry)
    return out


def extract_meta(path: Path, dlc_root: Path | None = None) -> dict:
    """Return metadata dict for a loose song folder.

    Priority chain:
      1. manifest.json (explicit user-supplied data)
      2. XML metadata  (parsed from arrangement XMLs)
      3. Folder name   (last resort inference from directory structure)

    `dlc_root` is used to bound the folder-name inference: artist/album
    are only inferred from `path.relative_to(dlc_root)` components, so
    a loose folder placed at `<DLC>/song/` doesn't accidentally surface
    the user's home-directory name as the artist.
    """
    # 1. Try manifest.json
    manifest_path = path / "manifest.json"
    manifest = {}
    if manifest_path.exists():
        try:
            manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
        except Exception:
            pass
    if not isinstance(manifest, dict):
        manifest = {}

    # 2. Parse XMLs for song metadata + arrangements
    arrangements, xml_meta = _detect_arrangements(path)

    # 3. Folder inference — only from path components under DLC_DIR so
    # absolute-path parts (`/home/<user>/...`) can never leak as artist.
    rel_parts: tuple[str, ...] = ()
    if dlc_root is not None:
        try:
            rel_parts = path.resolve().relative_to(dlc_root.resolve()).parts
        except (ValueError, OSError):
            rel_parts = ()

    # Coerce manifest text fields — non-strings (lists, numbers, null)
    # would otherwise propagate into meta_db rows and break DB writes.
    title  = _coerce_text(manifest.get("title"))  or xml_meta.get("title")  or path.name
    artist = (_coerce_text(manifest.get("artist")) or xml_meta.get("artist")
              or (rel_parts[-3] if len(rel_parts) >= 3 else ""))
    album  = (_coerce_text(manifest.get("album")) or xml_meta.get("album")
              or (rel_parts[-2] if len(rel_parts) >= 2 else ""))
    raw_year = manifest.get("year")
    if isinstance(raw_year, (int, float)) and not isinstance(raw_year, bool):
        manifest_year = str(int(raw_year))
    else:
        manifest_year = _coerce_text(raw_year) or ""
    year   = manifest_year or str(xml_meta.get("year", ""))
    duration = _coerce_duration(manifest.get("duration"),
                                xml_meta.get("duration", 0))
    tuning_offsets = _coerce_tuning_offsets(manifest.get("tuning_offsets"),
                                            xml_meta.get("tuning_offsets"))

    manifest_arr = _validate_manifest_arrangements(manifest.get("arrangements"))
    if manifest_arr is not None:
        arrangements = manifest_arr

    audio = find_audio(path)
    art   = find_art(path)

    return {
        "title":          title,
        "artist":         artist,
        "album":          album,
        "year":           year,
        "duration":       duration,
        "tuning_offsets": tuning_offsets,
        "arrangements":   arrangements,
        "audio_path":     str(audio) if audio else None,
        "art_path":       str(art)   if art   else None,
        "has_lyrics":     _has_lyrics(path),
    }
