from __future__ import annotations

import re
import sys
import unicodedata
import urllib.error
import urllib.request
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from pathlib import Path

RSS_URL = "https://feeds.simplecast.com/MBdw4Qrw"
ITUNES_NAMESPACE = {"itunes": "http://www.itunes.com/dtds/podcast-1.0.dtd"}
MIN_DURATION_SECONDS = 2 * 60 * 60
USER_AGENT = "clean-gg-rss/1.0"
WINDOWS_RESERVED_NAMES = {
    "CON",
    "PRN",
    "AUX",
    "NUL",
    "COM1",
    "COM2",
    "COM3",
    "COM4",
    "COM5",
    "COM6",
    "COM7",
    "COM8",
    "COM9",
    "LPT1",
    "LPT2",
    "LPT3",
    "LPT4",
    "LPT5",
    "LPT6",
    "LPT7",
    "LPT8",
    "LPT9",
}


class PodcastFetchError(RuntimeError):
    """Raised when the RSS feed or audio file cannot be processed."""


@dataclass(frozen=True)
class FeedCategory:
    primary: str
    secondary: str | None = None


@dataclass(frozen=True)
class ChannelMetadata:
    title: str
    description: str
    link: str
    language: str
    copyright: str
    image_url: str
    author: str
    summary: str
    explicit: str
    owner_name: str
    owner_email: str
    categories: tuple[FeedCategory, ...]


@dataclass(frozen=True)
class Episode:
    guid: str
    title: str
    description: str
    published_at: datetime
    pub_date_text: str
    duration_text: str
    duration_seconds: int
    mp3_url: str
    link: str


@dataclass(frozen=True)
class FeedSnapshot:
    channel: ChannelMetadata
    episodes: tuple[Episode, ...]


def fetch_rss(url: str = RSS_URL) -> bytes:
    request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
    try:
        with urllib.request.urlopen(request, timeout=30) as response:
            return response.read()
    except (urllib.error.URLError, OSError) as exc:
        raise PodcastFetchError(f"Unable to fetch RSS feed: {exc}") from exc


def parse_duration(value: str) -> int:
    text = value.strip()
    if not text:
        raise ValueError("Duration is empty.")

    if not re.fullmatch(r"\d+(?::\d+){0,2}", text):
        raise ValueError(f"Unsupported duration format: {value!r}")

    parts = [int(part) for part in text.split(":")]
    if len(parts) == 3:
        hours, minutes, seconds = parts
    elif len(parts) == 2:
        hours = 0
        minutes, seconds = parts
    else:
        hours = 0
        minutes = 0
        seconds = parts[0]

    return (hours * 3600) + (minutes * 60) + seconds


def parse_pub_date(value: str) -> datetime:
    published_at = parsedate_to_datetime(value)
    if published_at.tzinfo is None:
        published_at = published_at.replace(tzinfo=timezone.utc)
    return published_at


def parse_feed_snapshot(rss_xml: bytes) -> FeedSnapshot:
    try:
        root = ET.fromstring(rss_xml)
    except ET.ParseError as exc:
        raise PodcastFetchError(f"Invalid RSS XML: {exc}") from exc

    channel_element = root.find("channel")
    if channel_element is None:
        raise PodcastFetchError("RSS feed is missing the channel element.")

    categories: list[FeedCategory] = []
    for category_element in channel_element.findall("itunes:category", ITUNES_NAMESPACE):
        primary = category_element.attrib.get("text", "").strip()
        if not primary:
            continue
        nested_categories = category_element.findall("itunes:category", ITUNES_NAMESPACE)
        if nested_categories:
            for nested_category in nested_categories:
                secondary = nested_category.attrib.get("text", "").strip() or None
                categories.append(FeedCategory(primary=primary, secondary=secondary))
        else:
            categories.append(FeedCategory(primary=primary))

    image_element = channel_element.find("itunes:image", ITUNES_NAMESPACE)
    image_url = ""
    if image_element is not None:
        image_url = image_element.attrib.get("href", "").strip()
    if not image_url:
        image_url = channel_element.findtext("image/url", default="").strip()

    owner_element = channel_element.find("itunes:owner", ITUNES_NAMESPACE)
    owner_name = ""
    owner_email = ""
    if owner_element is not None:
        owner_name = owner_element.findtext("itunes:name", default="", namespaces=ITUNES_NAMESPACE).strip()
        owner_email = owner_element.findtext("itunes:email", default="", namespaces=ITUNES_NAMESPACE).strip()

    channel = ChannelMetadata(
        title=channel_element.findtext("title", default="").strip(),
        description=channel_element.findtext("description", default="").strip(),
        link=channel_element.findtext("link", default="").strip(),
        language=channel_element.findtext("language", default="").strip(),
        copyright=channel_element.findtext("copyright", default="").strip(),
        image_url=image_url,
        author=channel_element.findtext("itunes:author", default="", namespaces=ITUNES_NAMESPACE).strip(),
        summary=channel_element.findtext("itunes:summary", default="", namespaces=ITUNES_NAMESPACE).strip(),
        explicit=channel_element.findtext("itunes:explicit", default="", namespaces=ITUNES_NAMESPACE).strip(),
        owner_name=owner_name,
        owner_email=owner_email,
        categories=tuple(categories),
    )

    episodes: list[Episode] = []
    for item in channel_element.findall("item"):
        duration_text = item.findtext("itunes:duration", default="", namespaces=ITUNES_NAMESPACE).strip()
        duration_seconds = 0
        if duration_text:
            try:
                duration_seconds = parse_duration(duration_text)
            except ValueError:
                duration_seconds = 0

        pub_date_text = item.findtext("pubDate", default="").strip()
        if not pub_date_text:
            continue

        try:
            published_at = parse_pub_date(pub_date_text)
        except (TypeError, ValueError):
            continue

        enclosure = item.find("enclosure")
        mp3_url = ""
        if enclosure is not None:
            mp3_url = enclosure.attrib.get("url", "").strip()

        episodes.append(
            Episode(
                guid=item.findtext("guid", default="").strip(),
                title=item.findtext("title", default="Untitled episode").strip() or "Untitled episode",
                description=item.findtext("description", default="").strip(),
                published_at=published_at,
                pub_date_text=pub_date_text,
                duration_text=duration_text,
                duration_seconds=duration_seconds,
                mp3_url=mp3_url,
                link=item.findtext("link", default=channel.link).strip() or channel.link,
            )
        )

    return FeedSnapshot(channel=channel, episodes=tuple(episodes))


def find_latest_episode_over_2h(feed_snapshot_or_xml: FeedSnapshot | bytes) -> Episode:
    snapshot = feed_snapshot_or_xml
    if isinstance(feed_snapshot_or_xml, (bytes, bytearray)):
        snapshot = parse_feed_snapshot(bytes(feed_snapshot_or_xml))

    latest_episode = None
    for episode in snapshot.episodes:
        if episode.duration_seconds <= MIN_DURATION_SECONDS:
            continue
        if latest_episode is None or episode.published_at > latest_episode.published_at:
            latest_episode = episode

    if latest_episode is None:
        raise PodcastFetchError("No episode longer than 2 hours was found in the RSS feed.")

    if not latest_episode.mp3_url:
        raise PodcastFetchError(
            f"The latest episode longer than 2 hours is missing an enclosure URL: {latest_episode.title}"
        )

    return latest_episode


def sanitize_filename(title: str, extension: str = ".mp3") -> str:
    normalized = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode("ascii")
    cleaned = re.sub(r'[<>:"/\\|?*\x00-\x1f]+', " ", normalized)
    cleaned = re.sub(r"\s+", " ", cleaned).strip(" .")
    cleaned = cleaned.replace(" ", "_")
    cleaned = re.sub(r"_+", "_", cleaned)
    cleaned = cleaned[:120].rstrip("._ ")
    if not cleaned:
        cleaned = "episode"
    if cleaned.upper() in WINDOWS_RESERVED_NAMES:
        cleaned = f"episode_{cleaned}"
    return f"{cleaned}{extension}"


def download_file(url: str, destination: Path, overwrite: bool = False) -> Path:
    if destination.exists() and not overwrite:
        return destination

    destination.parent.mkdir(parents=True, exist_ok=True)
    temp_path = destination.with_name(f"{destination.name}.part")
    request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})

    try:
        with urllib.request.urlopen(request, timeout=60) as response:
            with temp_path.open("wb") as output_file:
                while True:
                    chunk = response.read(1024 * 1024)
                    if not chunk:
                        break
                    output_file.write(chunk)
        temp_path.replace(destination)
    except (urllib.error.URLError, OSError) as exc:
        try:
            temp_path.unlink()
        except FileNotFoundError:
            pass
        raise PodcastFetchError(f"Unable to download MP3 file: {exc}") from exc

    return destination


def main() -> int:
    try:
        rss_xml = fetch_rss()
        snapshot = parse_feed_snapshot(rss_xml)
        episode = find_latest_episode_over_2h(snapshot)
        destination = Path.cwd() / sanitize_filename(episode.title)

        print(f"Title: {episode.title}")
        print(f"Published: {episode.published_at.isoformat()}")
        print(f"Duration: {episode.duration_text} ({episode.duration_seconds} seconds)")
        print(f"MP3 URL: {episode.mp3_url}")

        downloaded_file = download_file(episode.mp3_url, destination)
        print(f"Downloaded to: {downloaded_file}")
        return 0
    except PodcastFetchError as exc:
        print(f"Error: {exc}", file=sys.stderr)
        return 1


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