from pathlib import Path
import itertools
import shutil
import sys
import re
import threading
import time
import requests
import musicbrainzngs
from mutagen import File, MutagenError

MUSIC_ROOT = Path(r"\\Matt-htpc\d\Music")

DRY_RUN = False   # keep True first. Set False only after checking output.
ENABLE_LYRICS = False
ENABLE_FOLDER_CLEANUP = True
ENABLE_RENAME = True
ENABLE_FILE_CLEANUP = True
RECENT_FOLDERS_ONLY = False
RECENT_FOLDER_WINDOW_SECONDS = 2 * 60 * 60

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*$")

musicbrainzngs.set_useragent(
    "NavidromeCoverDownloader",
    "1.0",
    "your-email@example.com"
)


class UI:
    RESET = "\033[0m"
    BOLD = "\033[1m"
    DIM = "\033[2m"
    RED = "\033[31m"
    GREEN = "\033[32m"
    YELLOW = "\033[33m"
    BLUE = "\033[34m"
    MAGENTA = "\033[35m"
    CYAN = "\033[36m"
    WHITE = "\033[37m"
    ORANGE = "\033[38;5;208m"
    MUTED = "\033[38;5;244m"
    BG = "\033[48;5;236m"

    enabled = sys.stdout.isatty()


def enable_terminal_colors():
    if not UI.enabled:
        return

    if sys.platform != "win32":
        return

    try:
        import ctypes

        kernel32 = ctypes.windll.kernel32
        handle = kernel32.GetStdHandle(-11)
        mode = ctypes.c_uint()
        if kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
            kernel32.SetConsoleMode(handle, mode.value | 0x0004)
    except Exception:
        UI.enabled = False


def style(text: str, *codes: str) -> str:
    if not UI.enabled:
        return text
    return "".join(codes) + text + UI.RESET


def line(char: str = "-") -> str:
    width = shutil.get_terminal_size((88, 20)).columns
    return style(char * min(width, 88), UI.MUTED)


def print_banner():
    print()
    print(style("Music Covers", UI.BOLD, UI.ORANGE))
    print(style("Clean albums, rename tracks, fetch lyrics, and fill missing covers.", UI.DIM))
    print(line())


def info(message: str):
    print(f"{style('>', UI.CYAN)} {message}")


def success(message: str):
    print(f"{style('OK', UI.GREEN, UI.BOLD)} {message}")


def warn(message: str):
    print(f"{style('WARN', UI.YELLOW, UI.BOLD)} {message}")


def skip(message: str):
    print(f"{style('SKIP', UI.MUTED, UI.BOLD)} {message}")


def action(label: str, message: str):
    print(f"{style(label, UI.ORANGE, UI.BOLD)} {message}")


class Spinner:
    def __init__(self, message: str):
        self.message = message
        self.done = threading.Event()
        self.thread = threading.Thread(target=self._spin, daemon=True)

    def __enter__(self):
        if UI.enabled:
            self.thread.start()
        else:
            info(self.message)
        return self

    def __exit__(self, exc_type, exc, tb):
        if not UI.enabled:
            return

        self.done.set()
        self.thread.join()
        sys.stdout.write("\r" + " " * shutil.get_terminal_size((88, 20)).columns + "\r")
        sys.stdout.flush()

    def _spin(self):
        for frame in itertools.cycle("-\\|/"):
            if self.done.is_set():
                break
            sys.stdout.write(f"\r{style(frame, UI.ORANGE)} {style(self.message, UI.DIM)}")
            sys.stdout.flush()
            time.sleep(0.08)


def get_key() -> str:
    if sys.platform == "win32":
        import msvcrt

        key = msvcrt.getch()
        if key in (b"\x00", b"\xe0"):
            key = msvcrt.getch()
        return key.decode(errors="ignore")

    import termios
    import tty

    fd = sys.stdin.fileno()
    old = termios.tcgetattr(fd)
    try:
        tty.setraw(fd)
        key = sys.stdin.read(1)
        if key == "\x1b":
            key += sys.stdin.read(2)
        return key
    finally:
        termios.tcsetattr(fd, termios.TCSADRAIN, old)


def choose_modes():
    global ENABLE_LYRICS, ENABLE_FOLDER_CLEANUP, ENABLE_RENAME, ENABLE_FILE_CLEANUP, RECENT_FOLDERS_ONLY

    options = [
        {
            "label": "Lyric mode",
            "description": "Fetch missing .lrc or .txt sidecar lyrics",
            "enabled": ENABLE_LYRICS,
        },
        {
            "label": "Folder cleanup mode",
            "description": "Normalize album folder names to 'YEAR - Album'",
            "enabled": ENABLE_FOLDER_CLEANUP,
        },
        {
            "label": "Rename mode",
            "description": "Rename audio files to 'NN - Title.mp3'",
            "enabled": ENABLE_RENAME,
        },
        {
            "label": "File cleanup mode",
            "description": "Remove files except audio, cover art, and lyric sidecars",
            "enabled": ENABLE_FILE_CLEANUP,
        },
        {
            "label": "Recent folders only",
            "description": "Process album folders created in the last 2 hours",
            "enabled": RECENT_FOLDERS_ONLY,
        },
    ]

    if not sys.stdin.isatty():
        return

    if not UI.enabled:
        print("Select modes. Press Enter to keep defaults, or type numbers to toggle, e.g. 1 3.")
        for index, option in enumerate(options, 1):
            marker = "x" if option["enabled"] else " "
            print(f"[{marker}] {index}. {option['label']} - {option['description']}")
        answer = input("> ").strip()
        for token in answer.replace(",", " ").split():
            if token.isdigit() and 1 <= int(token) <= len(options):
                options[int(token) - 1]["enabled"] = not options[int(token) - 1]["enabled"]
    else:
        selected = 0
        instructions = "Space toggles  Up/Down moves  Enter starts"

        while True:
            sys.stdout.write("\033[?25l")
            sys.stdout.write("\033[H\033[J")
            print_banner()
            print(style("Startup Modes", UI.BOLD, UI.WHITE))
            print(style(instructions, UI.DIM))
            print()

            for index, option in enumerate(options):
                pointer = style(">", UI.ORANGE, UI.BOLD) if index == selected else " "
                checkbox = style("[x]", UI.GREEN, UI.BOLD) if option["enabled"] else style("[ ]", UI.MUTED)
                label_color = UI.WHITE if index == selected else UI.RESET
                print(f"{pointer} {checkbox} {style(option['label'], UI.BOLD, label_color)}")
                print(f"    {style(option['description'], UI.DIM)}")

            key = get_key()
            if key in ("\r", "\n"):
                break
            if key in (" ",):
                options[selected]["enabled"] = not options[selected]["enabled"]
            elif key in ("H", "\x1b[A"):
                selected = (selected - 1) % len(options)
            elif key in ("P", "\x1b[B"):
                selected = (selected + 1) % len(options)

        sys.stdout.write("\033[?25h")
        sys.stdout.write("\033[H\033[J")

    ENABLE_LYRICS = options[0]["enabled"]
    ENABLE_FOLDER_CLEANUP = options[1]["enabled"]
    ENABLE_RENAME = options[2]["enabled"]
    ENABLE_FILE_CLEANUP = options[3]["enabled"]
    RECENT_FOLDERS_ONLY = options[4]["enabled"]

    print_banner()
    enabled_modes = ", ".join(option["label"] for option in options if option["enabled"]) or "none"
    info(f"Enabled modes: {enabled_modes}")
    print(line())


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)
    if match:
        return match.group(1)
    return 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()

    # handles "1/12", "01/12", "1"
    text = text.split("/")[0].strip()

    if not text.isdigit():
        return None

    return text.zfill(2)


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


def load_audio_metadata(path: Path):
    try:
        audio = File(path, easy=True)
    except (MutagenError, OSError) as e:
        warn(f"could not read metadata ({type(e).__name__}): {path}")
        return None

    if audio is None:
        skip(f"unsupported audio metadata: {path}")

    return audio


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))
    if match:
        return match.group(1)

    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_metadata(file)
        if audio is None:
            continue

        artist = get_first_tag(audio, ["albumartist", "artist"])
        album = get_first_tag(audio, ["album"])
        year = extract_year(get_first_tag(audio, ["date", "originaldate", "year"]))

        if artist or album or year:
            return artist, album, year

    return None, None, None


def find_album_year(artist: str, album: str) -> str | 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 as e:
        warn(f"Error finding album year: {e}")

    return None


def is_top_level_music_folder(folder: Path) -> bool:
    try:
        return folder.resolve().parent == MUSIC_ROOT.resolve()
    except OSError:
        return folder.parent == MUSIC_ROOT


def ensure_album_folder_name(album_folder: Path, artist: str, album: str, year: str | None) -> Path:
    if not album or not year:
        return album_folder

    if is_top_level_music_folder(album_folder):
        skip(f"refusing to rename top-level folder as album: {album_folder}")
        return album_folder

    new_name = f"{year} - {safe_filename(album)}"
    new_folder = album_folder.with_name(new_name)

    if album_folder.name == new_name:
        return album_folder

    if new_folder.exists():
        skip(f"folder rename target exists: {new_folder}")
        return album_folder

    action("FOLDER", f"{artist}")
    print(f"  {style('From', UI.DIM)} {album_folder.name}")
    print(f"  {style('To', UI.DIM)}   {new_name}")

    if DRY_RUN:
        return album_folder

    album_folder.rename(new_folder)
    return new_folder


def rename_audio_file(path: Path):
    if path.suffix.lower() not in AUDIO_EXTENSIONS:
        return path

    audio = load_audio_metadata(path)
    if audio is None:
        return path

    artist = get_first_tag(audio, ["artist", "albumartist"])
    album = get_first_tag(audio, ["album"])
    title = get_first_tag(audio, ["title"])
    track = clean_track_number(audio.get("tracknumber"))

    if not artist or not album or not title or not track:
        skip(f"missing metadata: {path}")
        return path

    # only rename files already inside Artist\Album structure
    album_folder = path.parent
    artist_folder = album_folder.parent

    if not artist_folder.exists() or not album_folder.exists():
        return path

    new_name = f"{track} - {safe_filename(title)}{path.suffix.lower()}"
    new_path = path.with_name(new_name)

    if path.name == new_name:
        return path

    if new_path.exists():
        skip(f"target exists: {new_path}")
        return path

    action("RENAME", path.name)
    print(f"  {style('To', UI.DIM)} {new_name}")

    if not DRY_RUN:
        path.rename(new_path)
        return new_path

    return path


def get_album_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 should_keep_album_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 clean_album_files(album_folder: Path):
    cover_to_keep = get_album_cover_to_keep(album_folder)

    for file in album_folder.iterdir():
        if not file.is_file() or should_keep_album_file(file, cover_to_keep):
            continue

        action("REMOVE", str(file))

        if DRY_RUN:
            warn(f"DRY RUN: would remove {file}")
            continue

        try:
            file.unlink()
            success(f"Removed {file}")
        except OSError as e:
            warn(f"could not remove {file}: {e}")


def get_track_metadata(path: Path):
    audio = load_audio_metadata(path)
    if audio is None:
        return None, None, None, None

    artist = get_first_tag(audio, ["artist", "albumartist"])
    album = get_first_tag(audio, ["album"])
    title = get_first_tag(audio, ["title"])
    duration = None

    info = getattr(audio, "info", None)
    if info and info.length:
        duration = round(info.length)

    return artist, album, title, duration


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 cover_lookup_previously_failed(album_folder: Path, artist: str, album: str) -> bool:
    marker = album_folder / COVER_MISSING_MARKER
    if not marker.exists():
        return False

    try:
        return marker.read_text(encoding="utf-8").strip() == f"{artist}\n{album}"
    except OSError:
        return False


def mark_cover_lookup_failed(album_folder: Path, artist: str, album: str):
    if DRY_RUN:
        return

    marker = album_folder / COVER_MISSING_MARKER
    marker.write_text(f"{artist}\n{album}", encoding="utf-8")


def lyrics_lookup_key(artist: str, album: str, title: str, duration: int | None) -> str:
    duration_text = str(duration) if duration else ""
    return f"{artist}\t{album}\t{title}\t{duration_text}"


def get_failed_lyrics_lookups(album_folder: Path) -> set[str]:
    marker = album_folder / LYRICS_MISSING_MARKER
    if not marker.exists():
        return set()

    try:
        return {
            line.strip()
            for line in marker.read_text(encoding="utf-8").splitlines()
            if line.strip()
        }
    except OSError:
        return set()


def mark_lyrics_lookup_failed(album_folder: Path, key: str):
    if DRY_RUN:
        return

    marker = album_folder / LYRICS_MISSING_MARKER
    failed = get_failed_lyrics_lookups(album_folder)
    failed.add(key)
    marker.write_text("\n".join(sorted(failed)) + "\n", encoding="utf-8")


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


def find_album_cover(artist: str, album: str):
    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 as e:
        warn(f"Error finding cover: {e}")

    return None


def find_track_lyrics(artist: str, album: str, title: str, duration: int | None):
    if not duration:
        skip("lyrics lookup missing 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": "NavidromeCoverDownloader/1.0 (local music library script)"
            },
            timeout=20,
        )

        if response.status_code == 404:
            return None

        if response.status_code != 200:
            warn(f"Lyrics lookup failed: HTTP {response.status_code}")
            return None

        data = response.json()
        synced_lyrics = data.get("syncedLyrics")
        plain_lyrics = data.get("plainLyrics")

        if synced_lyrics:
            return ".lrc", synced_lyrics.strip() + "\n"

        if plain_lyrics:
            return ".txt", plain_lyrics.strip() + "\n"

    except Exception as e:
        warn(f"Error finding lyrics: {e}")

    return None


def download_lyrics_for_track(audio_file: Path, album_artist: str, album_name: str):
    if has_lyrics(audio_file):
        return

    track_artist, track_album, title, duration = get_track_metadata(audio_file)
    artist = track_artist or album_artist
    album = track_album or album_name

    if not artist or not album or not title:
        skip(f"lyrics missing metadata: {audio_file}")
        return

    key = lyrics_lookup_key(artist, album, title, duration)
    failed = get_failed_lyrics_lookups(audio_file.parent)

    if key in failed:
        skip(f"lyrics lookup already failed: {artist} - {title}")
        return

    action("LYRICS", f"{artist} - {title}")
    with Spinner("Searching LRCLIB"):
        lyrics = find_track_lyrics(artist, album, title, duration)

    if not lyrics:
        skip("no lyrics found")
        mark_lyrics_lookup_failed(audio_file.parent, key)
        return

    extension, text = lyrics
    output_file = audio_file.with_suffix(extension)

    if DRY_RUN:
        warn(f"DRY RUN: would save {output_file}")
    else:
        output_file.write_text(text, encoding="utf-8")
        success(f"Saved {output_file}")

    time.sleep(1)


def process_album_folder(album_folder: Path):
    print()
    action("ALBUM", str(album_folder))

    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 ENABLE_FOLDER_CLEANUP and not year and artist and album:
        with Spinner("Finding album year"):
            year = find_album_year(artist, album)

    if ENABLE_FOLDER_CLEANUP:
        album_folder = ensure_album_folder_name(album_folder, artist, album, year)

    audio_files = []

    for file in album_folder.iterdir():
        if file.is_file() and file.suffix.lower() in AUDIO_EXTENSIONS:
            if ENABLE_RENAME:
                audio_files.append(rename_audio_file(file))
            else:
                audio_files.append(file)

    if ENABLE_LYRICS:
        for file in audio_files:
            download_lyrics_for_track(file, artist, album)

    if has_cover(album_folder):
        pass
    elif cover_lookup_previously_failed(album_folder, artist, album):
        skip(f"cover lookup already failed: {artist} - {album}")
    else:
        action("COVER", f"{artist} - {album}")

        with Spinner("Searching Cover Art Archive"):
            image_data = find_album_cover(artist, album)

        if not image_data:
            skip("no cover found")
            mark_cover_lookup_failed(album_folder, artist, album)
        else:
            output_file = album_folder / "cover.jpg"

            if DRY_RUN:
                warn(f"DRY RUN: would save {output_file}")
            else:
                output_file.write_bytes(image_data)
                success(f"Saved {output_file}")

            time.sleep(1)

    if ENABLE_FILE_CLEANUP:
        clean_album_files(album_folder)


def was_created_within_recent_window(folder: Path) -> bool:
    try:
        created_at = folder.stat().st_ctime
    except OSError as e:
        warn(f"could not read folder timestamps: {folder} ({e})")
        return False

    age_seconds = time.time() - created_at
    return 0 <= age_seconds <= RECENT_FOLDER_WINDOW_SECONDS


def main():
    enable_terminal_colors()
    choose_modes()
    info(f"MUSIC_ROOT: {MUSIC_ROOT}")
    info(f"Exists: {MUSIC_ROOT.exists()}")
    info(f"Is folder: {MUSIC_ROOT.is_dir()}")

    if not MUSIC_ROOT.exists():
        warn("Music root does not exist.")
        return

    if any(p.is_file() and p.suffix.lower() in AUDIO_EXTENSIONS for p in MUSIC_ROOT.iterdir()):
        warn("Music root contains audio files directly; skipping root-level album processing.")

    for first_level_folder in MUSIC_ROOT.iterdir():
        if not first_level_folder.is_dir():
            continue

        if any(p.is_file() and p.suffix.lower() in AUDIO_EXTENSIONS for p in first_level_folder.iterdir()):
            warn(f"Skipping top-level folder with audio files: {first_level_folder}")
            continue

        # Process only Artist\Album folders. Album folders should never be created
        # or renamed directly under MUSIC_ROOT.
        for album_folder in first_level_folder.iterdir():
            if not album_folder.is_dir():
                continue

            if RECENT_FOLDERS_ONLY and not was_created_within_recent_window(album_folder):
                skip(f"outside 2-hour creation window: {album_folder}")
                continue

            process_album_folder(album_folder)


if __name__ == "__main__":
    main()
