#!/usr/bin/env python3
# Matcher v2: hard artist gate + feat-in-title handling + remix/live penalties
# -*- coding: utf-8 -*-

r"""
Split a long YouTube compilation by timestamps, fetch iTunes metadata/artwork,
and tag each output track.

Recommended input: M4A/AAC downloaded by YTSage.
If the input audio codec is AAC/ALAC, splitting is lossless (-c:a copy).
Other codecs are automatically converted to AAC/M4A.

Requirements:
    py -m pip install mutagen

Usage:
    py split_itunes.py "D:\Downloads\AK1daP-0oPU.m4a"
    py split_itunes.py "D:\Downloads\AK1daP-0oPU.m4a" -o "D:\Music\2010s Mix"
    py split_itunes.py "D:\Downloads\AK1daP-0oPU.webm" --country US

ffmpeg and ffprobe must be available in PATH.
"""

from __future__ import annotations

import argparse
import difflib
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import time
import unicodedata
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Any

try:
    from mutagen.mp4 import MP4, MP4Cover
except ImportError:
    print("ERROR: mutagen is not installed.")
    print("Install it with: py -m pip install mutagen")
    raise SystemExit(2)


TRACKLIST = r"""
0:00|Down|Jay Sean
3:35|Dynamite|Taio Cruz
6:59|Break Your Heart|Taio Cruz
10:25|In My Head|Jason Derulo
13:43|Whatcha Say|Jason Derulo
17:26|Replay|Iyaz
20:28|Fire Burning|Sean Kingston
24:28|Last Friday Night (T.G.I.F.)|Katy Perry
28:20|Airplanes|B.o.B feat. Hayley Williams
31:18|You Make Me Feel...|Cobra Starship feat. Sabi
34:53|TiK ToK|Kesha
38:14|Hotel Room Service|Pitbull
42:15|DJ Got Us Fallin' in Love|Usher feat. Pitbull
45:46|Give Me Everything|Pitbull feat. Ne-Yo
49:51|Mr. Saxobeat|Alexandra Stan
53:04|Like a G6|Far East Movement
56:43|S&M|Rihanna
1:00:47|Blow|Kesha
1:04:29|Right Round|Flo Rida feat. Kesha
1:07:53|Teenage Dream|Katy Perry
1:11:41|Starships|Nicki Minaj
1:15:11|Evacuate the Dancefloor|Cascada
1:18:39|We R Who We R|Kesha
1:21:56|On the Floor|Jennifer Lopez feat. Pitbull
1:26:40|California Gurls|Katy Perry feat. Snoop Dogg
1:30:36|Your Love Is My Drug|Kesha
1:33:43|You Belong with Me|Taylor Swift
1:37:30|Sexy Bitch|David Guetta feat. Akon
1:40:45|OMG|Usher feat. will.i.am
1:45:16|Die Young|Kesha
1:48:42|22|Taylor Swift
1:52:26|Hey, Soul Sister|Train
1:56:00|Stereo Hearts|Gym Class Heroes feat. Adam Levine
1:59:35|Cooler Than Me|Mike Posner
2:03:07|Rude Boy|Rihanna
2:06:52|Blah Blah Blah|Kesha feat. 3OH!3
2:09:46|The One That Got Away|Katy Perry
2:13:33|Love Story|Taylor Swift
2:17:27|Party in the U.S.A.|Miley Cyrus
2:20:49|Telephone|Lady Gaga feat. Beyoncé
2:24:31|I Gotta Feeling|The Black Eyed Peas
2:28:38|Only Girl (In the World)|Rihanna
2:32:34|American Boy|Estelle feat. Kanye West
2:37:11|Moves Like Jagger|Maroon 5 feat. Christina Aguilera
2:40:34|Imma Be|The Black Eyed Peas
2:44:51|Party Rock Anthem|LMFAO
2:49:14|Good Feeling|Flo Rida
2:53:21|Where Have You Been|Rihanna
2:57:24|Bad Romance|Lady Gaga
3:02:16|Meet Me Halfway|The Black Eyed Peas
3:07:01|Billionaire|Travie McCoy feat. Bruno Mars
3:10:33|The Edge of Glory|Lady Gaga
3:15:52|Fireflies|Owl City
3:19:37|Good Time|Owl City feat. Carly Rae Jepsen
3:23:01|Just Dance|Lady Gaga
3:27:04|Good Girls Go Bad|Cobra Starship feat. Leighton Meester
3:30:20|Glad You Came|The Wanted
3:33:39|Tonight Tonight|Hot Chelle Rae
3:36:54|What Makes You Beautiful|One Direction
3:40:13|Waka Waka (This Time for Africa)|Shakira
3:43:28|We Found Love|Rihanna
3:47:03|Poker Face|Lady Gaga
3:51:00|E.T.|Katy Perry
3:54:27|Born This Way|Lady Gaga
3:58:45|Just the Way You Are|Bruno Mars
4:02:24|Payphone|Maroon 5 feat. Wiz Khalifa
4:06:16|We Are Young|fun. feat. Janelle Monáe
4:10:25|Love the Way You Lie|Eminem feat. Rihanna
4:14:47|Part of Me|Katy Perry
4:18:25|Coming Home|Diddy - Dirty Money feat. Skylar Grey
4:22:23|Not Over You|Gavin DeGraw
4:26:01|Just a Dream|Nelly
4:29:59|Beautiful Girls|Sean Kingston
4:33:59|I Like It|Enrique Iglesias feat. Pitbull
4:37:49|Yeah 3x|Chris Brown
4:41:49|Tonight (I'm Lovin' You)|Enrique Iglesias feat. Ludacris
4:45:39|Hey Baby (Drop It to the Floor)|Pitbull feat. T-Pain
4:49:35|Boom Boom Pow|The Black Eyed Peas
4:53:48|Magic|B.o.B feat. Rivers Cuomo
4:57:01|Domino|Jessie J
5:00:52|Miss Independent|Ne-Yo
5:04:46|Alejandro|Lady Gaga
5:09:17|Naturally|Selena Gomez & The Scene
5:12:41|A Thousand Years|Christina Perri
5:17:21|Need You Now|Lady A
5:21:15|Drive By|Train
5:24:32|Paradise|Coldplay
5:28:45|One More Night|Maroon 5
5:32:19|Chasing the Sun|The Wanted
5:35:33|Halo|Beyoncé
5:39:53|Grenade|Bruno Mars
5:43:30|Misery|Maroon 5
5:46:59|Animal|Neon Trees
5:50:29|Secrets|OneRepublic
5:54:14|The Lazy Song|Bruno Mars
5:57:24|Wide Awake|Katy Perry
6:01:03|My First Kiss|3OH!3 feat. Kesha
6:04:16|Super Bass|Nicki Minaj
6:07:36|Paparazzi|Lady Gaga
6:11:04|Nothin' on You|B.o.B feat. Bruno Mars
6:15:26|Baby|Justin Bieber feat. Ludacris
6:18:59|Kiss Me Thru the Phone|Soulja Boy Tell 'Em feat. Sammie
6:22:12|Heartless|Kanye West
6:25:42|Ridin' Solo|Jason Derulo
6:29:17|What's My Name?|Rihanna feat. Drake
6:33:42|Take It Off|Kesha
6:37:16|Till the World Ends|Britney Spears
6:41:14|Pound the Alarm|Nicki Minaj
6:44:42|Single Ladies (Put a Ring on It)|Beyoncé
6:47:53|Marry You|Bruno Mars
6:51:40|Just Can't Get Enough|The Black Eyed Peas
6:55:21|Empire State of Mind|JAY-Z feat. Alicia Keys
6:59:57|Jar of Hearts|Christina Perri
7:04:02|Titanium|David Guetta feat. Sia
7:07:57|Firework|Katy Perry
""".strip()


@dataclass
class Track:
    start_text: str
    start: float
    title: str
    artist: str


def parse_time(text: str) -> float:
    parts = [int(x) for x in text.split(":")]
    if len(parts) == 2:
        m, s = parts
        return m * 60 + s
    if len(parts) == 3:
        h, m, s = parts
        return h * 3600 + m * 60 + s
    raise ValueError(f"Bad timestamp: {text}")


def parse_tracklist() -> list[Track]:
    tracks: list[Track] = []
    for line in TRACKLIST.splitlines():
        ts, title, artist = line.split("|", 2)
        tracks.append(Track(ts, parse_time(ts), title.strip(), artist.strip()))
    return tracks


def run(cmd: list[str], *, capture: bool = False) -> subprocess.CompletedProcess[str]:
    return subprocess.run(
        cmd,
        check=True,
        text=True,
        capture_output=capture,
        encoding="utf-8",
        errors="replace",
    )


def ffprobe_info(path: Path) -> tuple[float, str]:
    cp = run([
        "ffprobe", "-v", "error",
        "-select_streams", "a:0",
        "-show_entries", "format=duration:stream=codec_name",
        "-of", "json",
        str(path),
    ], capture=True)
    data = json.loads(cp.stdout)
    duration = float(data["format"]["duration"])
    codec = data["streams"][0].get("codec_name", "")
    return duration, codec


INVALID_WIN = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
RESERVED_WIN = {
    "CON", "PRN", "AUX", "NUL",
    *(f"COM{i}" for i in range(1, 10)),
    *(f"LPT{i}" for i in range(1, 10)),
}


def safe_filename(text: str) -> str:
    text = INVALID_WIN.sub("_", text).strip().rstrip(". ")
    if not text:
        text = "_"
    if text.upper() in RESERVED_WIN:
        text = "_" + text
    return text[:180]


def normalize(s: str) -> str:
    s = unicodedata.normalize("NFKD", s)
    s = "".join(c for c in s if not unicodedata.combining(c))
    s = s.lower()
    s = s.replace("&", " and ")
    s = re.sub(r"\b(feat|ft|featuring)\.?\b", " ", s)
    s = re.sub(r"\bthe\b", " ", s)
    s = re.sub(r"[^a-z0-9]+", " ", s)
    return " ".join(s.split())


def similarity(a: str, b: str) -> float:
    return difflib.SequenceMatcher(None, normalize(a), normalize(b)).ratio()


def artist_similarity(wanted: str, got: str) -> float:
    # Compare whole strings plus individual credited artists.
    whole = similarity(wanted, got)
    wparts = [p.strip() for p in re.split(r"\b(?:feat|ft|featuring)\.?\b|&|,| x ", wanted, flags=re.I) if p.strip()]
    gnorm = normalize(got)
    if not wparts:
        return whole
    part_scores = [difflib.SequenceMatcher(None, normalize(p), gnorm).ratio() for p in wparts]
    contained = sum(1 for p in wparts if normalize(p) and normalize(p) in gnorm) / len(wparts)
    return max(whole, max(part_scores, default=0.0) * 0.9, contained)


def canonical_title(s: str) -> str:
    # Apple often stores featured artists in the title:
    # "Magic (feat. Rivers Cuomo)" while our source list says just "Magic".
    s = re.sub(
        r"\s*[\(\[]\s*(?:feat|ft|featuring)\.?\s+.*?[\)\]]",
        "",
        s or "",
        flags=re.I,
    )
    return normalize(s)


def split_artist_credits(s: str) -> list[str]:
    # Keep compound stage names such as "Diddy - Dirty Money" intact.
    parts = re.split(
        r"\b(?:feat|ft|featuring)\.?\b|&|,|\s+\bx\b\s+",
        s or "",
        flags=re.I,
    )
    return [p.strip() for p in parts if normalize(p)]


def score_result(track: Track, result: dict[str, Any], segment_duration: float) -> float:
    title = result.get("trackName", "")
    artist = result.get("artistName", "")

    if result.get("kind") != "song":
        return -1.0

    # HARD ARTIST GATE.
    #
    # Search results with the same title but an unrelated performer used to
    # pass (covers, karaoke, Glee Cast, etc.). Require at least one credited
    # source artist to occur either in Apple's artistName OR in trackName,
    # since Apple frequently puts featured artists into the title instead.
    wanted_parts = split_artist_credits(track.artist)
    combined = normalize(f"{artist} {title}")

    matched_parts = [
        p for p in wanted_parts
        if normalize(p) and normalize(p) in combined
    ]

    if matched_parts:
        credit_score = len(matched_parts) / max(1, len(wanted_parts))
    else:
        # Allow spelling/punctuation variations, but reject unrelated artists.
        fuzzy = max(
            (similarity(p, artist) for p in wanted_parts),
            default=0.0,
        )
        if fuzzy < 0.78:
            return -1.0
        credit_score = fuzzy

    # Compare titles after removing "(feat. ...)" / "[feat. ...]".
    ts = difflib.SequenceMatcher(
        None,
        canonical_title(track.title),
        canonical_title(title),
    ).ratio()

    duration_ms = result.get("trackTimeMillis")
    if duration_ms:
        delta = abs((float(duration_ms) / 1000.0) - segment_duration)
        ds = max(0.0, 1.0 - delta / 30.0)
    else:
        ds = 0.4

    score = ts * 0.60 + credit_score * 0.30 + ds * 0.10

    if canonical_title(track.title) == canonical_title(title):
        score += 0.12

    primary = normalize(wanted_parts[0]) if wanted_parts else ""
    if primary and primary in combined:
        score += 0.06

    # Prefer the ordinary studio version if the source title did not ask for
    # a remix/live/session/edit/etc.
    variant_words = (
        "remix", "edit", "live", "acoustic", "karaoke",
        "tribute", "session", "instrumental", "k mix",
    )
    wanted_title = normalize(track.title)
    got_title = normalize(title)
    for word in variant_words:
        nw = normalize(word)
        if nw in got_title and nw not in wanted_title:
            score -= 0.18
            break

    return score


class ITunesClient:
    API = "https://itunes.apple.com/search"

    def __init__(self, cache_file: Path, country: str = "US", min_delay: float = 3.2):
        self.cache_file = cache_file
        self.country = country.upper()
        self.min_delay = min_delay
        self.last_request = 0.0
        try:
            self.cache = json.loads(cache_file.read_text("utf-8"))
        except (FileNotFoundError, json.JSONDecodeError):
            self.cache = {}

    def save_cache(self) -> None:
        self.cache_file.write_text(
            json.dumps(self.cache, ensure_ascii=False, indent=2),
            encoding="utf-8",
        )

    def search(self, track: Track, segment_duration: float) -> tuple[dict[str, Any] | None, float]:
        query = f"{track.artist} {track.title}"
        cache_key = f"{self.country}|{query}"

        if cache_key in self.cache:
            results = self.cache[cache_key]
        else:
            wait = self.min_delay - (time.monotonic() - self.last_request)
            if wait > 0:
                time.sleep(wait)

            params = urllib.parse.urlencode({
                "term": query,
                "country": self.country,
                "media": "music",
                "entity": "song",
                "limit": 50,
                "explicit": "Yes",
            })
            url = f"{self.API}?{params}"

            results = None
            for attempt in range(5):
                try:
                    req = urllib.request.Request(
                        url,
                        headers={
                            "User-Agent": "Mozilla/5.0",
                            "Accept": "application/json",
                        },
                    )
                    self.last_request = time.monotonic()
                    with urllib.request.urlopen(req, timeout=20) as r:
                        payload = json.load(r)
                    results = payload.get("results", [])
                    break
                except urllib.error.HTTPError as e:
                    if e.code == 429:
                        sleep_for = 10 * (attempt + 1)
                        print(f"    iTunes rate limit; retry in {sleep_for}s")
                        time.sleep(sleep_for)
                        continue
                    raise
                except (urllib.error.URLError, TimeoutError) as e:
                    if attempt == 4:
                        print(f"    iTunes request failed: {e}")
                        results = []
                        break
                    time.sleep(3 * (attempt + 1))

            assert results is not None
            self.cache[cache_key] = results
            self.save_cache()

        if not results:
            return None, 0.0

        ranked = sorted(
            ((score_result(track, r, segment_duration), r) for r in results),
            key=lambda x: x[0],
            reverse=True,
        )
        return ranked[0][1], ranked[0][0]


def high_res_artwork_url(url: str, size: int) -> str:
    # Current iTunes Search API commonly returns .../100x100bb.jpg
    url2 = re.sub(
        r"/\d+x\d+bb\.(jpg|jpeg|png)(?:\?.*)?$",
        lambda m: f"/{size}x{size}bb.{m.group(1)}",
        url,
        flags=re.I,
    )
    if url2 != url:
        return url2

    # Older mzstatic/iTunes format: .100x100-75.jpg
    url2 = re.sub(
        r"\.\d+x\d+-\d+\.(jpg|jpeg|png)(?:\?.*)?$",
        lambda m: f".{size}x{size}-999.{m.group(1)}",
        url,
        flags=re.I,
    )
    return url2


def download_artwork(url: str, out: Path, size: int) -> bytes | None:
    candidates = [high_res_artwork_url(url, size), url]
    seen: set[str] = set()
    for candidate in candidates:
        if candidate in seen:
            continue
        seen.add(candidate)
        try:
            req = urllib.request.Request(candidate, headers={"User-Agent": "Mozilla/5.0"})
            with urllib.request.urlopen(req, timeout=20) as r:
                data = r.read()
            if len(data) < 1000:
                continue
            out.write_bytes(data)
            return data
        except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError):
            pass
    return None


def split_track(
    source: Path,
    out: Path,
    start: float,
    end: float,
    source_codec: str,
    force: bool,
) -> None:
    if out.exists() and not force:
        return

    tmp = out.with_suffix(".tmp.m4a")
    tmp.unlink(missing_ok=True)

    duration = max(0.1, end - start)

    common = [
        "ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
        "-ss", f"{start:.3f}",
        "-i", str(source),
        "-t", f"{duration:.3f}",
        "-map", "0:a:0",
        "-map_metadata", "-1",
        "-vn",
    ]

    if source_codec in {"aac", "alac"}:
        codec_args = ["-c:a", "copy"]
    else:
        # Only used if YTSage input isn't M4A/AAC.
        codec_args = ["-c:a", "aac", "-b:a", "256k"]

    cmd = common + codec_args + ["-movflags", "+faststart", str(tmp)]
    run(cmd)
    os.replace(tmp, out)


def tag_m4a(path: Path, meta: dict[str, Any] | None, fallback: Track, artwork: bytes | None) -> None:
    audio = MP4(path)
    if audio.tags is None:
        audio.add_tags()

    if meta:
        title = meta.get("trackName") or fallback.title
        artist = meta.get("artistName") or fallback.artist
        album = meta.get("collectionName")
        release = meta.get("releaseDate", "")
        year = release[:4] if release else None
        genre = meta.get("primaryGenreName")
        track_no = meta.get("trackNumber")
        track_count = meta.get("trackCount")
        disc_no = meta.get("discNumber")
        disc_count = meta.get("discCount")
    else:
        title = fallback.title
        artist = fallback.artist
        album = None
        year = None
        genre = None
        track_no = track_count = disc_no = disc_count = None

    audio.tags["\xa9nam"] = [str(title)]
    audio.tags["\xa9ART"] = [str(artist)]
    audio.tags["aART"] = [str(artist)]

    if album:
        audio.tags["\xa9alb"] = [str(album)]
    if year:
        audio.tags["\xa9day"] = [str(year)]
    if genre:
        audio.tags["\xa9gen"] = [str(genre)]
    if track_no:
        audio.tags["trkn"] = [(int(track_no), int(track_count or 0))]
    if disc_no:
        audio.tags["disk"] = [(int(disc_no), int(disc_count or 0))]

    # Apple/iTunes IDs when available.
    if meta:
        if meta.get("trackId") is not None:
            audio.tags["cnID"] = [int(meta["trackId"])]
        if meta.get("artistId") is not None:
            audio.tags["atID"] = [int(meta["artistId"])]
        if meta.get("collectionId") is not None:
            audio.tags["plID"] = [int(meta["collectionId"])]

    if artwork:
        if artwork.startswith(b"\x89PNG"):
            imageformat = MP4Cover.FORMAT_PNG
        else:
            imageformat = MP4Cover.FORMAT_JPEG
        audio.tags["covr"] = [MP4Cover(artwork, imageformat=imageformat)]

    audio.save()


def main() -> int:
    ap = argparse.ArgumentParser(
        description="Split AK1daP-0oPU compilation, fetch iTunes metadata/artwork and tag M4A tracks."
    )
    ap.add_argument("input", type=Path, help="Downloaded long audio file (M4A/AAC recommended)")
    ap.add_argument("-o", "--output", type=Path, default=Path("split_tracks"), help="Output directory")
    ap.add_argument("--country", default="US", help="iTunes storefront country code, default: US")
    ap.add_argument("--artwork-size", type=int, default=1200, help="Requested artwork size, default: 1200")
    ap.add_argument("--force", action="store_true", help="Re-split existing audio files")
    ap.add_argument("--min-score", type=float, default=0.70, help="Minimum match score before metadata is trusted")
    args = ap.parse_args()

    if not args.input.is_file():
        print(f"ERROR: input file not found: {args.input}")
        return 2
    if shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None:
        print("ERROR: ffmpeg/ffprobe not found in PATH.")
        return 2

    tracks = parse_tracklist()
    args.output.mkdir(parents=True, exist_ok=True)
    artwork_dir = args.output / "Artworks"
    artwork_dir.mkdir(exist_ok=True)

    source_duration, source_codec = ffprobe_info(args.input)
    if source_duration <= tracks[-1].start:
        print(f"ERROR: source duration is only {source_duration:.1f}s, but last timestamp is {tracks[-1].start:.1f}s")
        return 2

    print(f"Input: {args.input}")
    print(f"Codec: {source_codec}")
    print(f"Duration: {source_duration/3600:.2f} h")
    print(f"Tracks: {len(tracks)}")
    if source_codec not in {"aac", "alac"}:
        print("WARNING: source is not AAC/ALAC; output will be re-encoded to AAC 256 kb/s.")
        print("         For lossless splitting, download M4A/AAC in YTSage.")

    cache_file = args.output / "itunes_cache.json"
    client = ITunesClient(cache_file, args.country)

    report: list[dict[str, Any]] = []

    for idx, track in enumerate(tracks, 1):
        end = tracks[idx].start if idx < len(tracks) else source_duration
        seg_duration = end - track.start

        stem = safe_filename(f"{idx:03d} - {track.artist} - {track.title}")
        out = args.output / f"{stem}.m4a"
        art_out = artwork_dir / f"{stem}.jpg"

        print(f"[{idx:03d}/{len(tracks):03d}] {track.artist} - {track.title}")

        split_track(
            args.input, out, track.start, end, source_codec, args.force
        )

        # Always search if retag requested. Otherwise the API cache makes reruns cheap.
        match, score = client.search(track, seg_duration)

        trusted = match is not None and score >= args.min_score
        if match:
            print(
                f"    match {score:.3f}: "
                f"{match.get('artistName', '?')} - {match.get('trackName', '?')} "
                f"[{match.get('collectionName', '?')}]"
            )
        else:
            print("    no iTunes result")

        if not trusted and match:
            print("    CHECK: match score is low; using supplied title/artist, but keeping candidate in report")

        artwork = None
        if trusted and match and match.get("artworkUrl100"):
            artwork = download_artwork(
                str(match["artworkUrl100"]),
                art_out,
                args.artwork_size,
            )
            if artwork is None:
                print("    artwork download failed")
        else:
            art_out.unlink(missing_ok=True)

        tag_m4a(out, match if trusted else None, track, artwork)

        report.append({
            "index": idx,
            "timestamp": track.start_text,
            "source_title": track.title,
            "source_artist": track.artist,
            "segment_duration_sec": round(seg_duration, 3),
            "score": round(score, 4),
            "trusted": trusted,
            "itunes_title": match.get("trackName") if match else None,
            "itunes_artist": match.get("artistName") if match else None,
            "itunes_album": match.get("collectionName") if match else None,
            "itunes_release_date": match.get("releaseDate") if match else None,
            "itunes_track_id": match.get("trackId") if match else None,
            "itunes_url": match.get("trackViewUrl") if match else None,
            "output": out.name,
            "artwork": art_out.name if art_out.exists() else None,
        })

    report_path = args.output / "match_report.json"
    report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")

    low = [x for x in report if not x["trusted"]]
    print()
    print(f"Done. Output: {args.output.resolve()}")
    print(f"Match report: {report_path.resolve()}")
    print(f"Low-confidence / missing matches: {len(low)}")
    if low:
        print("Review these indexes:")
        print(", ".join(f"{x['index']:03d}" for x in low))

    return 0


if __name__ == "__main__":
    raise SystemExit(main())
