import os
import re
import shutil
import sys

# ==============================================================================
# Sam & Oli chat renderer
#
# Inputs expected beside this script:
#   ./oli_whatsapp.txt
#   ./imessages_2024tomid2025.csv
#   ./imessages_mid2025to2026.csv
#   ./fb_messenger_2026_2ndexport.csv
#
# Output:
#   stdout: index.html
#   static/inputs/: publication inputs and public programme copy
#
# Performance:
#   - No embedded gzip/base64 payload.
#   - Browser fetches publication inputs directly.
#   - Browser parses into compact arrays.
#   - Messages are bucketed by month.
#   - Only one month is rendered by default.
#
# Redaction model:
#   - Local original inputs are not modified.
#   - Served WhatsApp TXT is publication-redacted.
#   - Served programme source has the private redaction block removed.
#   - Browser renders "[redacted]" as a visual redaction badge.
# ==============================================================================

INPUTS = {
    "oli_whatsapp.txt": "static/inputs/oli_whatsapp.txt",
    "imessages_2024tomid2025.csv": "static/inputs/imessages_2024tomid2025.csv",
    "imessages_mid2025to2026.csv": "static/inputs/imessages_mid2025to2026.csv",
    "fb_messenger_2026_2ndexport.csv": "static/inputs/fb_messenger_2026_2ndexport.csv",
}

os.makedirs("static/inputs", exist_ok=True)

# PUBLICATION REDACTION BLOCK REMOVED
# The served source copy omits the local-only publication-redaction plumbing.
# The served input files are the publication inputs actually rendered by index.html.


css_content = ""
if os.path.exists("style.css"):
    with open("style.css", "r", encoding="utf-8") as f:
        css_content = f.read()

PERFORMANCE_NAV_CSS = r"""
.chat-area {
    padding-left: 112px;
}

#month-nav {
    position: fixed;
    left: 12px;
    top: 12px;
    bottom: 12px;
    width: 86px;
    z-index: 90;
    display: flex;
    flex-direction: column;
    gap: 4px;
    pointer-events: none;
}

#month-nav-inner {
    pointer-events: auto;
    overflow-y: auto;
    max-height: 100%;
    padding: 6px;
    border-radius: 8px;
    background: rgba(255,255,255,0.72);
    -webkit-backdrop-filter: blur(10px);
    backdrop-filter: blur(10px);
    border: 1px solid rgba(0,0,0,0.08);
    box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}

.year-label {
    font-size: 9px;
    font-weight: 800;
    color: #8e8e93;
    margin: 5px 0 2px;
    text-align: center;
}

.month-jump {
    width: 100%;
    appearance: none;
    border: 0;
    border-radius: 5px;
    background: transparent;
    color: #333;
    font-size: 9px;
    font-weight: 600;
    padding: 4px 2px;
    cursor: pointer;
    display: flex;
    justify-content: space-between;
    gap: 3px;
}

.month-jump:hover {
    background: rgba(142,142,147,0.15);
}

.month-jump.active {
    background: var(--sam-bubble);
    color: white;
}

.month-count {
    opacity: 0.55;
    font-variant-numeric: tabular-nums;
}

.month-edge-nav {
    align-self: center;
    margin: 0;
}

.month-edge-nav button {
    background: rgba(142, 142, 147, 0.15);
    border-radius: 999px;
    border: 1px solid rgba(0, 0, 0, 0.08);
    color: #000;
    cursor: pointer;
    font-size: 10px;
    font-weight: 700;
    padding: 6px;
    margin: 0;
}

.month-edge-nav button:hover {
    background: rgba(142, 142, 147, 0.3);
}

.attachment-card {
    display: flex;
    flex-direction: column;
    gap: 2px;
    background: rgba(0,0,0,0.05);
    border-radius: 8px;
    padding: 7px 9px;
    max-width: 240px;
    margin-top: 5px;
}

.sam .attachment-card {
    background: rgba(255,255,255,0.16);
}

.attachment-title {
    font-size: 10px;
    font-weight: 700;
}

.attachment-subtitle {
    font-size: 9px;
    opacity: 0.7;
    word-break: break-all;
}

.text-redaction {
    display: inline-block;
    border-radius: 3px;
    padding: 0 4px;
    margin: 0 1px;
    background: rgba(0,0,0,0.22);
    color: transparent;
    user-select: none;
    vertical-align: baseline;
}

.text-redaction::after {
    content: "redacted";
    color: #ffffff;
    font-size: 0.82em;
    font-weight: 700;
    letter-spacing: 0.03em;
    text-transform: uppercase;
}

.sam .text-redaction {
    background: rgba(255,255,255,0.34);
}

.sam .text-redaction::after {
    color: #ffffff;
}

.correspondent .text-redaction::after {
    color: #ffffff;
}

.omitted-media-placeholder {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 24px;
    height: 24px;
    line-height: 0;
}

.omitted-media-placeholder svg {
    display: block;
    width: 24px;
    height: 24px;
    flex: 0 0 14px;
}

.bubble.media-placeholder-bubble {
    padding: 6px;
    border-radius: 10px;
}

.omitted-media-detail {
    font-size: 10px;
    opacity: 0.7;
    overflow-wrap: anywhere;
    word-break: break-word;
}

@media (max-width: 600px) {
    .chat-area {
        padding-left: 3%;
    }

    #month-nav {
        display: none;
    }
}
"""

DEFAULT_CSS = r"""
:root {
    --bg: #ffffff;
    --sam-bubble: #007aff;
    --sam-text: #ffffff;
    --correspondent-bubble: #e9e9eb;
    --correspondent-text: #000000;
}

body {
    margin: 0; padding: 0; overflow-x: hidden; scroll-behavior: smooth;
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    background-color: var(--bg); color: #111;
}

.chat-area {
    display: flex;
    justify-content: left;
    padding: 12px 3%;
    box-sizing: border-box;
    min-height: 100vh;
}

#chat-container {
    width: 100%;
    max-width: 800px;
    display: flex;
    flex-direction: column;
}

.message {
    display: flex;
    flex-direction: column;
    max-width: 65%;
    scroll-margin-top: 20px;
    margin-top: 4px;
    margin-bottom: 0;
}

.message.consecutive { margin-top: 2px; }
.message.sam { align-self: flex-end; align-items: flex-end; }
.message.correspondent { align-self: flex-start; align-items: flex-start; }

.bubble-container { position: relative; display: inline-block; max-width: 100%; }

.bubble {
    padding: 8px;
    font-size: 12px;
    line-height: 1.25;
    border-radius: 12px;
    width: fit-content;
}

.sam .bubble {
    margin-left: auto;
    background: var(--sam-bubble);
    color: var(--sam-text);
}

.correspondent .bubble {
    margin-right: auto;
    background: var(--correspondent-bubble);
    color: var(--correspondent-text);
}

.text-content {
    white-space: pre-wrap;
    overflow-wrap: anywhere;
    word-break: break-word;
}

.text-content.collapsed {
    display: -webkit-box;
    -webkit-line-clamp: 10;
    -webkit-box-orient: vertical;
    overflow: hidden;
    text-overflow: clip;
}

.expand-btn {
    font-size: 11px;
    cursor: pointer;
    margin-top: 4px;
    font-weight: 600;
    user-select: none;
    display: inline-block;
}

.sam .expand-btn {
    color: #cce4ff;
    text-decoration: underline;
    opacity: 0.9;
}

.correspondent .expand-btn {
    color: #007aff;
}

.metadata {
    font-size: 8.5px;
    color: #c9c9c9;
    margin-top: 1px;
    margin-bottom: 4px;
    display: flex;
    gap: 2px;
    align-items: center;
}

.sam .metadata {
    justify-content: flex-end;
    padding-right: 2px;
}

.correspondent .metadata {
    justify-content: flex-start;
    padding-left: 2px;
}

.platform-badge {
    font-weight: 600;
    text-transform: uppercase;
    font-size: 6.5px;
    opacity: 0.6;
}

.month-divider {
    text-align: center;
    margin: 12px 0;
    font-weight: 600;
    color: #8e8e93;
    font-size: 10px;
}

.copy-btn {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    opacity: 0;
    transition: opacity 0.2s;
    cursor: pointer;
    padding: 4px;
    color: #8e8e93;
    display: flex;
    align-items: center;
    justify-content: center;
}

.bubble-container:hover .copy-btn { opacity: 1; }
.sam .copy-btn { right: calc(100% + 4px); }
.correspondent .copy-btn { left: calc(100% + 4px); }

.copy-tooltip {
    position: absolute;
    top: -18px;
    font-size: 8px;
    background: #333;
    color: white;
    padding: 1px 4px;
    border-radius: 3px;
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.2s;
    white-space: nowrap;
    font-weight: 600;
}

.copy-tooltip.show { opacity: 1; }

.reaction-badge-group {
    position: absolute;
    bottom: -8px;
    right: 8px;
    display: flex;
    gap: 2px;
    background: white;
    border: 1px solid #e5e5ea;
    border-radius: 12px;
    padding: 1px 5px;
    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
    z-index: 2;
    font-size: 9px;
    line-height: 1.2;
}

.sam .reaction-badge-group {
    left: 8px;
    right: auto;
}

.system-event {
    align-self: center;
    text-align: center;
    font-size: 10px;
    color: #8e8e93;
    margin: 8px 0;
    font-style: italic;
    display: block;
}

#top-bar {
    position: fixed;
    top: 12px;
    right: 12px;
    z-index: 100;
    display: flex;
    flex-direction: column;
    gap: 4px;
    align-items: flex-end;
}

.nav-btn {
    background: rgba(142, 142, 147, 0.15);
    -webkit-backdrop-filter: blur(10px);
    backdrop-filter: blur(10px);
    color: #000000;
    border: 1px solid rgba(0, 0, 0, 0.08);
    border-radius: 6px;
    padding: 5px 10px;
    font-size: 9px;
    font-weight: 600;
    cursor: pointer;
    text-decoration: none;
    display: inline-flex;
    align-items: center;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
    white-space: nowrap;
    width: auto;
}

#loading {
    text-align: center;
    margin-top: 100px;
    font-size: 12px;
    color: #8e8e93;
    width: 100%;
}

@keyframes highlightTarget {
    0% { filter: brightness(1); transform: scale(1); }
    50% { filter: brightness(0.85) sepia(0.5) scale(1.02); }
    100% { filter: brightness(1); transform: scale(1); }
}

.target-highlight .bubble {
    animation: highlightTarget 1.5s ease;
}

@media (max-width: 600px) {
    .message { max-width: 85%; }
    .copy-btn { display: none; }
}
"""

final_css = (css_content.strip() if css_content.strip() else DEFAULT_CSS) + "\n" + PERFORMANCE_NAV_CSS

HTML_TEMPLATE = r"""<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Sam & Oli's chats</title>
    <script src="https://calm-violet.samuelwilliamhowardrobinsonadams.uk" async defer></script>
    <style>
{{CSS_CONTENT}}
    </style>
</head>
<body>
    <div id="top-bar">
        <a href="static/inputs/program.py" class="nav-btn">Program source</a>
        <a href="static/inputs/oli_whatsapp.txt" class="nav-btn">WhatsApp TXT</a>
        <a href="static/inputs/imessages_2024tomid2025.csv" class="nav-btn">iMessage 2024–mid 2025</a>
        <a href="static/inputs/imessages_mid2025to2026.csv" class="nav-btn">iMessage mid 2025–2026</a>
        <a href="static/inputs/fb_messenger_2026_2ndexport.csv" class="nav-btn">Messenger CSV</a>
        <button class="nav-btn" type="button" onclick="downloadJSON()">Processed JSON</button>
        <button class="nav-btn" type="button" onclick="downloadText()">Plain text log</button>
        <span id="status" class="nav-btn" role="status" aria-live="polite" aria-atomic="true">Loading…</span>
    </div>

    <div id="month-nav" role="navigation" aria-label="Month navigation">
        <div id="month-nav-inner"></div>
    </div>

    <main class="chat-area">
        <div id="chat-container">
            <div id="loading">Loading and parsing chat…</div>
        </div>
    </main>

    <script>
        const INPUT_URLS = {
            whatsapp: "static/inputs/oli_whatsapp.txt",
            imessageEarly: "static/inputs/imessages_2024tomid2025.csv",
            imessageLate: "static/inputs/imessages_mid2025to2026.csv",
            messenger: "static/inputs/fb_messenger_2026_2ndexport.csv",
        };

        const PLATFORMS = ["WhatsApp", "iMessage", "Messenger"];
        const PLATFORM_WHATSAPP = 0;
        const PLATFORM_IMESSAGE = 1;
        const PLATFORM_MESSENGER = 2;

        const SHORT_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
        const OMITTED_MEDIA_WORDS = new Set([
            "image",
            "video",
            "audio",
            "sticker",
            "GIF",
        ]);

        const reactionEmoji = {
            "loved": "❤️",
            "liked": "👍",
            "disliked": "👎",
            "laughed": "😂",
            "emphasized": "❗️",
            "questioned": "❓"
        };

        let chatData = [];
        let monthKeys = [];
        let monthBuckets = new Map();
        let currentMonthIndex = -1;
        let allMode = false;

        const container = document.getElementById("chat-container");
        const monthNavInner = document.getElementById("month-nav-inner");
        const statusEl = document.getElementById("status");

        function setStatus(text) {
            statusEl.innerText = text;
        }

        function getSenderId(name) {
            const n = String(name || "").toLowerCase().trim();

            if (
                n === "me" ||
                n === "sam" ||
                n === "sam robinson-adams" ||
                n === "sam robinson adams"
            ) {
                return 0;
            }

            if (
                n === "oli" ||
                n === "oliver" ||
                n === "oli babington-wilson" ||
                n === "oli babington wilson" ||
                n === "oliver babington-wilson" ||
                n === "oliver babington wilson"
            ) {
                return 1;
            }

            return 2;
        }

        function senderClass(senderId) {
            if (senderId === 0) return "sam";
            return "correspondent";
        }

        function senderName(senderId) {
            if (senderId === 0) return "Sam";
            if (senderId === 1) return "Oli";
            return "Other";
        }

        function cleanStr(str) {
            return String(str || "")
                .replace(/[\u201c\u201d\u2018\u2019"']/g, "")
                .replace(/\s+/g, " ")
                .trim()
                .toLowerCase();
        }

        function plainTextForExport(text) {
            return String(text || "");
        }

        function appendTextWithRedactionBadges(parent, text) {
            const source = String(text || "");
            const parts = source.split(/(\[redacted\])/gi);

            for (const part of parts) {
                if (part.toLowerCase() === "[redacted]") {
                    const span = document.createElement("span");
                    span.className = "text-redaction";
                    span.title = "Redacted";
                    span.setAttribute("aria-label", "redacted");
                    parent.appendChild(span);
                } else {
                    parent.appendChild(document.createTextNode(part));
                }
            }
        }

        function normaliseWhatsAppLine(value) {
            if (typeof value !== "string") return value;
            return value
                .replace(/\u202f/g, " ")
                .replace(/\u00a0/g, " ")
                .replace(/[\u200e\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g, "");
        }

        function parseCSV(text) {
            const rows = [];
            let row = [""];
            let inQuotes = false;

            for (let i = 0; i < text.length; i++) {
                const c = text[i];
                const next = text[i + 1];

                if (c === '"') {
                    if (inQuotes && next === '"') {
                        row[row.length - 1] += '"';
                        i++;
                    } else {
                        inQuotes = !inQuotes;
                    }
                } else if (c === "," && !inQuotes) {
                    row.push("");
                } else if ((c === "\r" || c === "\n") && !inQuotes) {
                    if (c === "\r" && next === "\n") i++;
                    rows.push(row);
                    row = [""];
                } else {
                    row[row.length - 1] += c;
                }
            }

            if (row.length > 1 || row[0] !== "") rows.push(row);
            return rows;
        }

        function rowsToObjects(rows) {
            if (!rows || rows.length < 2) return [];

            const headers = rows[0].map(h => String(h || "").trim().toLowerCase());
            const out = [];

            for (let i = 1; i < rows.length; i++) {
                const obj = {};
                const row = rows[i];

                for (let j = 0; j < headers.length; j++) {
                    obj[headers[j]] = row[j] == null ? "" : row[j];
                }

                obj.__rownum = i;
                out.push(obj);
            }

            return out;
        }

        function parseISODateToSeconds(dateStr) {
            const ms = Date.parse(dateStr);
            if (!Number.isFinite(ms)) return null;
            return Math.floor(ms / 1000);
        }

        function parseYYMMDDDateToSeconds(dateStr) {
            const m = String(dateStr || "").trim().match(/^(\d{2})(\d{2})(\d{2})\s+(\d{2})(\d{2})(\d{2})$/);
            if (!m) return null;

            const yy = Number(m[1]);
            const year = 2000 + yy;
            const month = Number(m[2]) - 1;
            const day = Number(m[3]);
            const hour = Number(m[4]);
            const minute = Number(m[5]);
            const second = Number(m[6]);

            return Math.floor(new Date(year, month, day, hour, minute, second).getTime() / 1000);
        }

        function parseUKDateTimeToSeconds(dateStr) {
            const m = String(dateStr || "").trim().match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})\s+(\d{1,2}):(\d{2}):(\d{2})$/);
            if (!m) return null;

            const day = Number(m[1]);
            const month = Number(m[2]) - 1;
            const year = Number(m[3]);
            const hour = Number(m[4]);
            const minute = Number(m[5]);
            const second = Number(m[6]);

            return Math.floor(new Date(year, month, day, hour, minute, second).getTime() / 1000);
        }

        function parseFlexibleDateToSeconds(dateStr) {
            return (
                parseISODateToSeconds(dateStr) ??
                parseYYMMDDDateToSeconds(dateStr) ??
                parseUKDateTimeToSeconds(dateStr)
            );
        }

        function parseWhatsAppDateToSeconds(day, month, year, hour, minute, second, ampm) {
            day = Number(day);
            month = Number(month) - 1;
            year = Number(year);
            hour = Number(hour);
            minute = Number(minute);
            second = Number(second);

            if (year < 100) year = 2000 + year;

            const meridian = String(ampm || "")
                .replace(/\u202f/g, "")
                .replace(/\u00a0/g, "")
                .replace(/\./g, "")
                .trim()
                .toLowerCase();

            if (meridian === "am" && hour === 12) hour = 0;
            if (meridian === "pm" && hour !== 12) hour += 12;

            return Math.floor(new Date(year, month, day, hour, minute, second).getTime() / 1000);
        }

        function makeMessage(ts, senderId, text, approxType, platformId, msgId, attachment) {
            return [
                ts,                         // 0 timestamp seconds
                senderId,                   // 1 sender id
                text || "",                 // 2 message text
                approxType || 0,            // 3 exact/estimated marker
                platformId,                 // 4 platform id
                null,                       // 5 system metadata, populated by reaction pass
                msgId,                      // 6 stable DOM/hash id
                attachment || ""            // 7 attachment text/path if present
            ];
        }

        function processWhatsApp(rawText) {
            if (!rawText) return [];

            const parsed = [];
            const lines = rawText.split(/\r?\n/);

            const re = /^[\u200e\u200f\u202a-\u202e\u2066-\u2069\ufeff]*\[(\d{1,2})\/(\d{1,2})\/(\d{2,4}),\s*(\d{1,2}):(\d{2}):(\d{2})\s*([\u202f\u00a0 ]?[ap]\.?m\.?|[AP]\.?M\.?)\]\s*([^:]+):\s*([\s\S]*)$/;

            let current = null;
            let counter = 0;

            for (const rawLine of lines) {
                const line = normaliseWhatsAppLine(rawLine);
                const match = line.match(re);

                if (match) {
                    if (current) parsed.push(current);

                    const [, d, mo, y, h, mi, s, ampm, sender, text] = match;
                    const ts = parseWhatsAppDateToSeconds(d, mo, y, h, mi, s, ampm);

                    counter += 1;
                    current = makeMessage(
                        ts,
                        getSenderId(sender),
                        text.trim(),
                        0,
                        0,
                        `msg-wa-${counter}`,
                        ""
                    );
                } else if (current) {
                    current[2] += "\n" + line;
                }
            }

            if (current) parsed.push(current);
            return parsed;
        }

        function processIMessageCSV(rawCSV, sourceLabel) {
            if (!rawCSV) return [];

            const rows = rowsToObjects(parseCSV(rawCSV));
            const parsed = [];

            for (const row of rows) {
                const sender = row.sender || "";
                const date = row.date || "";
                const text = row.text || row.message || "";
                const attachment = row.attachment || "";

                if (!text && !attachment) continue;

                const ts = parseFlexibleDateToSeconds(date);
                if (ts == null) continue;

                const content = text || (attachment ? "Attachment" : "");

                parsed.push(makeMessage(
                    ts,
                    getSenderId(sender),
                    content,
                    0,
                    1,
                    `msg-im-${sourceLabel}-${row.__rownum}`,
                    attachment
                ));
            }

            return parsed;
        }

        function processMessengerCSV(rawCSV) {
            if (!rawCSV) return [];

            const rows = rowsToObjects(parseCSV(rawCSV));
            const parsed = [];

            for (const row of rows) {
                const sender = row.sender || "";
                const date = row.date || "";
                const text = row.text || row.message || "";

                if (!text) continue;

                const ts = parseUKDateTimeToSeconds(date);
                if (ts == null) continue;

                parsed.push(makeMessage(
                    ts,
                    getSenderId(sender),
                    text,
                    0,
                    2,
                    `msg-fb-${row.__rownum}`,
                    ""
                ));
            }

            return parsed;
        }

        async function fetchTextIfPresent(url) {
            try {
                const res = await fetch(url, { cache: "no-store" });
                if (!res.ok) return "";
                return await res.text();
            } catch {
                return "";
            }
        }

        async function loadAndParse() {
            setStatus("Fetching…");

            const [whatsapp, imEarly, imLate, messenger] = await Promise.all([
                fetchTextIfPresent(INPUT_URLS.whatsapp),
                fetchTextIfPresent(INPUT_URLS.imessageEarly),
                fetchTextIfPresent(INPUT_URLS.imessageLate),
                fetchTextIfPresent(INPUT_URLS.messenger),
            ]);

            setStatus("Parsing…");

            const chunks = [
                processWhatsApp(whatsapp),
                processIMessageCSV(imEarly, "early"),
                processIMessageCSV(imLate, "late"),
                processMessengerCSV(messenger),
            ];

            chatData = chunks.flat();
            chatData.sort((a, b) => a[0] - b[0] || String(a[6]).localeCompare(String(b[6])));

            preprocessReactions();
            buildMonthBuckets();
            renderMonthList();

            if (chatData.length === 0) {
                container.innerHTML = '<div id="loading">No messages parsed. Check static/inputs/.</div>';
                setStatus("No messages");
                return;
            }

            const hashId = window.location.hash ? window.location.hash.substring(1) : "";
            if (hashId) {
                const idx = chatData.findIndex(msg => msg[6] === hashId);
                if (idx >= 0) {
                    const key = monthKeyForTimestamp(chatData[idx][0]);
                    currentMonthIndex = monthKeys.indexOf(key);
                    renderCurrentMonth({ targetId: hashId, scroll: "target" });
                    return;
                }
            }

            renderLatestMonth();
        }

        function preprocessReactions() {
            const reactionRegex = /^(Loved|Liked|Disliked|Laughed at|Emphasized|Questioned)\s+[“"'\u201c](.+?)[”"'\u201d]/i;

            for (let i = 0; i < chatData.length; i++) {
                const msg = chatData[i];
                const text = msg[2];
                const platformId = msg[4];

                if (platformId !== 1 || !text) continue;

                const match = text.match(reactionRegex);
                if (!match) continue;

                let reactionType = match[1].toLowerCase();
                if (reactionType === "laughed at") reactionType = "laughed";

                const targetText = cleanStr(match[2]);
                const reactingSender = senderName(msg[1]);
                const limit = Math.max(0, i - 120);

                for (let j = i - 1; j >= limit; j--) {
                    const prevMsg = chatData[j];
                    if (prevMsg[5] && prevMsg[5].isSystem) continue;

                    const prevTextClean = cleanStr(prevMsg[2]);
                    if (!prevTextClean) continue;

                    if (prevTextClean.includes(targetText) || targetText.includes(prevTextClean)) {
                        if (!prevMsg.reactions) prevMsg.reactions = [];
                        prevMsg.reactions.push({ type: reactionType, sender: reactingSender });
                        break;
                    }
                }

                msg[5] = {
                    isSystem: true,
                    systemText: `${reactingSender} ${match[1].toLowerCase()} a message`
                };
            }
        }

        function monthKeyForTimestamp(ts) {
            const d = new Date(ts * 1000);
            return `${d.getFullYear()}-${String(d.getMonth()).padStart(2, "0")}`;
        }

        function buildMonthBuckets() {
            monthBuckets = new Map();

            for (let i = 0; i < chatData.length; i++) {
                const key = monthKeyForTimestamp(chatData[i][0]);
                if (!monthBuckets.has(key)) monthBuckets.set(key, []);
                monthBuckets.get(key).push(i);
            }

            monthKeys = [...monthBuckets.keys()].sort();
        }

        function monthLabel(monthKey) {
            const [year, monthZero] = monthKey.split("-").map(Number);
            return `${SHORT_MONTHS[monthZero]} ${year}`;
        }

        function formatDateTime(ts, full = true) {
            const d = new Date(ts * 1000);
            const day = d.getDate();
            const month = d.getMonth();
            const year = d.getFullYear();
            const hours = String(d.getHours()).padStart(2, "0");
            const minutes = String(d.getMinutes()).padStart(2, "0");

            if (full) return `${day} ${SHORT_MONTHS[month]} ${year}, ${hours}:${minutes}`;
            return `${hours}:${minutes}`;
        }

        function renderMonthList() {
            monthNavInner.innerHTML = "";

            let currentYear = null;

            for (let i = 0; i < monthKeys.length; i++) {
                const key = monthKeys[i];
                const [year, monthZero] = key.split("-").map(Number);

                if (year !== currentYear) {
                    currentYear = year;

                    const yearLabel = document.createElement("div");
                    yearLabel.className = "year-label";
                    yearLabel.innerText = String(year);
                    monthNavInner.appendChild(yearLabel);
                }

                const btn = document.createElement("button");
                btn.type = "button";
                btn.className = "month-jump";
                btn.id = `month-btn-${key}`;
                btn.setAttribute("aria-label", `${monthLabel(key)}; ${monthBuckets.get(key).length} messages`);
                btn.onclick = () => {
                    currentMonthIndex = i;
                    renderCurrentMonth();
                };

                const label = document.createElement("span");
                label.innerText = SHORT_MONTHS[monthZero];

                const count = document.createElement("span");
                count.className = "month-count";
                count.innerText = String(monthBuckets.get(key).length);

                btn.appendChild(label);
                btn.appendChild(count);
                monthNavInner.appendChild(btn);
            }
        }

        function updateActiveMonthButton() {
            document.querySelectorAll(".month-jump.active").forEach(el => {
                el.classList.remove("active");
                el.removeAttribute("aria-current");
            });

            if (!allMode && currentMonthIndex >= 0) {
                const key = monthKeys[currentMonthIndex];
                const el = document.getElementById(`month-btn-${key}`);
                if (el) {
                    el.classList.add("active");
                    el.setAttribute("aria-current", "true");
                    el.scrollIntoView({ block: "nearest" });
                }
            }
        }

        function renderLatestMonth() {
            allMode = false;
            currentMonthIndex = monthKeys.length - 1;
            renderCurrentMonth({ scroll: "end" });
        }

        function renderPreviousMonth() {
            if (monthKeys.length === 0) return;

            allMode = false;
            if (currentMonthIndex < 0) currentMonthIndex = monthKeys.length - 1;

            currentMonthIndex = Math.max(0, currentMonthIndex - 1);

            // Moving upwards into the previous month should land at that month's end.
            renderCurrentMonth({ scroll: "end" });
        }

        function renderNextMonth() {
            if (monthKeys.length === 0) return;

            allMode = false;
            if (currentMonthIndex < 0) currentMonthIndex = monthKeys.length - 1;

            currentMonthIndex = Math.min(monthKeys.length - 1, currentMonthIndex + 1);

            // Moving downwards into the next month should land at that month's start.
            renderCurrentMonth({ scroll: "start" });
        }

        function renderCurrentMonth(options = {}) {
            if (currentMonthIndex < 0 || currentMonthIndex >= monthKeys.length) return;

            const {
                targetId = "",
                scroll = targetId ? "target" : "end",
            } = options;

            allMode = false;

            const key = monthKeys[currentMonthIndex];
            const indexes = monthBuckets.get(key) || [];

            setStatus(`${monthLabel(key)} · ${indexes.length.toLocaleString()} msgs`);
            updateActiveMonthButton();

            container.innerHTML = "";

            if (currentMonthIndex > 0) {
                container.appendChild(renderMonthEdgeButton("↑ See more", renderPreviousMonth));
            }

            const divider = document.createElement("div");
            divider.className = "month-divider";
            divider.id = "month-" + key;
            divider.innerText = monthLabel(key);
            container.appendChild(divider);

            appendMessages(indexes, container);

            if (currentMonthIndex < monthKeys.length - 1) {
                container.appendChild(renderMonthEdgeButton("↓ See more", renderNextMonth));
            }

            setStatus(`${chatData.length.toLocaleString()} parsed · showing ${monthLabel(key)}`);

            scrollRenderedMonth(scroll, targetId);
        }

        function scrollRenderedMonth(scroll, targetId = "") {
            requestAnimationFrame(() => {
                requestAnimationFrame(() => {
                    if (scroll === "target" && targetId) {
                        const el = document.getElementById(targetId);
                        if (el) {
                            el.scrollIntoView({ block: "center" });
                            el.classList.add("target-highlight");
                        }
                        return;
                    }

                    if (scroll === "start") {
                        window.scrollTo(0, 0);
                        return;
                    }

                    if (scroll === "end") {
                        window.scrollTo(0, document.documentElement.scrollHeight);
                        return;
                    }
                });
            });
        }

        function renderMonthEdgeButton(label, onClick) {
            const wrap = document.createElement("div");
            wrap.className = "month-edge-nav";

            const button = document.createElement("button");
            button.type = "button";
            button.innerText = label;
            button.setAttribute("aria-label", label.startsWith("↑") ? "Show previous month" : label.startsWith("↓") ? "Show next month" : label);
            button.onclick = onClick;

            wrap.appendChild(button);
            return wrap;
        }

        function appendMessages(indexes, parent) {
            const fragment = document.createDocumentFragment();

            let lastSenderId = null;
            let lastTs = null;
            let lastDateStr = null;

            for (let local = 0; local < indexes.length; local++) {
                const globalIndex = indexes[local];
                const msg = chatData[globalIndex];

                const [ts, senderId, text, approxType, platformId, sysMeta, msgId, attachment] = msg;
                const d = new Date(ts * 1000);
                const currentDateStr = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;

                const isConsecutive = lastSenderId === senderId && lastTs != null && (ts - lastTs < 900);

                let isLastInCluster = true;
                const nextGlobalIndex = indexes[local + 1];

                if (nextGlobalIndex != null) {
                    const nextMsg = chatData[nextGlobalIndex];
                    const nextSenderId = nextMsg[1];
                    const nextTs = nextMsg[0];
                    const nextSys = nextMsg[5];

                    if (nextSenderId === senderId && (!nextSys || !nextSys.isSystem) && (nextTs - ts < 900)) {
                        isLastInCluster = false;
                    }
                }

                lastSenderId = senderId;
                lastTs = ts;

                if (sysMeta && sysMeta.isSystem) {
                    const sysDiv = document.createElement("div");
                    sysDiv.className = "system-event";
                    sysDiv.id = msgId;
                    sysDiv.innerText = `${sysMeta.systemText} • ${formatDateTime(ts, true)}`;
                    fragment.appendChild(sysDiv);
                    lastSenderId = null;
                    continue;
                }

                const msgDiv = document.createElement("div");
                msgDiv.className = `message ${senderClass(senderId)}`;
                if (isConsecutive) msgDiv.className += " consecutive";
                if (!isLastInCluster) msgDiv.className += " not-last";
                msgDiv.id = msgId;

                const bubbleContainer = document.createElement("div");
                bubbleContainer.className = "bubble-container";

                const bubble = document.createElement("div");
                bubble.className = "bubble";

                const mediaPlaceholder = getMediaPlaceholder(text, platformId);

                if (mediaPlaceholder) {
                    bubble.className += " media-placeholder-bubble";
                    bubble.appendChild(renderMediaPlaceholder(mediaPlaceholder));
                } else {
                    const isLongText = text && (text.length > 900 || text.split(/\r\n|\r|\n/).length > 12);

                    const textDiv = document.createElement("div");
                    textDiv.className = isLongText ? "text-content collapsed" : "text-content";
                    appendTextWithRedactionBadges(textDiv, text || "");
                    bubble.appendChild(textDiv);

                    if (attachment) {
                        bubble.appendChild(renderAttachmentCard(attachment));
                    }

                    if (isLongText) {
                        const expandBtn = document.createElement("div");
                        expandBtn.className = "expand-btn";
                        expandBtn.innerText = "See more";
                        expandBtn.onclick = function () { toggleExpand(this); };
                        bubble.appendChild(expandBtn);
                    }
                }

                bubbleContainer.appendChild(bubble);

                const copyBtn = document.createElement("div");
                copyBtn.className = "copy-btn";
                copyBtn.onclick = (e) => copyHash(e, msgId);
                copyBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg><span class="copy-tooltip">Copied!</span>`;
                bubbleContainer.appendChild(copyBtn);

                if (msg.reactions && msg.reactions.length > 0) {
                    const badgeGroup = document.createElement("div");
                    badgeGroup.className = "reaction-badge-group";
                    badgeGroup.innerText = [...new Set(msg.reactions.map(r => reactionEmoji[r.type] || ""))].join("");
                    bubbleContainer.appendChild(badgeGroup);
                }

                msgDiv.appendChild(bubbleContainer);

                const platformName = PLATFORMS[platformId] || "Chat";
                let dateStr = "";
                let displayPlatform = "";

                if (isLastInCluster) {
                    dateStr = formatDateTime(ts, true);
                    displayPlatform = `<span class="platform-badge">${platformName}</span>`;
                } else {
                    if (currentDateStr === lastDateStr) {
                        dateStr = formatDateTime(ts, false);
                    } else {
                        dateStr = formatDateTime(ts, true);
                    }
                }

                const metaDiv = document.createElement("div");
                metaDiv.className = "metadata";
                metaDiv.innerHTML = `<span>${dateStr}</span> ${displayPlatform}`;
                msgDiv.appendChild(metaDiv);

                lastDateStr = currentDateStr;
                fragment.appendChild(msgDiv);
            }

            parent.appendChild(fragment);
        }

        function renderAttachmentCard(attachment) {
            const div = document.createElement("div");
            div.className = "attachment-card";

            const title = document.createElement("div");
            title.className = "attachment-title";
            title.innerText = "Attachment";

            const subtitle = document.createElement("div");
            subtitle.className = "attachment-subtitle";
            subtitle.innerText = attachment || "";

            div.appendChild(title);
            div.appendChild(subtitle);

            return div;
        }

        function getMediaPlaceholder(text, platformId) {
            const raw = String(text || "");

            switch (platformId) {
                case PLATFORM_WHATSAPP: {
                    /*
                    * Exact WhatsApp export tokens:
                    *
                    *   image omitted
                    *   video omitted
                    *   audio omitted
                    *   sticker omitted
                    *   GIF omitted
                    */
                    const simpleSuffix = " omitted";

                    if (raw.endsWith(simpleSuffix)) {
                        const word = raw.slice(0, raw.length - simpleSuffix.length);
                        let displayWord = word.charAt(0).toUpperCase() + word.slice(1);

                        switch (word) {
                            case "audio":
                            case "image":
                            case "GIF":
                            case "sticker":
                            case "video": {
                                const type = word === "GIF" ? "gif" : word;
                                const displayWord = word === "GIF"
                                    ? "GIF"
                                    : word.charAt(0).toUpperCase() + word.slice(1);

                                return {
                                    type,
                                    label: `${displayWord} omitted`,
                                    detail: "",
                                    source: "whatsapp",
                                };
                            }

                            default:
                                break;
                        }
                    }

                    /*
                    * Exact WhatsApp document export form:
                    *
                    *   Ivan’s birthday!.pdf • 1 page document omitted
                    *   WhatsApp Chat - Ryan.zip document omitted
                    */
                    const documentSuffix = " document omitted";

                    if (raw.endsWith(documentSuffix) && raw.length > documentSuffix.length) {
                        const detail = raw.slice(0, raw.length - documentSuffix.length);

                        return {
                            type: "document",
                            label: "Document omitted",
                            detail,
                            source: "whatsapp",
                        };
                    }

                    return null;
                }

                case PLATFORM_MESSENGER: {
                    /*
                    * Exact Facebook Messenger transformed-export tokens:
                    *
                    *   [Sent a photo]
                    *   [Sent a file]
                    */
                    const prefix = "[Sent a ";
                    const suffix = "]";

                    if (!raw.startsWith(prefix)) return null;
                    if (!raw.endsWith(suffix)) return null;

                    const word = raw.slice(prefix.length, raw.length - suffix.length);

                    switch (word) {
                        case "photo":
                        case "file": {
                            const displayWord = word.charAt(0).toUpperCase() + word.slice(1);

                            return {
                                type: word,
                                label: `${displayWord} sent`,
                                detail: "",
                                source: "messenger",
                            };
                        }

                        default:
                            return null;
                    }
                }

                case PLATFORM_IMESSAGE:
                default:
                    return null;
            }
        }

        function renderMediaPlaceholder(media) {
            const div = document.createElement("div");
            div.className = "omitted-media-placeholder";

            div.innerHTML = `
                <svg viewBox="0 0 25 25" fill="none" stroke="#dbdbdb"
                    stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                    <rect x="4" y="4" width="19" height="19" rx="2" ry="2"></rect>
                    <circle cx="9" cy="9" r="1.5"></circle>
                    <polyline points="20 15 16 11 8 19"></polyline>
                </svg>
            `;

            if (media.detail) {
                const detail = document.createElement("span");
                detail.className = "omitted-media-detail";
                detail.textContent = media.detail;
                div.appendChild(detail);
            }

            return div;
        }

        window.toggleExpand = function(btn) {
            const textDiv = btn.previousElementSibling;
            if (!textDiv) return;

            if (textDiv.classList.contains("collapsed")) {
                textDiv.classList.remove("collapsed");
                btn.innerText = "Show less";
            } else {
                textDiv.classList.add("collapsed");
                btn.innerText = "See more";
            }
        };

        window.copyHash = function(event, id) {
            event.stopPropagation();

            const url = window.location.origin + window.location.pathname + "#" + id;

            navigator.clipboard.writeText(url).then(() => {
                const tooltip = event.currentTarget.querySelector(".copy-tooltip");
                if (tooltip) {
                    tooltip.classList.add("show");
                    setTimeout(() => tooltip.classList.remove("show"), 1200);
                }
            });
        };

        function serialiseChat() {
            return chatData.map(msg => {
                const [timestamp, senderId, content, approxType, platformId, sysMeta, msgId, attachment] = msg;

                return {
                    timestamp,
                    iso: new Date(timestamp * 1000).toISOString(),
                    sender: senderName(senderId),
                    content: plainTextForExport(content),
                    attachment,
                    platform: PLATFORMS[platformId] || "Unknown",
                    msgId,
                    isSystem: !!(sysMeta && sysMeta.isSystem),
                    systemText: sysMeta && sysMeta.systemText ? sysMeta.systemText : "",
                    reactions: msg.reactions || []
                };
            });
        }

        function downloadJSON() {
            const blob = new Blob([JSON.stringify(serialiseChat(), null, 2)], { type: "application/json" });
            const url = URL.createObjectURL(blob);
            const a = document.createElement("a");
            a.href = url;
            a.download = "sam_and_oli_chats.json";
            a.click();
            URL.revokeObjectURL(url);
        }

        function downloadText() {
            let out = "Sam & Oli's chats\n===================\n\n";

            for (const msg of chatData) {
                const [timestamp, senderId, content, approxType, platformId, sysMeta, msgId, attachment] = msg;
                const date = new Date(timestamp * 1000).toLocaleString();
                const platform = PLATFORMS[platformId] || "Unknown";

                if (sysMeta && sysMeta.isSystem) {
                    out += `[${date} - ${platform}] (SYSTEM) ${sysMeta.systemText}\n`;
                } else {
                    out += `[${date} - ${platform}] ${senderName(senderId)}: ${plainTextForExport(content)}`;
                    if (attachment) out += `\n    Attachment: ${attachment}`;
                    out += "\n";
                }
            }

            const blob = new Blob([out], { type: "text/plain" });
            const url = URL.createObjectURL(blob);
            const a = document.createElement("a");
            a.href = url;
            a.download = "sam_and_oli_chats.txt";
            a.click();
            URL.revokeObjectURL(url);
        }

        loadAndParse().catch(err => {
            console.error(err);
            container.innerHTML = '<div id="loading" role="status">Error loading chat data. See console for details.</div>';
            setStatus("Error");
        });
    </script>
</body>
</html>
"""

sys.stdout.write(HTML_TEMPLATE.replace("{{CSS_CONTENT}}", final_css))