"""Music library maintenance, refactored from the original ``music-covers.py`` CLI.

The interactive terminal UI is gone; what remains is pure, importable logic the
web app drives. Two entry points matter:

* :func:`scan_library` — read-only analysis. Walks ``MUSIC_ROOT`` and reports, per
  album, what each maintenance mode *would* do. Safe to call any time.
* :func:`process_library` — performs the work. Honours ``dry_run`` (the web UI
  default) so nothing is renamed, deleted, or downloaded unless the caller opts in.

Both are synchronous (filesystem + blocking HTTP); call them from FastAPI via
``asyncio.to_thread``. Progress is reported through an optional ``log`` callback.
"""

from __future__ import annotations

import logging
import os
import queue
import re
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Iterator

import requests

try:  # Optional: only needed for the "find missing year/cover" online lookups.
    import musicbrainzngs

    musicbrainzngs.set_useragent("HomelabToolkit", "1.0", "homelab-toolkit@example.com")
    # musicbrainzngs logs "uncaught attribute"/"uncaught tag" at INFO whenever the
    # MusicBrainz XML carries fields it doesn't model (e.g. release-group type-id).
    # Harmless noise — keep only real warnings.
    logging.getLogger("musicbrainzngs").setLevel(logging.WARNING)
    _HAS_MUSICBRAINZ = True
except Exception:  # pragma: no cover - optional dependency
    _HAS_MUSICBRAINZ = False

from mutagen import File as MutagenFile, MutagenError

MUSIC_ROOT = Path(os.environ.get("MUSIC_ROOT", r"\\Matt-htpc\d\Music"))

COVER_NAME_PRIORITY = ("cover.jpg", "folder.jpg", "front.jpg")
COVER_NAMES = set(COVER_NAME_PRIORITY)
COVER_MISSING_MARKER = ".cover-not-found"
LYRICS_MISSING_MARKER = ".lyrics-not-found"
LYRICS_SIDECAR_EXTENSIONS = {".lrc", ".txt"}
AUDIO_EXTENSIONS = {".mp3", ".flac", ".m4a"}
YEAR_ALBUM_FOLDER_RE = re.compile(r"^\s*(\d{4})\s*-\s*(.+?)\s*$")

LogCallback = Callable[[dict], None]


DEFAULT_RECENT_WINDOW_SECONDS = 2 * 60 * 60


@dataclass
class ProcessOptions:
    folder_cleanup: bool = False
    rename: bool = False
    file_cleanup: bool = False
    lyrics: bool = False
    covers: bool = True
    dry_run: bool = True
    recent_only: bool = False
    recent_window_seconds: int = DEFAULT_RECENT_WINDOW_SECONDS

    @classmethod
    def from_dict(cls, data: dict) -> "ProcessOptions":
        try:
            window = int(data.get("recent_window_seconds") or DEFAULT_RECENT_WINDOW_SECONDS)
        except (TypeError, ValueError):
            window = DEFAULT_RECENT_WINDOW_SECONDS
        return cls(
            folder_cleanup=bool(data.get("folder_cleanup", False)),
            rename=bool(data.get("rename", False)),
            file_cleanup=bool(data.get("file_cleanup", False)),
            lyrics=bool(data.get("lyrics", False)),
            covers=bool(data.get("covers", True)),
            dry_run=bool(data.get("dry_run", True)),
            recent_only=bool(data.get("recent_only", False)),
            recent_window_seconds=window,
        )


@dataclass
class _Recorder:
    """Collects structured action records and forwards them to an optional sink."""

    sink: LogCallback | None = None
    actions: list[dict] = field(default_factory=list)
    counts: dict[str, int] = field(default_factory=dict)

    def emit(self, level: str, action: str, message: str, **extra) -> None:
        record = {"level": level, "action": action, "message": message, **extra}
        self.actions.append(record)
        self.counts[action] = self.counts.get(action, 0) + 1
        if self.sink:
            self.sink(record)


# ── pure string helpers ──────────────────────────────────────────────────────


def clean_name(text: str) -> str:
    text = re.sub(r"\[(.*?)\]|\((.*?)\)", "", text)
    text = text.replace("_", " ").replace("-", " ")
    return " ".join(text.split()).strip()


def clean_album_folder_name(text: str) -> str:
    match = YEAR_ALBUM_FOLDER_RE.match(text)
    if match:
        text = match.group(2)
    return clean_name(text)


def get_year_from_album_folder_name(text: str) -> str | None:
    match = YEAR_ALBUM_FOLDER_RE.match(text)
    return match.group(1) if match else None


def safe_filename(text: str) -> str:
    text = re.sub(r'[<>:"/\\|?*]', "", text)
    text = text.strip().rstrip(".")
    return " ".join(text.split())


def clean_track_number(value) -> str | None:
    if not value:
        return None
    text = str(value[0] if isinstance(value, list) else value).strip()
    text = text.split("/")[0].strip()
    return text.zfill(2) if text.isdigit() else None


def extract_year(value: str | None) -> str | None:
    if not value:
        return None
    match = re.search(r"\b(19\d{2}|20\d{2})\b", str(value))
    return match.group(1) if match else None


# ── metadata reading ─────────────────────────────────────────────────────────


def _load_audio(path: Path):
    try:
        return MutagenFile(path, easy=True)
    except (MutagenError, OSError):
        return None


def _first_tag(audio, names):
    for name in names:
        value = audio.get(name)
        if value:
            return str(value[0]).strip()
    return None


def get_album_metadata_from_files(album_folder: Path):
    for file in album_folder.iterdir():
        if not file.is_file() or file.suffix.lower() not in AUDIO_EXTENSIONS:
            continue
        audio = _load_audio(file)
        if audio is None:
            continue
        artist = _first_tag(audio, ["albumartist", "artist"])
        album = _first_tag(audio, ["album"])
        year = extract_year(_first_tag(audio, ["date", "originaldate", "year"]))
        if artist or album or year:
            return artist, album, year
    return None, None, None


def get_track_metadata(path: Path):
    audio = _load_audio(path)
    if audio is None:
        return None, None, None, None
    artist = _first_tag(audio, ["artist", "albumartist"])
    album = _first_tag(audio, ["album"])
    title = _first_tag(audio, ["title"])
    duration = None
    info = getattr(audio, "info", None)
    if info and getattr(info, "length", None):
        duration = round(info.length)
    return artist, album, title, duration


# ── album inspection ─────────────────────────────────────────────────────────


def _cover_to_keep(album_folder: Path) -> str | None:
    existing = {p.name.lower() for p in album_folder.iterdir() if p.is_file()}
    for name in COVER_NAME_PRIORITY:
        if name in existing:
            return name
    return None


def _has_cover(album_folder: Path) -> bool:
    existing = {p.name.lower() for p in album_folder.iterdir() if p.is_file()}
    return any(name in existing for name in COVER_NAMES)


def _should_keep_file(path: Path, cover_to_keep: str | None) -> bool:
    name = path.name.lower()
    suffix = path.suffix.lower()
    return (
        suffix in AUDIO_EXTENSIONS
        or suffix in LYRICS_SIDECAR_EXTENSIONS
        or name == cover_to_keep
    )


def _has_lyrics(audio_file: Path) -> bool:
    return any(
        audio_file.with_suffix(extension).exists()
        for extension in LYRICS_SIDECAR_EXTENSIONS
    )


def analyze_album(album_folder: Path) -> dict:
    """Read-only summary of an album folder and the pending maintenance work."""
    tag_artist, tag_album, tag_year = get_album_metadata_from_files(album_folder)
    artist = tag_artist or clean_name(album_folder.parent.name)
    album = tag_album or clean_album_folder_name(album_folder.name)
    year = tag_year or get_year_from_album_folder_name(album_folder.name)

    audio_files = [
        f for f in album_folder.iterdir()
        if f.is_file() and f.suffix.lower() in AUDIO_EXTENSIONS
    ]
    cover_to_keep = _cover_to_keep(album_folder)
    extra_files = [
        f.name for f in album_folder.iterdir()
        if f.is_file() and not _should_keep_file(f, cover_to_keep)
    ]

    suggested_folder = None
    if album and year:
        candidate = f"{year} - {safe_filename(album)}"
        if candidate != album_folder.name:
            suggested_folder = candidate

    missing_lyrics = sum(1 for f in audio_files if not _has_lyrics(f))

    return {
        "path": str(album_folder),
        "folder_name": album_folder.name,
        "artist": artist or "",
        "album": album or "",
        "year": year,
        "track_count": len(audio_files),
        "has_cover": _has_cover(album_folder),
        "suggested_folder": suggested_folder,
        "needs_folder_rename": suggested_folder is not None,
        "extra_files": extra_files,
        "extra_file_count": len(extra_files),
        "missing_lyrics_count": missing_lyrics,
    }


def _was_created_recently(folder: Path, window_seconds: int) -> bool:
    """True if the folder was created/modified within the window (recent-only mode)."""
    try:
        stat = folder.stat()
    except OSError:
        return False
    age = time.time() - max(stat.st_ctime, stat.st_mtime)
    return 0 <= age <= window_seconds


def _iter_album_folders(root: Path):
    for first_level in root.iterdir():
        if not first_level.is_dir():
            continue
        try:
            has_audio = any(
                p.is_file() and p.suffix.lower() in AUDIO_EXTENSIONS
                for p in first_level.iterdir()
            )
        except OSError:
            continue
        if has_audio:
            # Top-level folder holding audio directly is not an Artist/Album tree.
            continue
        for album_folder in first_level.iterdir():
            if album_folder.is_dir():
                yield album_folder


def scan_library(root: Path | None = None) -> dict:
    root = root or MUSIC_ROOT
    if not root.exists():
        return {"root": str(root), "exists": False, "albums": []}

    albums = []
    for album_folder in _iter_album_folders(root):
        try:
            albums.append(analyze_album(album_folder))
        except OSError:
            continue
    albums.sort(key=lambda a: (a["artist"].lower(), a["year"] or "", a["album"].lower()))

    return {
        "root": str(root),
        "exists": True,
        "album_count": len(albums),
        "missing_cover_count": sum(1 for a in albums if not a["has_cover"]),
        "needs_rename_count": sum(1 for a in albums if a["needs_folder_rename"]),
        "extra_file_count": sum(a["extra_file_count"] for a in albums),
        "albums": albums,
    }


# ── online lookups (year / cover / lyrics) ───────────────────────────────────


def find_album_year(artist: str, album: str) -> str | None:
    if not _HAS_MUSICBRAINZ:
        return None
    try:
        result = musicbrainzngs.search_releases(artist=artist, release=album, limit=5)
        for release in result.get("release-list", []):
            year = extract_year(release.get("date"))
            if year:
                return year
    except Exception:
        return None
    return None


def find_album_cover(artist: str, album: str) -> bytes | None:
    if not _HAS_MUSICBRAINZ:
        return None
    try:
        result = musicbrainzngs.search_releases(artist=artist, release=album, limit=3)
        releases = result.get("release-list", [])
        if not releases:
            return None
        mbid = releases[0]["id"]
        url = f"https://coverartarchive.org/release/{mbid}/front-500"
        response = requests.get(url, timeout=20, allow_redirects=True)
        if response.status_code == 200 and response.headers.get("content-type", "").startswith("image"):
            return response.content
    except Exception:
        return None
    return None


def find_track_lyrics(artist: str, album: str, title: str, duration: int | None):
    if not duration:
        return None
    try:
        response = requests.get(
            "https://lrclib.net/api/get",
            params={
                "artist_name": artist,
                "track_name": title,
                "album_name": album,
                "duration": duration,
            },
            headers={"User-Agent": "HomelabToolkit/1.0 (local music library tool)"},
            timeout=20,
        )
        if response.status_code != 200:
            return None
        data = response.json()
        if data.get("syncedLyrics"):
            return ".lrc", data["syncedLyrics"].strip() + "\n"
        if data.get("plainLyrics"):
            return ".txt", data["plainLyrics"].strip() + "\n"
    except Exception:
        return None
    return None


# ── mutating operations (respect dry_run) ────────────────────────────────────


def _is_top_level(folder: Path, root: Path) -> bool:
    try:
        return folder.resolve().parent == root.resolve()
    except OSError:
        return folder.parent == root


def _rename_album_folder(album_folder, artist, album, year, root, opts, rec) -> Path:
    if not album or not year or _is_top_level(album_folder, root):
        return album_folder
    new_name = f"{year} - {safe_filename(album)}"
    if album_folder.name == new_name:
        return album_folder
    new_folder = album_folder.with_name(new_name)
    if new_folder.exists():
        rec.emit("skip", "folder", f"Rename target already exists: {new_name}")
        return album_folder
    rec.emit(
        "dry" if opts.dry_run else "ok",
        "folder",
        f"{album_folder.name}  →  {new_name}",
        path=str(album_folder),
    )
    if opts.dry_run:
        return album_folder
    album_folder.rename(new_folder)
    return new_folder


def _rename_track(path: Path, opts, rec) -> Path:
    audio = _load_audio(path)
    if audio is None:
        return path
    artist = _first_tag(audio, ["artist", "albumartist"])
    album = _first_tag(audio, ["album"])
    title = _first_tag(audio, ["title"])
    track = clean_track_number(audio.get("tracknumber"))
    if not (artist and album and title and track):
        return path
    new_name = f"{track} - {safe_filename(title)}{path.suffix.lower()}"
    if path.name == new_name:
        return path
    new_path = path.with_name(new_name)
    if new_path.exists():
        rec.emit("skip", "rename", f"Target exists: {new_name}")
        return path
    rec.emit("dry" if opts.dry_run else "ok", "rename", f"{path.name}  →  {new_name}")
    if opts.dry_run:
        return path
    path.rename(new_path)
    return new_path


def _clean_files(album_folder: Path, opts, rec) -> None:
    cover_to_keep = _cover_to_keep(album_folder)
    for file in album_folder.iterdir():
        if not file.is_file() or _should_keep_file(file, cover_to_keep):
            continue
        rec.emit("dry" if opts.dry_run else "ok", "remove", f"Remove {file.name}", path=str(file))
        if opts.dry_run:
            continue
        try:
            file.unlink()
        except OSError as exc:
            rec.emit("warn", "remove", f"Could not remove {file.name}: {exc}")


def _fetch_cover(album_folder, artist, album, opts, rec) -> None:
    if _has_cover(album_folder):
        return
    if not (artist and album):
        return
    rec.emit("info", "cover", f"Looking up cover: {artist} - {album}")
    image = find_album_cover(artist, album)
    if not image:
        rec.emit("skip", "cover", f"No cover found: {artist} - {album}")
        return
    output = album_folder / "cover.jpg"
    rec.emit("dry" if opts.dry_run else "ok", "cover", f"Save cover.jpg for {album}")
    if not opts.dry_run:
        output.write_bytes(image)
        time.sleep(1)


def _fetch_lyrics(audio_file, album_artist, album_name, opts, rec) -> None:
    if _has_lyrics(audio_file):
        return
    t_artist, t_album, title, duration = get_track_metadata(audio_file)
    artist = t_artist or album_artist
    album = t_album or album_name
    if not (artist and album and title):
        return
    lyrics = find_track_lyrics(artist, album, title, duration)
    if not lyrics:
        rec.emit("skip", "lyrics", f"No lyrics: {artist} - {title}")
        return
    extension, text = lyrics
    output = audio_file.with_suffix(extension)
    rec.emit("dry" if opts.dry_run else "ok", "lyrics", f"Save lyrics for {title}")
    if not opts.dry_run:
        output.write_text(text, encoding="utf-8")
        time.sleep(1)


def _process_album(album_folder: Path, root: Path, opts: ProcessOptions, rec: _Recorder) -> None:
    tag_artist, tag_album, tag_year = get_album_metadata_from_files(album_folder)
    artist = tag_artist or clean_name(album_folder.parent.name)
    album = tag_album or clean_album_folder_name(album_folder.name)
    year = tag_year or get_year_from_album_folder_name(album_folder.name)

    if opts.folder_cleanup and not year and artist and album:
        year = find_album_year(artist, album)
    if opts.folder_cleanup:
        album_folder = _rename_album_folder(album_folder, artist, album, year, root, opts, rec)

    audio_files = [
        f for f in album_folder.iterdir()
        if f.is_file() and f.suffix.lower() in AUDIO_EXTENSIONS
    ]
    if opts.rename:
        audio_files = [_rename_track(f, opts, rec) for f in audio_files]

    if opts.lyrics:
        for file in audio_files:
            _fetch_lyrics(file, artist, album, opts, rec)

    if opts.covers:
        _fetch_cover(album_folder, artist, album, opts, rec)

    if opts.file_cleanup:
        _clean_files(album_folder, opts, rec)


def process_library(
    options: ProcessOptions,
    *,
    root: Path | None = None,
    log: LogCallback | None = None,
    album_paths: list[str] | None = None,
) -> dict:
    """Run the selected maintenance modes. Honours ``options.dry_run``.

    ``album_paths`` optionally limits the run to specific album folders.
    """
    root = root or MUSIC_ROOT
    rec = _Recorder(sink=log)

    if not root.exists():
        rec.emit("warn", "root", f"Music root does not exist: {root}")
        return {"root": str(root), "dry_run": options.dry_run, "actions": rec.actions, "counts": rec.counts}

    if album_paths:
        wanted = {str(Path(p)) for p in album_paths}
        folders = [Path(p) for p in album_paths] if all(Path(p).exists() for p in album_paths) else [
            f for f in _iter_album_folders(root) if str(f) in wanted
        ]
    else:
        folders = list(_iter_album_folders(root))

    if options.recent_only and not album_paths:
        before = len(folders)
        folders = [f for f in folders if _was_created_recently(f, options.recent_window_seconds)]
        rec.emit(
            "info",
            "recent",
            f"Recent-only: {len(folders)} of {before} albums modified in the last "
            f"{options.recent_window_seconds // 3600}h",
        )

    rec.emit(
        "info",
        "start",
        f"{'Dry run' if options.dry_run else 'Applying'} across {len(folders)} album(s)",
    )
    for album_folder in folders:
        try:
            _process_album(album_folder, root, options, rec)
        except OSError as exc:
            rec.emit("warn", "album", f"Error processing {album_folder.name}: {exc}")

    rec.emit("info", "done", "Finished")
    return {
        "root": str(root),
        "dry_run": options.dry_run,
        "actions": rec.actions,
        "counts": rec.counts,
    }


# ── streaming variants (disk-efficient; yield as work happens) ────────────────


def scan_library_stream(root: Path | None = None) -> Iterator[dict]:
    """Yield one album analysis at a time so the UI can render incrementally.

    Memory stays flat (no full list is accumulated) and the slow NAS walk
    streams results to the caller as each album folder is inspected.
    """
    root = root or MUSIC_ROOT
    if not root.exists():
        yield {"type": "error", "message": f"Music root not found: {root}"}
        return

    yield {"type": "start", "root": str(root)}
    album_count = missing_cover = needs_rename = extra_files = 0
    for album_folder in _iter_album_folders(root):
        try:
            album = analyze_album(album_folder)
        except OSError:
            continue
        album_count += 1
        if not album["has_cover"]:
            missing_cover += 1
        if album["needs_folder_rename"]:
            needs_rename += 1
        extra_files += album["extra_file_count"]
        yield {"type": "album", "album": album, "scanned": album_count}

    yield {
        "type": "summary",
        "root": str(root),
        "album_count": album_count,
        "missing_cover_count": missing_cover,
        "needs_rename_count": needs_rename,
        "extra_file_count": extra_files,
    }


def process_library_stream(
    options: ProcessOptions,
    *,
    root: Path | None = None,
    album_paths: list[str] | None = None,
) -> Iterator[dict]:
    """Run maintenance and yield each action the moment it happens.

    ``process_library`` already reports through a ``log`` callback; we bridge that
    to a queue drained by this generator so the HTTP response streams live.
    """
    events: queue.Queue = queue.Queue()
    sentinel = object()

    def worker():
        try:
            process_library(options, root=root, log=events.put, album_paths=album_paths)
        except Exception as exc:  # pragma: no cover - defensive
            events.put({"level": "warn", "action": "error", "message": str(exc)})
        finally:
            events.put(sentinel)

    threading.Thread(target=worker, name="music_process_stream", daemon=True).start()
    while True:
        item = events.get()
        if item is sentinel:
            break
        yield item
