#!/usr/bin/env python3
"""
Build a self-contained HTML report from the result.xml scrobble archive.

Usage:
    python make_lastfm_visualizations.py
    python make_lastfm_visualizations.py result.xml my_report.html
"""

from __future__ import annotations

import argparse
import calendar
import html
import math
import statistics
import xml.etree.ElementTree as ET
from collections import Counter, defaultdict
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from pathlib import Path


WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
PALETTE = ["#247ba0", "#70c1b3", "#f3b61f", "#c44536", "#6a4c93", "#2f855a", "#d67ab1"]


@dataclass(frozen=True)
class Track:
    title: str
    artist: str
    album: str
    url: str
    image: str
    listens: int
    first_seen: datetime
    last_seen: datetime


def clean_text(value: str | None, fallback: str = "Unknown") -> str:
    value = (value or "").strip()
    return value if value else fallback


def parse_archive(xml_path: Path) -> tuple[list[Track], list[tuple[datetime, str, str, str]]]:
    tracks: list[Track] = []
    listens: list[tuple[datetime, str, str, str]] = []

    for _, elem in ET.iterparse(xml_path, events=("end",)):
        if elem.tag != "track":
            continue

        title = clean_text(elem.findtext("title"), "Untitled")
        artist = clean_text(elem.findtext("artist"), "Unknown artist")
        album = clean_text(elem.findtext("album"), "Unknown album")
        url = clean_text(elem.findtext("url"), "")
        image = clean_text(elem.findtext("image"), "")

        dates: list[datetime] = []
        for date_elem in elem.findall("./listens/date"):
            uts = date_elem.attrib.get("uts")
            if not uts:
                continue
            try:
                listened_at = datetime.fromtimestamp(int(uts))
            except (OSError, OverflowError, ValueError):
                continue
            dates.append(listened_at)
            listens.append((listened_at, artist, title, album))

        if dates:
            tracks.append(
                Track(
                    title=title,
                    artist=artist,
                    album=album,
                    url=url,
                    image=image,
                    listens=len(dates),
                    first_seen=min(dates),
                    last_seen=max(dates),
                )
            )

        elem.clear()

    listens.sort(key=lambda row: row[0])
    tracks.sort(key=lambda row: (-row.listens, row.artist.casefold(), row.title.casefold()))
    return tracks, listens


def pct(part: int | float, total: int | float) -> float:
    return 0.0 if not total else 100.0 * part / total


def fmt_int(value: int) -> str:
    return f"{value:,}".replace(",", " ")


def esc(value: object) -> str:
    return html.escape(str(value), quote=True)


def counter_top(counter: Counter, n: int) -> list[tuple[str, int]]:
    return [(str(key), value) for key, value in counter.most_common(n)]


def html_bar_list(rows: list[tuple[str, int]], total: int | None = None, max_rows: int | None = None) -> str:
    if max_rows:
        rows = rows[:max_rows]
    if not rows:
        return '<p class="muted">No data.</p>'
    max_value = max(value for _, value in rows) or 1
    total_value = total or sum(value for _, value in rows)
    items = []
    for label, value in rows:
        width = max(2.0, 100.0 * value / max_value)
        items.append(
            f"""
            <div class="bar-row">
              <div class="bar-label" title="{esc(label)}">{esc(label)}</div>
              <div class="bar-track"><div class="bar-fill" style="width:{width:.2f}%"></div></div>
              <div class="bar-value">{fmt_int(value)} <span>{pct(value, total_value):.1f}%</span></div>
            </div>
            """
        )
    return "\n".join(items)


def month_key(value: datetime | date) -> str:
    return f"{value.year}-{value.month:02d}"


def svg_line_chart(points: list[tuple[str, int]], width: int = 980, height: int = 260) -> str:
    if not points:
        return ""
    margin_l, margin_r, margin_t, margin_b = 48, 16, 18, 40
    plot_w = width - margin_l - margin_r
    plot_h = height - margin_t - margin_b
    max_value = max(value for _, value in points) or 1
    step = plot_w / max(1, len(points) - 1)

    coords = []
    for i, (_, value) in enumerate(points):
        x = margin_l + i * step
        y = margin_t + plot_h - (value / max_value) * plot_h
        coords.append((x, y, value))

    path = " ".join(("M" if i == 0 else "L") + f"{x:.1f},{y:.1f}" for i, (x, y, _) in enumerate(coords))
    area = f"{path} L {coords[-1][0]:.1f},{margin_t + plot_h:.1f} L {coords[0][0]:.1f},{margin_t + plot_h:.1f} Z"
    y_ticks = []
    for tick in range(5):
        value = max_value * tick / 4
        y = margin_t + plot_h - (value / max_value) * plot_h
        y_ticks.append(
            f'<line x1="{margin_l}" y1="{y:.1f}" x2="{width - margin_r}" y2="{y:.1f}" class="chart-grid"/>'
            f'<text x="{margin_l - 8}" y="{y + 4:.1f}" text-anchor="end" class="axis">{fmt_int(round(value))}</text>'
        )

    label_every = max(1, math.ceil(len(points) / 10))
    x_labels = []
    for i, (label, _) in enumerate(points):
        if i % label_every == 0 or i == len(points) - 1:
            x = margin_l + i * step
            x_labels.append(f'<text x="{x:.1f}" y="{height - 12}" text-anchor="middle" class="axis">{esc(label)}</text>')

    dots = []
    for x, y, value in coords:
        dots.append(f'<circle cx="{x:.1f}" cy="{y:.1f}" r="2.7"><title>{fmt_int(value)} listens</title></circle>')

    return f"""
    <svg class="line-chart" viewBox="0 0 {width} {height}" role="img" aria-label="Listening trend chart">
      <path d="{area}" class="area"></path>
      {''.join(y_ticks)}
      <path d="{path}" class="line"></path>
      {''.join(dots)}
      {''.join(x_labels)}
    </svg>
    """


def svg_year_bars(year_counts: Counter[int], width: int = 980, height: int = 260) -> str:
    if not year_counts:
        return ""
    years = list(range(min(year_counts), max(year_counts) + 1))
    max_value = max(year_counts.values()) or 1
    margin_l, margin_r, margin_t, margin_b = 48, 16, 18, 40
    plot_w = width - margin_l - margin_r
    plot_h = height - margin_t - margin_b
    gap = 8
    bar_w = max(10, (plot_w - gap * (len(years) - 1)) / len(years))
    bars = []
    for i, year in enumerate(years):
        value = year_counts[year]
        h = (value / max_value) * plot_h
        x = margin_l + i * (bar_w + gap)
        y = margin_t + plot_h - h
        bars.append(
            f'<rect x="{x:.1f}" y="{y:.1f}" width="{bar_w:.1f}" height="{h:.1f}" rx="4">'
            f'<title>{year}: {fmt_int(value)} listens</title></rect>'
        )
        bars.append(f'<text x="{x + bar_w / 2:.1f}" y="{height - 12}" text-anchor="middle" class="axis">{year}</text>')
    y_ticks = []
    for tick in range(5):
        value = max_value * tick / 4
        y = margin_t + plot_h - (value / max_value) * plot_h
        y_ticks.append(
            f'<line x1="{margin_l}" y1="{y:.1f}" x2="{width - margin_r}" y2="{y:.1f}" class="chart-grid"/>'
            f'<text x="{margin_l - 8}" y="{y + 4:.1f}" text-anchor="end" class="axis">{fmt_int(round(value))}</text>'
        )
    return f"""
    <svg class="bar-chart" viewBox="0 0 {width} {height}" role="img" aria-label="Yearly listens chart">
      {''.join(y_ticks)}
      {''.join(bars)}
    </svg>
    """


def svg_multi_series_chart(
    month_labels: list[str],
    series: list[tuple[str, list[int]]],
    width: int = 1180,
    height: int = 420,
) -> str:
    if not month_labels or not series:
        return ""

    margin_l, margin_r, margin_t, margin_b = 64, 24, 24, 100
    plot_w = width - margin_l - margin_r
    plot_h = height - margin_t - margin_b
    max_value = max((max(values) for _, values in series if values), default=1) or 1
    step = plot_w / max(1, len(month_labels) - 1)

    y_ticks = []
    for tick in range(5):
        value = max_value * tick / 4
        y = margin_t + plot_h - (value / max_value) * plot_h
        y_ticks.append(
            f'<line x1="{margin_l}" y1="{y:.1f}" x2="{width - margin_r}" y2="{y:.1f}" class="chart-grid"/>'
            f'<text x="{margin_l - 8}" y="{y + 4:.1f}" text-anchor="end" class="axis">{fmt_int(round(value))}</text>'
        )

    label_every = max(1, math.ceil(len(month_labels) / 12))
    x_labels = []
    for i, label in enumerate(month_labels):
        if i % label_every == 0 or i == len(month_labels) - 1:
            x = margin_l + i * step
            x_labels.append(
                f'<text x="{x:.1f}" y="{height - 70}" text-anchor="middle" class="axis" transform="rotate(-45 {x:.1f} {height - 70})">{esc(label)}</text>'
            )

    paths = []
    legend = []
    legend_rows = math.ceil(len(series) / 4)
    legend_start_y = height - legend_rows * 22 + 12
    for idx, (label, values) in enumerate(series):
        color = PALETTE[idx % len(PALETTE)]
        coords = []
        for i, value in enumerate(values):
            x = margin_l + i * step
            y = margin_t + plot_h - (value / max_value) * plot_h
            coords.append((x, y, value))
        path = " ".join(("M" if i == 0 else "L") + f"{x:.1f},{y:.1f}" for i, (x, y, _) in enumerate(coords))
        paths.append(f'<path d="{path}" style="stroke:{color}" class="series-line"><title>{esc(label)}</title></path>')
        for x, y, value in coords:
            if value:
                paths.append(f'<circle cx="{x:.1f}" cy="{y:.1f}" r="2.5" style="fill:{color}"><title>{esc(label)}: {fmt_int(value)} listens</title></circle>')
        legend_x = margin_l + (idx % 4) * 260
        legend_y = legend_start_y + (idx // 4) * 22
        legend.append(
            f'<rect x="{legend_x}" y="{legend_y - 10}" width="12" height="12" rx="2" style="fill:{color}"></rect>'
            f'<text x="{legend_x + 18}" y="{legend_y}" class="legend-label">{esc(label)}</text>'
        )

    return f"""
    <svg class="multi-chart" viewBox="0 0 {width} {height}" role="img" aria-label="Top items over time">
      {''.join(y_ticks)}
      {''.join(paths)}
      {''.join(x_labels)}
      {''.join(legend)}
    </svg>
    """


def top_n_month_heatmap(
    labels: list[str],
    month_labels: list[str],
    monthly_counts: dict[str, Counter[str]],
) -> str:
    if not labels or not month_labels:
        return ""
    max_value = max((monthly_counts[month][label] for month in month_labels for label in labels), default=0)
    header = '<div class="timeline-corner"></div>' + "".join(f'<div class="timeline-month">{esc(month)}</div>' for month in month_labels)
    rows = [header]
    for label in labels:
        cells = [f'<div class="timeline-label" title="{esc(label)}">{esc(label)}</div>']
        for month in month_labels:
            value = monthly_counts[month][label]
            cells.append(
                f'<div class="timeline-cell" style="background:{heat_color(value, max_value)}" '
                f'title="{esc(label)} in {esc(month)}: {fmt_int(value)} listens"></div>'
            )
        rows.append("".join(cells))
    return f'<div class="timeline-heatmap" style="grid-template-columns: minmax(260px, 360px) repeat({len(month_labels)}, minmax(18px, 1fr));">{"".join(rows)}</div>'


def heat_color(value: int, max_value: int) -> str:
    if value <= 0 or max_value <= 0:
        return "#edf2f7"
    t = value / max_value
    if t < 0.25:
        return "#b7e4dc"
    if t < 0.50:
        return "#70c1b3"
    if t < 0.75:
        return "#247ba0"
    return "#184e77"


def weekday_hour_heatmap(counts: Counter[tuple[int, int]]) -> str:
    max_value = max(counts.values()) if counts else 0
    header = '<div class="heat-head"></div>' + "".join(f'<div class="heat-hour">{hour:02d}</div>' for hour in range(24))
    rows = [header]
    for weekday, name in enumerate(WEEKDAYS):
        cells = [f'<div class="heat-day">{name}</div>']
        for hour in range(24):
            value = counts[(weekday, hour)]
            cells.append(
                f'<div class="heat-cell" style="background:{heat_color(value, max_value)}" '
                f'title="{name} {hour:02d}:00 - {fmt_int(value)} listens"></div>'
            )
        rows.append("".join(cells))
    return f'<div class="heatmap">{"".join(rows)}</div>'


def month_heatmap(month_counts: Counter[tuple[int, int]]) -> str:
    if not month_counts:
        return ""
    years = list(range(min(year for year, _ in month_counts), max(year for year, _ in month_counts) + 1))
    max_value = max(month_counts.values()) if month_counts else 0
    header = '<div class="month-corner"></div>' + "".join(f'<div class="month-name">{month}</div>' for month in MONTHS)
    rows = [header]
    for year in years:
        cells = [f'<div class="month-year">{year}</div>']
        for month in range(1, 13):
            value = month_counts[(year, month)]
            cells.append(
                f'<div class="month-cell" style="background:{heat_color(value, max_value)}" '
                f'title="{calendar.month_name[month]} {year}: {fmt_int(value)} listens"></div>'
            )
        rows.append("".join(cells))
    return f'<div class="monthmap">{"".join(rows)}</div>'


def longest_streak(listen_days: set[date]) -> tuple[int, date | None, date | None]:
    if not listen_days:
        return 0, None, None
    best_len = current_len = 1
    best_start = current_start = min(listen_days)
    best_end = current_start
    previous = current_start
    for day in sorted(listen_days)[1:]:
        if day == previous + timedelta(days=1):
            current_len += 1
        else:
            if current_len > best_len:
                best_len = current_len
                best_start = current_start
                best_end = previous
            current_start = day
            current_len = 1
        previous = day
    if current_len > best_len:
        best_len = current_len
        best_start = current_start
        best_end = previous
    return best_len, best_start, best_end


def table(rows: list[list[object]], headings: list[str], class_name: str = "") -> str:
    head = "".join(f"<th>{esc(heading)}</th>" for heading in headings)
    body = []
    for row in rows:
        body.append("<tr>" + "".join(f"<td>{cell}</td>" for cell in row) + "</tr>")
    class_attr = f' class="{esc(class_name)}"' if class_name else ""
    return f"<table{class_attr}><thead><tr>{head}</tr></thead><tbody>{''.join(body)}</tbody></table>"


def make_report(xml_path: Path, output_path: Path, top_n: int = 8) -> None:
    top_n = max(1, top_n)
    tracks, listens = parse_archive(xml_path)
    if not listens:
        raise SystemExit(f"No listens found in {xml_path}")

    total_listens = len(listens)
    unique_tracks = len(tracks)
    artist_counts: Counter[str] = Counter()
    album_counts: Counter[str] = Counter()
    track_counts: Counter[str] = Counter()
    year_counts: Counter[int] = Counter()
    month_counts: Counter[tuple[int, int]] = Counter()
    month_series: Counter[str] = Counter()
    day_counts: Counter[date] = Counter()
    weekday_counts: Counter[str] = Counter()
    hour_counts: Counter[int] = Counter()
    weekday_hour_counts: Counter[tuple[int, int]] = Counter()
    monthly_artist_counts: dict[str, Counter[str]] = defaultdict(Counter)
    monthly_track_counts: dict[str, Counter[str]] = defaultdict(Counter)

    for listened_at, artist, title, album in listens:
        month = month_key(listened_at)
        track_label = f"{title} - {artist}"
        artist_counts[artist] += 1
        album_counts[f"{album} - {artist}"] += 1
        track_counts[track_label] += 1
        year_counts[listened_at.year] += 1
        month_counts[(listened_at.year, listened_at.month)] += 1
        month_series[month] += 1
        day_counts[listened_at.date()] += 1
        weekday_counts[WEEKDAYS[listened_at.weekday()]] += 1
        hour_counts[listened_at.hour] += 1
        weekday_hour_counts[(listened_at.weekday(), listened_at.hour)] += 1
        monthly_artist_counts[month][artist] += 1
        monthly_track_counts[month][track_label] += 1

    first_dt = listens[0][0]
    last_dt = listens[-1][0]
    span_days = max(1, (last_dt.date() - first_dt.date()).days + 1)
    active_days = len(day_counts)
    avg_per_active_day = total_listens / active_days
    median_active_day = statistics.median(day_counts.values())
    streak_len, streak_start, streak_end = longest_streak(set(day_counts))

    one_listen_tracks = sum(1 for track in tracks if track.listens == 1)
    repeat_tracks = unique_tracks - one_listen_tracks
    one_listen_artists = sum(1 for _, value in artist_counts.items() if value == 1)
    top_artist, top_artist_value = artist_counts.most_common(1)[0]
    top_track, top_track_value = track_counts.most_common(1)[0]
    peak_day, peak_day_value = day_counts.most_common(1)[0]
    peak_hour, peak_hour_value = hour_counts.most_common(1)[0]
    peak_weekday, peak_weekday_value = weekday_counts.most_common(1)[0]
    best_year, best_year_value = year_counts.most_common(1)[0]

    monthly_points = []
    month_labels = []
    cursor = date(first_dt.year, first_dt.month, 1)
    end_month = date(last_dt.year, last_dt.month, 1)
    while cursor <= end_month:
        key = month_key(cursor)
        month_labels.append(key)
        monthly_points.append((key, month_series[key]))
        if cursor.month == 12:
            cursor = date(cursor.year + 1, 1, 1)
        else:
            cursor = date(cursor.year, cursor.month + 1, 1)

    top_days = [
        [esc(day.isoformat()), fmt_int(value), esc(calendar.day_name[day.weekday()])]
        for day, value in day_counts.most_common(10)
    ]

    yearly_top_rows = []
    yearly_artist_counts: dict[int, Counter[str]] = defaultdict(Counter)
    yearly_track_counts: dict[int, Counter[str]] = defaultdict(Counter)
    for listened_at, artist, title, _ in listens:
        yearly_artist_counts[listened_at.year][artist] += 1
        yearly_track_counts[listened_at.year][f"{title} - {artist}"] += 1
    for year in sorted(yearly_artist_counts):
        artist, value = yearly_artist_counts[year].most_common(1)[0]
        yearly_top_rows.append([year, esc(artist), fmt_int(value), f"{pct(value, year_counts[year]):.1f}%"])

    yearly_top_track_rows = []
    for year in sorted(yearly_track_counts):
        track, value = yearly_track_counts[year].most_common(1)[0]
        yearly_top_track_rows.append([year, esc(track), fmt_int(value), f"{pct(value, year_counts[year]):.1f}%"])

    monthly_top_artist_rows = []
    monthly_top_track_rows = []
    for month in month_labels:
        total = month_series[month]
        if monthly_artist_counts[month]:
            artist, value = monthly_artist_counts[month].most_common(1)[0]
            monthly_top_artist_rows.append([esc(month), esc(artist), fmt_int(value), f"{pct(value, total):.1f}%"])
        if monthly_track_counts[month]:
            track, value = monthly_track_counts[month].most_common(1)[0]
            monthly_top_track_rows.append([esc(month), esc(track), fmt_int(value), f"{pct(value, total):.1f}%"])

    top_artist_labels = [label for label, _ in artist_counts.most_common(top_n)]
    top_track_labels = [label for label, _ in track_counts.most_common(top_n)]
    top_artist_series = [
        (label, [monthly_artist_counts[month][label] for month in month_labels])
        for label in top_artist_labels
    ]
    top_track_series = [
        (label, [monthly_track_counts[month][label] for month in month_labels])
        for label in top_track_labels
    ]

    rediscovered_tracks = sorted(
        (track for track in tracks if track.listens >= 2),
        key=lambda track: ((track.last_seen - track.first_seen).days, track.listens),
        reverse=True,
    )[:15]
    rediscovery_rows = [
        [
            esc(f"{track.title} - {track.artist}"),
            fmt_int(track.listens),
            f"{(track.last_seen - track.first_seen).days} days",
            esc(track.first_seen.date().isoformat()),
            esc(track.last_seen.date().isoformat()),
        ]
        for track in rediscovered_tracks
    ]

    hour_rows = [(f"{hour:02d}:00", hour_counts[hour]) for hour in range(24)]
    weekday_rows = [(day, weekday_counts[day]) for day in WEEKDAYS]

    generated = datetime.now().strftime("%Y-%m-%d %H:%M")
    top_artist_share = pct(top_artist_value, total_listens)
    repeat_share = pct(repeat_tracks, unique_tracks)
    active_share = pct(active_days, span_days)

    css = """
    :root {
      color-scheme: light;
      --bg: #f7fafc;
      --panel: #ffffff;
      --ink: #16202a;
      --muted: #657384;
      --line: #d8e0e8;
      --accent: #247ba0;
      --accent-2: #70c1b3;
      --warn: #f3b61f;
      --hot: #c44536;
    }
    * { box-sizing: border-box; }
    body {
      margin: 0;
      font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      background: var(--bg);
      color: var(--ink);
      line-height: 1.45;
    }
    main { max-width: 1440px; margin: 0 auto; padding: 28px 20px 52px; }
    header { display: flex; justify-content: space-between; gap: 24px; align-items: end; margin-bottom: 22px; }
    h1 { margin: 0; font-size: clamp(30px, 5vw, 54px); line-height: 1; letter-spacing: 0; }
    h2 { margin: 0 0 14px; font-size: 21px; }
    h3 { margin: 0 0 10px; font-size: 15px; color: var(--muted); font-weight: 700; text-transform: uppercase; letter-spacing: .04em; }
    p { margin: 0; }
    .muted { color: var(--muted); }
    .source { text-align: right; min-width: 220px; }
    .layout { display: grid; grid-template-columns: repeat(12, 1fr); gap: 16px; }
    section, .card {
      background: var(--panel);
      border: 1px solid var(--line);
      border-radius: 8px;
      padding: 18px;
      box-shadow: 0 12px 30px rgba(22, 32, 42, .06);
    }
    .span-12 { grid-column: span 12; }
    .span-8 { grid-column: span 8; }
    .span-6 { grid-column: span 6; }
    .span-4 { grid-column: span 4; }
    .kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
    .kpi { min-height: 112px; display: flex; flex-direction: column; justify-content: space-between; }
    .kpi .value { font-size: 34px; line-height: 1; font-weight: 800; }
    .kpi .label { color: var(--muted); font-size: 14px; }
    .insights { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
    .insight strong { display: block; font-size: 24px; line-height: 1.1; margin-bottom: 8px; }
    .bar-row { display: grid; grid-template-columns: minmax(260px, 3.2fr) minmax(160px, 2fr) 100px; gap: 12px; align-items: center; margin: 9px 0; }
    .bar-label { overflow-wrap: anywhere; font-size: 14px; }
    .bar-track { height: 12px; background: #e7edf2; border-radius: 999px; overflow: hidden; }
    .bar-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent-2)); border-radius: 999px; }
    .bar-value { text-align: right; font-variant-numeric: tabular-nums; font-size: 13px; }
    .bar-value span { color: var(--muted); }
    svg { width: 100%; height: auto; display: block; }
    .line-chart .area { fill: rgba(112, 193, 179, .22); }
    .line-chart .line { fill: none; stroke: var(--accent); stroke-width: 3; stroke-linejoin: round; stroke-linecap: round; }
    .line-chart circle { fill: var(--hot); }
    .bar-chart rect { fill: var(--accent); }
    .multi-chart .series-line { fill: none; stroke-width: 2.8; stroke-linejoin: round; stroke-linecap: round; }
    .chart-grid { stroke: #dce4eb; stroke-width: 1; }
    .axis { fill: var(--muted); font-size: 12px; }
    .legend-label { fill: var(--ink); font-size: 12px; }
    .heatmap { display: grid; grid-template-columns: 42px repeat(24, minmax(18px, 1fr)); gap: 4px; align-items: center; overflow-x: auto; padding-bottom: 2px; }
    .heat-head, .heat-hour, .heat-day { color: var(--muted); font-size: 11px; text-align: center; }
    .heat-day { text-align: right; padding-right: 4px; }
    .heat-cell { min-width: 18px; aspect-ratio: 1 / 1; border-radius: 4px; border: 1px solid rgba(22, 32, 42, .06); }
    .monthmap { display: grid; grid-template-columns: 52px repeat(12, minmax(42px, 1fr)); gap: 5px; overflow-x: auto; }
    .month-corner, .month-name, .month-year { color: var(--muted); font-size: 12px; text-align: center; }
    .month-year { text-align: right; padding-right: 6px; }
    .month-cell { height: 22px; border-radius: 4px; border: 1px solid rgba(22, 32, 42, .06); }
    .timeline-heatmap { display: grid; gap: 4px; overflow-x: auto; align-items: center; padding-bottom: 4px; }
    .timeline-corner, .timeline-month { color: var(--muted); font-size: 11px; text-align: center; }
    .timeline-month { writing-mode: vertical-rl; transform: rotate(180deg); height: 62px; justify-self: center; }
    .timeline-label { font-size: 13px; overflow-wrap: anywhere; padding-right: 8px; }
    .timeline-cell { min-width: 18px; height: 22px; border-radius: 4px; border: 1px solid rgba(22, 32, 42, .06); }
    table { width: 100%; border-collapse: collapse; font-size: 14px; }
    th, td { padding: 9px 8px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
    th { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
    td:nth-child(n+2), th:nth-child(n+2) { text-align: right; }
    td:first-child, th:first-child { text-align: left; }
    .wide-table td:nth-child(2), .wide-table th:nth-child(2) { text-align: left; overflow-wrap: anywhere; }
    .table-scroll { max-height: 520px; overflow: auto; border: 1px solid var(--line); border-radius: 8px; }
    .table-scroll table { border: 0; }
    .table-scroll thead th { position: sticky; top: 0; background: var(--panel); z-index: 1; }
    .note { margin-top: 12px; color: var(--muted); font-size: 13px; }
    @media (max-width: 900px) {
      header { display: block; }
      .source { text-align: left; margin-top: 12px; }
      .span-8, .span-6, .span-4 { grid-column: span 12; }
      .kpis, .insights { grid-template-columns: repeat(2, 1fr); }
    }
    @media (max-width: 620px) {
      main { padding: 20px 12px 36px; }
      .kpis, .insights { grid-template-columns: 1fr; }
      .bar-row { grid-template-columns: 1fr 76px; }
      .bar-label { grid-column: 1; }
      .bar-track { grid-column: 1 / -1; grid-row: 2; }
      .bar-value { grid-column: 2; grid-row: 1; }
    }
    """

    html_doc = f"""<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Last.fm Listening Insights</title>
  <style>{css}</style>
</head>
<body>
<main>
  <header>
    <div>
      <h1>Last.fm Listening Insights</h1>
      <p class="muted">{esc(first_dt.date().isoformat())} to {esc(last_dt.date().isoformat())}</p>
    </div>
    <div class="source">
      <p><strong>{esc(xml_path.name)}</strong></p>
      <p class="muted">Generated {esc(generated)}</p>
    </div>
  </header>

  <div class="layout">
    <section class="span-12">
      <div class="kpis">
        <div class="kpi"><div class="value">{fmt_int(total_listens)}</div><div class="label">total scrobbles</div></div>
        <div class="kpi"><div class="value">{fmt_int(unique_tracks)}</div><div class="label">unique tracks</div></div>
        <div class="kpi"><div class="value">{fmt_int(len(artist_counts))}</div><div class="label">artists</div></div>
        <div class="kpi"><div class="value">{avg_per_active_day:.1f}</div><div class="label">listens per active day</div></div>
      </div>
    </section>

    <section class="span-12">
      <h2>What stands out</h2>
      <div class="insights">
        <div class="insight"><strong>{esc(top_artist)}</strong><p class="muted">Your top artist has {fmt_int(top_artist_value)} listens, {top_artist_share:.1f}% of the archive.</p></div>
        <div class="insight"><strong>{repeat_share:.1f}%</strong><p class="muted">of unique tracks were played more than once. {fmt_int(one_listen_tracks)} tracks appear only once.</p></div>
        <div class="insight"><strong>{fmt_int(streak_len)} days</strong><p class="muted">Longest daily listening streak{f", {streak_start} to {streak_end}" if streak_start and streak_end else ""}.</p></div>
        <div class="insight"><strong>{esc(str(best_year))}</strong><p class="muted">Most active year with {fmt_int(best_year_value)} listens.</p></div>
        <div class="insight"><strong>{esc(peak_weekday)} {peak_hour:02d}:00</strong><p class="muted">The busiest weekday and hour signals are {fmt_int(peak_weekday_value)} listens on {esc(peak_weekday)}s and {fmt_int(peak_hour_value)} listens around {peak_hour:02d}:00.</p></div>
        <div class="insight"><strong>{active_share:.1f}%</strong><p class="muted">of days in the archive had at least one listen; the median active day had {median_active_day:.0f} listens.</p></div>
      </div>
    </section>

    <section class="span-12">
      <h2>Monthly listening trend</h2>
      {svg_line_chart(monthly_points)}
    </section>

    <section class="span-12">
      <h2>Listens by year</h2>
      {svg_year_bars(year_counts)}
    </section>

    <section class="span-12">
      <h2>Top {top_n} artists over time</h2>
      {svg_multi_series_chart(month_labels, top_artist_series)}
    </section>

    <section class="span-12">
      <h2>Top {top_n} tracks over time</h2>
      {svg_multi_series_chart(month_labels, top_track_series)}
    </section>

    <section class="span-12">
      <h2>Top {top_n} artists month heatmap</h2>
      {top_n_month_heatmap(top_artist_labels, month_labels, monthly_artist_counts)}
      <p class="note">Each row is one of the all-time top artists; darker months mean more listens.</p>
    </section>

    <section class="span-12">
      <h2>Top {top_n} tracks month heatmap</h2>
      {top_n_month_heatmap(top_track_labels, month_labels, monthly_track_counts)}
      <p class="note">Each row is one of the all-time top tracks; darker months mean more listens.</p>
    </section>

    <section class="span-12">
      <h2>Top artists</h2>
      {html_bar_list(counter_top(artist_counts, 20), total_listens)}
    </section>

    <section class="span-12">
      <h2>Top tracks</h2>
      {html_bar_list(counter_top(track_counts, 20), total_listens)}
    </section>

    <section class="span-12">
      <h2>Top albums</h2>
      {html_bar_list(counter_top(album_counts, 20), total_listens)}
    </section>

    <section class="span-6">
      <h2>Listening by hour</h2>
      {html_bar_list(hour_rows, total_listens)}
    </section>

    <section class="span-12">
      <h2>Weekday and hour heatmap</h2>
      {weekday_hour_heatmap(weekday_hour_counts)}
      <p class="note">Darker cells mean more listening at that weekday and hour.</p>
    </section>

    <section class="span-12">
      <h2>Month-by-month intensity</h2>
      {month_heatmap(month_counts)}
    </section>

    <section class="span-6">
      <h2>Listening by weekday</h2>
      {html_bar_list(weekday_rows, total_listens)}
    </section>

    <section class="span-6">
      <h2>Most active days</h2>
      {table(top_days, ["Date", "Listens", "Weekday"])}
    </section>

    <section class="span-6">
      <h2>Top artist by year</h2>
      {table(yearly_top_rows, ["Year", "Artist", "Listens", "Share"], "wide-table")}
    </section>

    <section class="span-6">
      <h2>Top track by year</h2>
      {table(yearly_top_track_rows, ["Year", "Track", "Listens", "Share"], "wide-table")}
    </section>

    <section class="span-6">
      <h2>Top artist by month</h2>
      <div class="table-scroll">
        {table(monthly_top_artist_rows, ["Month", "Artist", "Listens", "Share"], "wide-table")}
      </div>
    </section>

    <section class="span-6">
      <h2>Top track by month</h2>
      <div class="table-scroll">
        {table(monthly_top_track_rows, ["Month", "Track", "Listens", "Share"], "wide-table")}
      </div>
    </section>

    <section class="span-6">
      <h2>Longest rediscoveries</h2>
      {table(rediscovery_rows, ["Track", "Listens", "Span", "First", "Latest"])}
      <p class="note">Sorted by the longest gap between first and latest listen among tracks with at least two listens.</p>
    </section>
  </div>
</main>
</body>
</html>
"""
    output_path.write_text(html_doc, encoding="utf-8")


def main() -> None:
    parser = argparse.ArgumentParser(description="Create a self-contained Last.fm insights HTML report.")
    parser.add_argument("xml", nargs="?", default="result.xml", help="Path to the Last.fm XML archive.")
    parser.add_argument("output", nargs="?", default="lastfm_insights.html", help="Path for the generated report.")
    parser.add_argument("--top-n", type=int, default=8, help="Number of top artists/tracks to show in combined timeline charts.")
    args = parser.parse_args()

    xml_path = Path(args.xml)
    output_path = Path(args.output)
    if not xml_path.exists():
        raise SystemExit(f"Input file not found: {xml_path}")

    make_report(xml_path, output_path, args.top_n)
    print(f"Wrote {output_path.resolve()}")


if __name__ == "__main__":
    main()
