#!/usr/bin/env python3
# =============================================================================
# GenreFix Standardizer + Duplicate Finder
# Generated by GenreFix · genrefix.net
# =============================================================================
# PHASE 1 — Populate empty tags from folder structure
#   If a track has no artist/album/title tags, fills them from folder names
#   and filename, then writes them back immediately.
#
# PHASE 2 — Normalize all tags and write back
#   Artist names:  "Beatles, The" / "Beatles" / "The Beatles"  →  one format
#   Disc numbers:  "Disc 1", "[Disc 1]", "(Disc 1 of 3)", "CD1"  →  "(Disc 1)"
#   Casing:        "ABBEY ROAD" / "abbey road"  →  "Abbey Road"
#
# PHASE 3 — Rebuild folder structure
#   Rebuilds as: MUSIC_FOLDER \ Artist Name \ Album Name \ tracks
#   No album tag:  MUSIC_FOLDER \ Artist Name \ track  (flat under artist)
#   No artist tag: MUSIC_FOLDER \ aaaUnsorted \ track
#   Cleans up empty folders after restructure.
#
# NOTE: Duplicate detection and removal is a paid feature available at genrefix.net.
#
# REQUIREMENTS:
#   Python 3.7+  |  pip install mutagen
#
# USAGE:
#   Windows:   python genrefix_standardizer.py
#   Mac/Linux: python3 genrefix_standardizer.py
# =============================================================================

import os
import re
import sys
import json
import shutil
from pathlib import Path
from datetime import datetime

# ── CONFIGURATION ─────────────────────────────────────────────────────────────

# Leave MUSIC_FOLDER blank ("") to be prompted at startup with a folder picker.
# Or hard-code a path to skip the prompt every time:
#   Windows example: r"C:\Users\Andre\Music"
#   Mac example:     "/Users/Andre/Music"
MUSIC_FOLDER = ""

# Artist article format — choose ONE:
#   "prefix"  →  "The Beatles"  (article at the front)
#   "suffix"  →  "Beatles, The" (article at the end with comma)
ARTICLE_FORMAT = ""  # Leave blank to be prompted at startup ("prefix" or "suffix")

# Preferred file format — this copy is kept when duplicates are found.
# If no copy matches, falls back to highest bitrate then largest file size.
# Common values: "mp3", "flac", "alac", "m4a", "aiff", "wav", "ogg", "wma"
PREFERRED_FORMAT = "mp3"

# Artists whose exact casing must be preserved.
# Key = lowercase version, Value = the exact form you want written.
CASING_EXCEPTIONS = {
    "inxs":       "INXS",
    "nsync":      "NSYNC",
    "acdc":       "AC/DC",
    "ac/dc":      "AC/DC",
    "kd lang":    "k.d. lang",
    "k.d. lang":  "k.d. lang",
    "will.i.am":  "will.i.am",
    "deadmau5":   "deadmau5",
    "run dmc":    "Run-DMC",
    "run-dmc":    "Run-DMC",
    "ub40":       "UB40",
    # Add more below — key is lowercase, value is the exact form you want
}

# ── END CONFIGURATION ─────────────────────────────────────────────────────────

AUDIO_EXTS = {
    '.mp3', '.flac', '.wav', '.aif', '.aiff',
    '.m4a', '.alac', '.ogg', '.wma', '.opus', '.ape'
}

ARTWORK_EXTS = {
    '.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp'
}

SKIP_FOLDERS = {"!GenreFix Duplicates", "aaaUnsorted"}

# Width of the progress line — pad to this to erase leftover characters from longer lines.
_PROGRESS_WIDTH = 110


def _print_progress(current: int, total: int, label: str = "Processing", detail: str = "") -> None:
    """Print an in-place progress line using \\r so it overwrites itself."""
    if total > 0:
        pct = current / total * 100
        base = f"  {label}  [{current:,} / {total:,}]  ({pct:.1f}%)"
    else:
        base = f"  {label}  [{current:,}]"
    if detail:
        # Truncate detail so the whole line stays under _PROGRESS_WIDTH
        max_detail = _PROGRESS_WIDTH - len(base) - 4
        if len(detail) > max_detail > 0:
            detail = "…" + detail[-(max_detail - 1):]
        base = f"{base}  {detail}"
    sys.stdout.write("\r" + base.ljust(_PROGRESS_WIDTH))
    sys.stdout.flush()


def _end_progress() -> None:
    """Move to the next line after an in-place progress sequence."""
    sys.stdout.write("\n")
    sys.stdout.flush()


# ═══════════════════════════════════════════════════════════════════════════════
# UTILITY FUNCTIONS
# ═══════════════════════════════════════════════════════════════════════════════

def title_case(s: str) -> str:
    """Capitalize every word; preserve all-caps words (MDNA, BBC) and mixed-case (McCartney)."""
    if not s or not s.strip():
        return s
    words = s.split()
    result = []
    for word in words:
        # Strip punctuation for the all-caps check (e.g. "BBC," → "BBC")
        core = re.sub(r'[^a-zA-Z0-9]', '', word)
        if core and core.isupper() and len(core) > 1:
            result.append(word)   # all-caps word — leave alone (MDNA, BBC, USA, etc.)
            continue
        rest = word[1:]
        has_upper = any(c.isupper() for c in rest)
        has_lower = any(c.islower() for c in rest)
        if has_upper and has_lower:
            result.append(word)   # mixed case — leave alone (McCartney)
        else:
            result.append(word.capitalize())
    return " ".join(result)


def apply_casing(name: str) -> str:
    """Apply title_case but check CASING_EXCEPTIONS first."""
    if not name or not name.strip():
        return name
    key = re.sub(r'[^a-z0-9]', '', name.lower())
    if key in CASING_EXCEPTIONS:
        return CASING_EXCEPTIONS[key]
    if name.strip().lower() in CASING_EXCEPTIONS:
        return CASING_EXCEPTIONS[name.strip().lower()]
    return title_case(name)


def normalize_title_article(name: str) -> str:
    """Always use prefix form for titles/albums: 'End, The' → 'The End'."""
    if not name or not name.strip():
        return name
    m = re.match(r'^(.+),\s*(the|a|an)\s*$', name.strip(), re.IGNORECASE)
    if m:
        base    = m.group(1).strip()
        article = m.group(2).strip().capitalize()
        return f"{article} {base}"
    return name


def normalize_artist(name: str, bare_to_canonical: dict = None) -> str:
    """Standardize article placement per ARTICLE_FORMAT."""
    if not name or not name.strip():
        return name
    s = name.strip()
    if bare_to_canonical and s in bare_to_canonical:
        return bare_to_canonical[s]
    suffix_match  = re.match(r'^(.+),\s*(the|a|an)\s*$', s, re.IGNORECASE)
    prefix_match  = re.match(r'^(the|an)\s+(.+)$',       s, re.IGNORECASE)
    a_prefix_match = re.match(r'^a\s+(\S+\s+\S.*)',      s, re.IGNORECASE)
    if suffix_match:
        base    = suffix_match.group(1).strip()
        article = suffix_match.group(2).strip()
    elif prefix_match:
        article = prefix_match.group(1).strip()
        base    = prefix_match.group(2).strip()
    elif a_prefix_match:
        article = "A"
        base    = a_prefix_match.group(1).strip()
    else:
        return name
    article_cap = article.capitalize()
    return f"{article_cap} {base}" if ARTICLE_FORMAT == "prefix" else f"{base}, {article_cap}"


def build_bare_to_canonical(all_artists: set) -> dict:
    """Map bare artist names ('Beatles') to their canonical article form."""
    mapping = {}
    for name in all_artists:
        s = name.strip()
        m_prefix = re.match(r'^(the|an)\s+(.+)$',      s, re.IGNORECASE)
        m_a      = re.match(r'^a\s+(\S+\s+\S.*)',      s, re.IGNORECASE)
        m_suffix = re.match(r'^(.+),\s*(the|a|an)\s*$', s, re.IGNORECASE)
        if m_prefix:
            article, base = m_prefix.group(1), m_prefix.group(2).strip()
        elif m_a:
            article, base = "A", m_a.group(1).strip()
        elif m_suffix:
            base, article = m_suffix.group(1).strip(), m_suffix.group(2).strip()
        else:
            continue
        article_cap = article.capitalize()
        canonical = f"{article_cap} {base}" if ARTICLE_FORMAT == "prefix" else f"{base}, {article_cap}"
        if base in all_artists:
            mapping[base] = canonical
    return mapping


def normalize_album(name: str) -> str:
    """
    Standardize album names:
      - Strip edition qualifiers: [Deluxe Edition], (Special Edition), etc.
      - Normalize disc indicators to '(Disc N)'
      - Normalize volume indicators to 'Vol. N'
    """
    if not name or not name.strip():
        return name
    s = name.strip()

    # Normalize qualifier formatting — convert brackets to parentheses and
    # standardize spacing, but keep the qualifier content intact.
    # "[Deluxe Edition]" → "(Deluxe Edition)"
    # "- Deluxe Edition" → "(Deluxe Edition)"
    # "Deluxe Edition" (bare, no brackets) → "(Deluxe Edition)"
    QUALIFIERS = (
        r'super deluxe|deluxe|special|expanded|anniversary|'
        r'remaster(ed)?|bonus|collector\'?s?\s*edition|limited edition|'
        r'edition|version'
    )
    # Already in square brackets → convert to parens
    s = re.sub(
        r'\s*\[\s*((?:' + QUALIFIERS + r')[^\]]*)\]',
        lambda m: f" ({m.group(1).strip()})",
        s, flags=re.IGNORECASE
    )
    # After a hyphen/dash with no brackets → wrap in parens
    s = re.sub(
        r'\s*[-–]\s*((?:' + QUALIFIERS + r')[^(\[]*?)(?=\s*[\(\[]|$)',
        lambda m: f" ({m.group(1).strip()})",
        s, flags=re.IGNORECASE
    )

    s = s.strip()

    # Normalize volume: "Vol 2", "Volume 2", "Vol. 2" → "Vol. 2"
    s = re.sub(
        r'\bvol(ume|\.?)?\s*(\d+)\b',
        lambda m: f"Vol. {m.group(2)}",
        s, flags=re.IGNORECASE
    )

    # Normalize disc: any variant at end of name → "(Disc N)"
    pattern = r'\s*[\[\(]?\s*(disc|disk|cd)\s*(\d+)(\s*of\s*\d+)?\s*[\]\)]?\s*$'
    match = re.search(pattern, s, re.IGNORECASE)
    if match:
        disc_num = match.group(2)
        s = s[:match.start()].strip()
        s = f"{s} (Disc {disc_num})"

    return s


def primary_artist(artist: str) -> str:
    """Strip featured artist credits, returning only the primary performing entity.
    Handles: 'feat.', 'ft.', 'featuring', 'w/' — but NOT '&' (could be a group name).
    """
    if not artist:
        return artist
    s = re.sub(
        r'\s+(feat\.?|ft\.?|featuring|w/)\s+.*$',
        '', artist, flags=re.IGNORECASE
    ).strip()
    return s if s else artist


def normalize_for_match(s: str) -> str:
    """Aggressively normalize a string for duplicate comparison."""
    if not s:
        return ""
    s = s.lower().strip()
    # Strip articles
    s = re.sub(r',\s*(the|a|an)\s*$', '', s)
    s = re.sub(r'^(the|an)\s+', '', s)
    s = re.sub(r'^a\s+(?=\S+\s)', '', s)
    # Normalize disc indicators
    s = re.sub(
        r'[\[\(]?\s*(disc|disk|cd)\s*(\d+)(\s*of\s*\d+)?\s*[\]\)]?',
        lambda m: f' disc{m.group(2)}', s
    )
    # Remove track number prefix
    s = re.sub(r'^\d{1,3}[.\-\s]+', '', s)
    # Normalize volume indicators before stripping
    s = re.sub(r'\bvol(ume|\.?)?\s*(\d+)\b', lambda m: f'vol{m.group(2)}', s, flags=re.IGNORECASE)
    # Strip common qualifier suffixes
    s = re.sub(
        r'[\s\-\(]*\b(remaster(ed)?|deluxe|edition|expanded|anniversary|'
        r'mono|stereo|live|remix|edit|version|single|bonus|tracks?|'
        r'explicit|clean|radio|naked)\b.*$',
        '', s, flags=re.IGNORECASE
    )
    # Strip trailing years
    s = re.sub(r'[\s\(\[]*\b(19|20)\d{2}\b[\)\]]*', '', s)
    # Strip remaining bracketed content
    s = re.sub(r'[\(\[][^\)\]]*[\)\]]', '', s)
    # Remove all non-alphanumeric
    s = re.sub(r'[^a-z0-9]', '', s)
    return s.strip()


def get_tags(path):
    """Return (artist, album_artist, album, title) from file tags."""
    try:
        from mutagen import File as MFile
        f = MFile(path, easy=True)
        if not f:
            return None, None, None, None
        artist       = (f.get("artist")      or [None])[0]
        album_artist = (f.get("albumartist") or [None])[0]
        album        = (f.get("album")       or [None])[0]
        title        = (f.get("title")       or [None])[0]
        return (
            str(artist).strip()       if artist       else None,
            str(album_artist).strip() if album_artist else None,
            str(album).strip()        if album        else None,
            str(title).strip()        if title        else None,
        )
    except Exception:
        return None, None, None, None


def write_tags(path, artist=None, album_artist=None, album=None, title=None):
    """Write tags back to the file."""
    from mutagen import File as MFile
    f = MFile(path, easy=True)
    if f is None:
        raise ValueError("Unsupported format or corrupt file")
    if artist       is not None: f["artist"]      = [artist]
    if album_artist is not None: f["albumartist"] = [album_artist]
    if album        is not None: f["album"]        = [album]
    if title        is not None: f["title"]        = [title]
    f.save()


def get_compilation_flag(path: str) -> bool:
    """Return True if the file's compilation tag is set."""
    try:
        from mutagen import File as MFile
        f = MFile(path, easy=True)
        if f is None:
            return False
        val = (f.get("compilation") or [None])[0]
        if val is None:
            return False
        return str(val).strip().lower() in ("1", "true", "yes")
    except Exception:
        return False


def write_compilation_flag(path: str, is_compilation: bool):
    """Set or clear the compilation flag. Uses format-aware mutagen easy interface."""
    try:
        from mutagen import File as MFile
        f = MFile(path, easy=True)
        if f is None:
            return
        if is_compilation:
            f["compilation"] = ["1"]
        else:
            try:
                del f["compilation"]
            except KeyError:
                pass
        f.save()
    except Exception:
        pass


def get_bitrate(path: str) -> int:
    try:
        from mutagen import File as MFile
        f = MFile(path)
        if f and hasattr(f, 'info') and hasattr(f.info, 'bitrate'):
            return f.info.bitrate // 1000
    except Exception:
        pass
    return 0


def get_duration(path: str) -> float:
    try:
        from mutagen import File as MFile
        f = MFile(path)
        if f and hasattr(f, 'info') and hasattr(f.info, 'length'):
            return f.info.length
    except Exception:
        pass
    return -1


def collect_files(folder: Path) -> list:
    """Return all audio file paths under folder, skipping reserved subfolders."""
    files = []
    for root, dirs, filenames in os.walk(folder):
        dirs[:] = [d for d in dirs if d not in SKIP_FOLDERS]
        for fname in filenames:
            if Path(fname).suffix.lower() in AUDIO_EXTS:
                files.append(os.path.join(root, fname))
    return files


def infer_from_path(fp: str, music_root: Path):
    """
    Infer artist, album, title from folder structure when tags are empty.

    Folder depth relative to music_root:
      3+ levels:  root / artist / album / track  → artist = parts[-3], album = parts[-2]
      2 levels:   root / album / track           → album = parts[-2]
      1 level:    root / track                   → title from filename only

    Also strips any embedded artist name from album folder names, e.g.
    'Beatles (White Album) [Disc 1], The' → '(White Album) [Disc 1]' → 'The Beatles (White Album) [Disc 1]'
    """
    rel = Path(fp).relative_to(music_root)
    parts = rel.parts

    inferred_artist = None
    inferred_album  = None
    inferred_title  = None

    if len(parts) >= 3:
        inferred_artist = parts[-3]
        inferred_album  = parts[-2]
    elif len(parts) == 2:
        inferred_album = parts[-2]

    # Title from filename stem, stripping leading track numbers
    stem = Path(fp).stem
    stem = re.sub(r'^\d{1,3}[.\-\s]+', '', stem).strip()
    inferred_title = stem if stem else None

    # Strip embedded artist name from album folder names
    if inferred_artist and inferred_album:
        # Get base of artist name (no article)
        artist_base = re.sub(r',\s*(the|a|an)\s*$', '', inferred_artist, flags=re.IGNORECASE).strip()
        artist_base = re.sub(r'^(the|a|an)\s+', '', artist_base, flags=re.IGNORECASE).strip()
        if artist_base:
            # Strip "Beatles " or "Beatles, The " from start of album name
            cleaned = re.sub(
                r'^' + re.escape(artist_base) + r'\s*,?\s*(the|a|an)?\s*',
                '', inferred_album, flags=re.IGNORECASE
            ).strip()
            if cleaned and len(cleaned) < len(inferred_album):
                inferred_album = cleaned

    return inferred_artist, inferred_album, inferred_title


def safe_folder_name(name: str) -> str:
    """Remove characters illegal in Windows/Mac folder names."""
    name = re.sub(r'[\\/:*?"<>|]', '', name)
    name = name.strip('. ')
    return name or "_"


SYSTEM_FILES = {"desktop.ini", "thumbs.db", ".ds_store", "folder.jpg", "folder.png"}

def folder_has_real_content(dp: Path) -> bool:
    """Return True if folder contains anything other than system/hidden files."""
    for item in dp.iterdir():
        if item.is_dir():
            return True  # has a subfolder
        if item.name.lower() not in SYSTEM_FILES and not item.name.startswith('.'):
            return True
    return False


def remove_empty_folders(root: Path):
    """Remove folders that contain only system files or nothing at all, bottom-up.
    Runs two passes so parent folders emptied by the first pass are caught."""
    for _ in range(2):
        for dirpath, dirnames, filenames in os.walk(root, topdown=False):
            dp = Path(dirpath)
            if dp == root:
                continue
            if dp.name in SKIP_FOLDERS or any(p in SKIP_FOLDERS for p in dp.parts):
                continue
            try:
                if not folder_has_real_content(dp):
                    # Remove any system files first so rmdir succeeds
                    for item in dp.iterdir():
                        try:
                            item.unlink()
                        except Exception:
                            pass
                    dp.rmdir()
            except Exception:
                pass


# ═══════════════════════════════════════════════════════════════════════════════
# DUPLICATE DETECTION
# ═══════════════════════════════════════════════════════════════════════════════

def find_duplicates(folder: Path) -> list:
    """
    Find duplicate tracks:
      1. Normalize artist, album, title tags for every track
      2. Sort by normalized title
      3. Group by identical normalized title
      4. Within each title group, sub-group by normalized artist + album
      5. Within each sub-group, cluster by duration (±4 seconds)
      6. Each cluster of 2+ = duplicate group
      7. Winner = preferred format → highest bitrate → largest file
    """
    from mutagen import File as MFile
    from itertools import groupby

    DURATION_TOLERANCE = 4.0

    tracks = []
    for root, dirs, files in os.walk(folder):
        dirs[:] = [d for d in dirs if d not in SKIP_FOLDERS]
        for fname in files:
            if Path(fname).suffix.lower() not in AUDIO_EXTS:
                continue
            fp = os.path.join(root, fname)
            try:
                f = MFile(fp, easy=True)
                if not f:
                    continue
                artist = str((f.get("artist") or [""])[0]).strip()
                album  = str((f.get("album")  or [""])[0]).strip()
                title  = str((f.get("title")  or [""])[0]).strip()

                # Fallbacks if still empty
                if not album:
                    album = Path(fp).parent.name
                if not title:
                    stem = Path(fp).stem
                    stem = re.sub(r'^\d{1,3}[.\-\s]+', '', stem).strip()
                    title = stem

                tracks.append({
                    "path":        fp,
                    "artist":      artist,
                    "album":       album,
                    "title":       title,
                    "bitrate":     get_bitrate(fp),
                    "duration":    get_duration(fp),
                    "size":        os.path.getsize(fp),
                    "title_norm":  normalize_for_match(title),
                    "artist_norm": normalize_for_match(artist),
                    "album_norm":  normalize_for_match(album),
                })
            except Exception:
                pass

    tracks.sort(key=lambda t: t["title_norm"])
    dup_sets = []

    for title_norm, title_group in groupby(tracks, key=lambda t: t["title_norm"]):
        if not title_norm:
            continue
        title_tracks = list(title_group)
        if len(title_tracks) < 2:
            continue

        def track_key(t):
            return (t["artist_norm"], t["album_norm"])

        title_tracks.sort(key=track_key)
        for (artist_norm, album_norm), group in groupby(title_tracks, key=track_key):
            group_tracks = list(group)
            if len(group_tracks) < 2:
                continue

            # Cluster by duration ±4 seconds
            group_tracks.sort(key=lambda t: t["duration"])
            clusters = []
            for t in group_tracks:
                placed = False
                for cluster in clusters:
                    rep_dur = cluster[0]["duration"]
                    if rep_dur < 0 or t["duration"] < 0:
                        cluster.append(t)
                        placed = True
                        break
                    if abs(t["duration"] - rep_dur) <= DURATION_TOLERANCE:
                        cluster.append(t)
                        placed = True
                        break
                if not placed:
                    clusters.append([t])

            for cluster in clusters:
                if len(cluster) < 2:
                    continue
                # Winner: preferred format → bitrate → file size
                def winner_score(t):
                    ext = Path(t["path"]).suffix.lower().lstrip(".")
                    return (1 if ext == PREFERRED_FORMAT.lower().lstrip(".") else 0,
                            t["bitrate"], t["size"])
                cluster.sort(key=winner_score, reverse=True)
                dup_sets.append({"winner": cluster[0], "losers": cluster[1:]})

    return dup_sets


def move_duplicates(dup_sets: list, dup_folder: Path, music_root: Path) -> tuple:
    """Move loser files into dup_folder, preserving relative path structure."""
    moved = 0
    errors = 0
    for s in dup_sets:
        for loser in s["losers"]:
            src = Path(loser["path"])
            try:
                try:
                    rel = src.relative_to(music_root)
                except ValueError:
                    rel = Path(src.name)
                dst = dup_folder / rel
                dst.parent.mkdir(parents=True, exist_ok=True)
                if dst.exists():
                    stem, suffix = dst.stem, dst.suffix
                    counter = 2
                    while dst.exists():
                        dst = dst.parent / f"{stem} ({counter}){suffix}"
                        counter += 1
                shutil.move(str(src), str(dst))
                moved += 1
            except Exception as e:
                print(f"  ERROR moving {src.name}: {e}")
                errors += 1
    return moved, errors


# ═══════════════════════════════════════════════════════════════════════════════
# MAIN
# ═══════════════════════════════════════════════════════════════════════════════

def main():
    global ARTICLE_FORMAT

    try:
        import mutagen  # noqa: F401
    except ImportError:
        print("ERROR: mutagen is not installed.")
        print("Install it with:  pip install mutagen")
        input("\nPress Enter to close...")
        return

    music_folder = MUSIC_FOLDER.strip()
    if not music_folder:
        # Try a GUI folder picker first, fall back to typed input
        try:
            import tkinter as tk
            from tkinter import filedialog
            root = tk.Tk()
            root.withdraw()
            root.lift()
            root.attributes('-topmost', True)
            music_folder = filedialog.askdirectory(title="Select your music folder")
            root.destroy()
        except Exception:
            music_folder = input("Enter the full path to your music folder: ").strip().strip('"')

    if not music_folder:
        print("No folder selected. Exiting.")
        input("\nPress Enter to close...")
        return

    folder = Path(music_folder)
    if not folder.exists():
        print(f"ERROR: Music folder not found: {folder}")
        input("\nPress Enter to close...")
        return

    # Ask article format preference
    article_format = ARTICLE_FORMAT.strip()
    if not article_format:
        try:
            import tkinter as tk
            from tkinter import simpledialog, messagebox
            root = tk.Tk()
            root.withdraw()
            root.lift()
            root.attributes('-topmost', True)
            choice = messagebox.askquestion(
                "Artist Name Format",
                "How would you like artist names formatted?\n\n"
                "YES  →  The Beatles  (article at the front)\n"
                "NO   →  Beatles, The  (article at the end)\n\n"
                "Click YES for 'The Beatles' style, NO for 'Beatles, The' style.",
                icon="question",
            )
            root.destroy()
            article_format = "prefix" if choice == "yes" else "suffix"
        except Exception:
            print("\nHow would you like artist names formatted?")
            print("  1 = The Beatles  (article at the front)")
            print("  2 = Beatles, The  (article at the end)")
            while True:
                ans = input("Enter 1 or 2: ").strip()
                if ans == "1":
                    article_format = "prefix"
                    break
                elif ans == "2":
                    article_format = "suffix"
                    break
                print("Please enter 1 or 2.")

    ARTICLE_FORMAT = article_format

    format_label = "The Beatles style (article first)" if article_format == "prefix" \
                   else "Beatles, The style (article last)"

    print(f"\nGenreFix Music Standardizer")
    print(f"{'='*60}")
    print(f"Music folder:      {folder}")
    print(f"Artist format:     {format_label}")
    print(f"Preferred format:  .{PREFERRED_FORMAT}")
    print(f"{'='*60}\n")

    print("Scanning files...")
    all_files = collect_files(folder)
    print(f"Found {len(all_files):,} audio files.\n")

    if not all_files:
        print("No audio files found.")
        input("\nPress Enter to close...")
        return

    # ══════════════════════════════════════════════════════════════════════════
    # PHASE 1 — Populate empty tags from folder structure
    # ══════════════════════════════════════════════════════════════════════════
    print(f"{'='*60}")
    print(f"PHASE 1 — Populate empty tags from folder structure")
    print(f"  Reads every file and fills in any missing artist, album,")
    print(f"  or title tags using the folder name and filename.")
    print(f"  This may take several minutes for large libraries.")
    print(f"  Please do not close this window.")
    print(f"{'='*60}")

    total_files = len(all_files)
    populated   = 0
    p1_errors   = []
    UPDATE_EVERY = 100

    _print_progress(0, total_files, "Processing files")

    for i, fp in enumerate(all_files, 1):
        # Show progress every UPDATE_EVERY files
        if i % UPDATE_EVERY == 0 or i == total_files:
            rel = str(Path(fp).relative_to(folder))
            _print_progress(i, total_files, "Processing files", rel)

        artist, album_artist, album, title = get_tags(fp)
        if artist and album and title:
            continue  # all tags present — nothing to infer

        inf_artist, inf_album, inf_title = infer_from_path(fp, folder)
        updates = {}
        if not artist       and inf_artist: updates["artist"]       = inf_artist
        if not album_artist and inf_artist: updates["album_artist"] = inf_artist
        if not album        and inf_album:  updates["album"]        = inf_album
        if not title        and inf_title:  updates["title"]        = inf_title

        if updates:
            try:
                write_tags(fp, **updates)
                populated += 1
            except Exception as e:
                p1_errors.append(f"{Path(fp).name}: {e}")

    _end_progress()

    if p1_errors:
        for msg in p1_errors:
            print(f"  ERROR populating {msg}")

    print(f"\n{'='*60}")
    print(f"  Phase 1 complete — {total_files:,} files processed, {populated:,} tags updated")
    print(f"{'='*60}\n")

    # ══════════════════════════════════════════════════════════════════════════
    # PHASE 2 — Normalize all tags and write back
    # ══════════════════════════════════════════════════════════════════════════
    print(f"{'='*60}")
    print(f"PHASE 2 — Normalize tags")
    print(f"  Standardizes artist name format, album names, disc numbers,")
    print(f"  title casing, and article placement (The/A/An).")
    print(f"  Please do not close this window.")
    print(f"{'='*60}")

    # Sub-pass A: Collect all artist names for bare-name detection
    from mutagen import File as MFile
    print(f"\n  Reading artist names...")
    all_artists: set = set()
    _print_progress(0, total_files, "Reading")
    for i, fp in enumerate(all_files, 1):
        if i % UPDATE_EVERY == 0 or i == total_files:
            _print_progress(i, total_files, "Reading")
        try:
            f = MFile(fp, easy=True)
            if f:
                for tag in ("artist", "albumartist"):
                    val = (f.get(tag) or [None])[0]
                    if val:
                        all_artists.add(str(val).strip())
        except Exception:
            pass
    _end_progress()

    bare_to_canonical = build_bare_to_canonical(all_artists)
    if bare_to_canonical:
        print(f"\n  Bare artist names detected (will be mapped to canonical form):")
        for bare, canonical in bare_to_canonical.items():
            print(f"    '{bare}'  →  '{canonical}'")
    print()

    # Sub-pass B: Normalize tags
    print(f"  Normalizing tags...")
    changed, skipped, errors = [], [], []
    _print_progress(0, total_files, "Normalizing")
    for i, fp in enumerate(all_files, 1):
        if i % UPDATE_EVERY == 0 or i == total_files:
            rel = str(Path(fp).relative_to(folder))
            _print_progress(i, total_files, "Normalizing", rel)
        try:
            artist, album_artist, album, title = get_tags(fp)

            new_artist       = apply_casing(normalize_artist(artist, bare_to_canonical)) if artist else None
            new_album_artist = apply_casing(normalize_artist(album_artist, bare_to_canonical)) if album_artist else None
            new_album        = apply_casing(normalize_title_article(normalize_album(album))) if album else None
            new_title        = title_case(normalize_title_article(title)) if title else None

            artist_changed       = new_artist       and new_artist       != artist
            album_artist_changed = new_album_artist and new_album_artist != album_artist
            album_changed        = new_album        and new_album        != album
            title_changed        = new_title        and new_title        != title

            if artist_changed or album_artist_changed or album_changed or title_changed:
                write_tags(
                    fp,
                    artist=new_artist             if artist_changed       else None,
                    album_artist=new_album_artist if album_artist_changed else None,
                    album=new_album               if album_changed        else None,
                    title=new_title               if title_changed        else None,
                )
                rec = {"path": fp, "changes": {}}
                if artist_changed:       rec["changes"]["artist"]       = {"from": artist,       "to": new_artist}
                if album_artist_changed: rec["changes"]["album_artist"] = {"from": album_artist, "to": new_album_artist}
                if album_changed:        rec["changes"]["album"]        = {"from": album,        "to": new_album}
                if title_changed:        rec["changes"]["title"]        = {"from": title,        "to": new_title}
                changed.append(rec)
            else:
                skipped.append(fp)

        except Exception as e:
            errors.append({"path": fp, "error": str(e)})

    _end_progress()

    if errors:
        for err in errors:
            print(f"  ERROR: {Path(err['path']).name}: {err['error']}")

    print(f"\n{'='*60}")
    print(f"  Phase 2 complete — {total_files:,} files processed")
    print(f"    Tags updated:  {len(changed):,}")
    print(f"    No change:     {len(skipped):,}")
    print(f"    Errors:        {len(errors):,}")
    print(f"{'='*60}\n")

    # ══════════════════════════════════════════════════════════════════════════
    # PHASE 2.5 — Normalize Album Artist tags
    # ══════════════════════════════════════════════════════════════════════════
    print(f"{'='*60}")
    print(f"PHASE 2.5 — Normalize Album Artist tags")
    print(f"  Non-compilation: Album Artist = primary Artist (no feat. credits)")
    print(f"  Compilation:     Album Artist = 'Various Artists' + set flag")
    print(f"  Detection:       multiple distinct primary artists in same folder")
    print(f"{'='*60}\n")

    # Group all files by their album folder (same directory = same album)
    from collections import defaultdict as _defaultdict
    album_folders: dict = _defaultdict(list)
    for fp in all_files:
        album_folders[str(Path(fp).parent)].append(fp)

    p25_aa_updated   = 0
    p25_comp_set     = 0
    p25_errors_25: list = []

    total_albums = len(album_folders)
    _print_progress(0, total_albums, "Scanning albums")
    for idx, (dir_path, dir_files) in enumerate(album_folders.items(), 1):
        if idx % 5 == 0 or idx == total_albums:
            _print_progress(idx, total_albums, "Albums", Path(dir_path).name)
        try:
            track_data = []
            for fp in dir_files:
                artist, album_artist, _, _ = get_tags(fp)
                is_comp = get_compilation_flag(fp)
                prim = primary_artist(artist) if artist else None
                # Normalize primary artist using the same casing rules as Phase 2
                if prim:
                    prim = apply_casing(normalize_artist(prim, bare_to_canonical))
                track_data.append({
                    "path":         fp,
                    "artist":       artist,
                    "album_artist": album_artist,
                    "primary":      prim,
                    "is_comp":      is_comp,
                })

            if not track_data:
                continue

            # Determine if compilation: flag already set OR multiple distinct primary artists
            primary_set = {t["primary"] for t in track_data if t["primary"]}
            any_comp_flag = any(t["is_comp"] for t in track_data)
            is_compilation = any_comp_flag or len(primary_set) > 1

            if is_compilation:
                # Rule 2: Various Artists + set compilation flag
                for t in track_data:
                    if t["album_artist"] != "Various Artists":
                        write_tags(t["path"], album_artist="Various Artists")
                        p25_aa_updated += 1
                    if not t["is_comp"]:
                        write_compilation_flag(t["path"], True)
                        p25_comp_set += 1
            else:
                # Rule 1 + Rule 4: Album Artist = primary Artist (feat. credits stripped)
                sole_primary = next(iter(primary_set), None) if primary_set else None
                for t in track_data:
                    target_aa = sole_primary or t["primary"] or t["artist"]
                    if target_aa and t["album_artist"] != target_aa:
                        write_tags(t["path"], album_artist=target_aa)
                        p25_aa_updated += 1

        except Exception as e:
            p25_errors_25.append(f"{Path(dir_path).name}: {e}")

    _end_progress()

    if p25_errors_25:
        for err in p25_errors_25:
            print(f"  ERROR: {err}")

    print(f"\n{'='*60}")
    print(f"  Phase 2.5 complete — {total_albums:,} albums scanned")
    print(f"    Album Artist fields updated: {p25_aa_updated:,}")
    print(f"    Compilation flags set:       {p25_comp_set:,}")
    print(f"    Errors:                      {len(p25_errors_25):,}")
    print(f"  NOTE: Classical Album Artist (composer) must be set manually")
    print(f"        via the GenreFix web app after genre classification runs.")
    print(f"{'='*60}\n")

    # ══════════════════════════════════════════════════════════════════════════
    # PHASE 3 — Rebuild folder structure from normalized tags
    # ══════════════════════════════════════════════════════════════════════════
    print(f"{'='*60}")
    print(f"PHASE 3 — Rebuild Folder Structure")
    print(f"{'='*60}")
    print(f"\n  This will rebuild your music library as:")
    print(f"    {folder}\\")
    print(f"      Artist Name\\")
    print(f"        Album Name\\")
    print(f"          track files")
    print(f"\n  WARNING: The original folder layout will be replaced.")
    print(f"  Any music player paths pointing to the old structure will need updating.")
    print(f"\n  Proceed with folder restructure? (yes / no)")
    answer = input("  > ").strip().lower()
    if answer not in ("yes", "y"):
        print("  Skipped. Folder structure unchanged.")
        input("\nPress Enter to close...")
        return

    # Collect all audio files and all artwork files before moving anything
    print(f"\n  Scanning files...")
    all_audio   = collect_files(folder)
    all_artwork = []
    for root, dirs, filenames in os.walk(folder):
        dirs[:] = [d for d in dirs if d not in SKIP_FOLDERS]
        for fname in filenames:
            if Path(fname).suffix.lower() in ARTWORK_EXTS:
                all_artwork.append(os.path.join(root, fname))
    print(f"  Found {len(all_audio):,} audio files and {len(all_artwork):,} artwork files.\n")

    restructured       = 0
    restructure_errors = 0
    p3_error_msgs      = []

    # Build a map: old album folder path → new album folder path
    # so artwork can follow its audio tracks
    old_to_new_dir: dict = {}

    print(f"  Moving audio files...")
    _print_progress(0, len(all_audio), "Restructuring")
    for i, fp in enumerate(all_audio, 1):
        if i % UPDATE_EVERY == 0 or i == len(all_audio):
            rel = str(Path(fp).relative_to(folder))
            _print_progress(i, len(all_audio), "Restructuring", rel)
        try:
            artist, album_artist, album, title = get_tags(fp)
            folder_artist = (album_artist or artist or "").strip()
            folder_album  = (album or "").strip()
            # Normalize folder names (safety net on top of Phase 2)
            if folder_artist:
                folder_artist = apply_casing(normalize_artist(folder_artist)) or folder_artist
                folder_artist = safe_folder_name(folder_artist)
            if folder_album:
                folder_album = apply_casing(normalize_title_article(normalize_album(folder_album))) or folder_album
                folder_album = safe_folder_name(folder_album)

            if not folder_artist:
                # No artist — send to aaaUnsorted at root
                new_dir = folder / "aaaUnsorted"
            elif not folder_album:
                # No album — flat under artist
                new_dir = folder / folder_artist
            else:
                new_dir = folder / folder_artist / folder_album
            new_dir.mkdir(parents=True, exist_ok=True)

            # Track old → new folder mapping for artwork
            old_dir = str(Path(fp).parent)
            old_to_new_dir[old_dir] = str(new_dir)

            new_path = new_dir / Path(fp).name
            if new_path.exists() and str(new_path) != fp:
                stem, suffix = Path(fp).stem, Path(fp).suffix
                counter = 2
                while new_path.exists():
                    new_path = new_dir / f"{stem} ({counter}){suffix}"
                    counter += 1

            if str(new_path) != fp:
                shutil.move(fp, str(new_path))
                restructured += 1

        except Exception as e:
            p3_error_msgs.append(f"ERROR restructuring {Path(fp).name}: {e}")
            restructure_errors += 1

    _end_progress()

    # Move artwork using the old → new folder map
    if all_artwork:
        print(f"\n  Moving artwork files...")
        _print_progress(0, len(all_artwork), "Artwork")
        for i, fp in enumerate(all_artwork, 1):
            if i % UPDATE_EVERY == 0 or i == len(all_artwork):
                _print_progress(i, len(all_artwork), "Artwork")
            try:
                old_dir  = str(Path(fp).parent)
                new_dir  = old_to_new_dir.get(old_dir)
                if not new_dir:
                    continue  # no audio was in this folder — leave artwork in place
                dst = Path(new_dir) / Path(fp).name
                Path(new_dir).mkdir(parents=True, exist_ok=True)
                if dst.exists() and str(dst) != fp:
                    stem, suffix = Path(fp).stem, Path(fp).suffix
                    counter = 2
                    while dst.exists():
                        dst = Path(new_dir) / f"{stem} ({counter}){suffix}"
                        counter += 1
                if str(dst) != fp:
                    shutil.move(fp, str(dst))
                    restructured += 1
            except Exception as e:
                p3_error_msgs.append(f"ERROR moving artwork {Path(fp).name}: {e}")
                restructure_errors += 1
        _end_progress()

    # Remove old empty folders
    print(f"\n  Cleaning up empty folders...")
    remove_empty_folders(folder)

    if p3_error_msgs:
        print()
        for msg in p3_error_msgs:
            print(f"  {msg}")

    print(f"\n{'='*60}")
    print(f"  Phase 3 complete — {restructured:,} files moved into new structure")
    print(f"    Errors: {restructure_errors:,}")
    print(f"{'='*60}\n")

    # ══════════════════════════════════════════════════════════════════════════
    # ── Save log ──────────────────────────────────────────────────────────────
    log = {
        "run_timestamp":         datetime.now().isoformat(),
        "music_folder":          str(folder),
        "article_format":        ARTICLE_FORMAT,
        "preferred_format":      PREFERRED_FORMAT,
        "files_found":           len(all_files),
        "tags_populated":        populated,
        "tags_normalized":       len(changed),
        "files_restructured":    restructured,
        "normalization_changes": changed,
        "errors":                errors,
    }
    out = Path.home() / "genrefix_standardizer_log.json"
    with open(out, "w", encoding="utf-8") as fh:
        json.dump(log, fh, indent=2, ensure_ascii=False)
    print(f"\nLog saved to: {out}")

    print("\n" + "="*60)
    print("  GenreFix Standardizer is done.")
    print("="*60)
    print()
    print("WHAT TO DO NEXT — follow these steps in order:")
    print()
    print("STEP 1 — Remove duplicate tracks (do this first):")
    print(f"  1. Open your browser and go to genrefix.net/duplicates")
    print(f"  2. Click Browse and select this same music folder:")
    print(f"     {folder}")
    print("  3. Run the scan and wait for it to complete")
    print('  4. When results appear, click "Download Windows Script"')
    print("  5. Go to your Downloads folder")
    print("  6. Find genrefix_mover.bat and DOUBLE-CLICK it")
    print("  7. Follow the prompts — this is what actually moves duplicate files")
    print("  8. Wait for the mover to finish")
    print("  9. Check for the !GenreFix Duplicates folder and delete it when satisfied")
    print()
    print("STEP 2 — Classify your music (after duplicates are removed):")
    print("  1. Go to genrefix.net in your browser")
    print("  2. Click 'Classify My Music'")
    print("  3. Select this same music folder when prompted")
    print("  4. GenreFix will classify every artist in your collection")
    print()
    print("="*60)

    input("\nPress Enter to close this window...")


if __name__ == "__main__":
    main()
