import subprocess
import sys
import os
import json
import re
import threading
import time
import random
import html
import tkinter as tk
from tkinter import ttk, messagebox, scrolledtext
import urllib3
import requests
from urllib.parse import urlparse, urljoin, unquote
from bs4 import BeautifulSoup
from concurrent.futures import ThreadPoolExecutor, as_completed
import undetected_chromedriver as uc
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

def _parse_chrome_major_from_version_text(text):
    m = re.search(r'(\d+)\.(\d+)\.(\d+)\.(\d+)', text)
    if not m:
        m = re.search(r'Chrome\s+(\d+)\.', text, re.I)
    return int(m.group(1)) if m else None

def get_chrome_major_version():
    try:
        if sys.platform == "win32":
            import winreg
            for root_path in (r"SOFTWARE\Google\Chrome", r"SOFTWARE\WOW6432Node\Google\Chrome"):
                try:
                    key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, root_path + r"\BLBeacon")
                    val = winreg.QueryValueEx(key, "version")[0]
                    major = _parse_chrome_major_from_version_text(val)
                    if major:
                        return major
                except Exception:
                    pass
        if sys.platform == "win32":
            candidates = [
                r"C:\Program Files\Google\Chrome\Application\chrome.exe",
                r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
                "chrome.exe", "GoogleChrome.exe", "chrome"
            ]
        elif sys.platform == "darwin":
            candidates = [
                "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
                "/Applications/Chromium.app/Contents/MacOS/Chromium",
                "google-chrome", "chromium"
            ]
        else:
            candidates = ["google-chrome", "google-chrome-stable", "chromium-browser", "chromium"]
        for cmd in candidates:
            try:
                if os.path.isabs(cmd) and not os.path.exists(cmd):
                    continue
                p = subprocess.run([cmd, "--version"], capture_output=True, text=True, check=False)
                major = _parse_chrome_major_from_version_text((p.stdout or "") + (p.stderr or ""))
                if major:
                    return major
            except Exception:
                pass
    except Exception:
        pass
    return None

def make_driver(PROFILE_DIR):
    options_uc = uc.ChromeOptions()
    options_uc.add_argument(f"--user-data-dir={PROFILE_DIR}")
    options_uc.add_argument("--disable-gpu")
    options_uc.add_argument("--disable-smooth-scrolling")
    options_uc.add_argument("--disable-backgrounding-occluded-windows")
    options_uc.add_argument("--disable-background-timer-throttling")
    options_uc.add_argument("--disable-background-networking")
    options_uc.add_argument("--disable-component-update")
    options_uc.add_argument("--disable-domain-reliability")
    options_uc.add_argument("--disable-sync")
    options_uc.add_argument("--disable-preconnect")
    options_uc.add_argument("--dns-prefetch-disable")
    options_uc.add_argument("--no-first-run")
    options_uc.add_argument("--no-default-browser-check")
    options_uc.add_argument("--autoplay-policy=user-gesture-required")
    prefs = {
        "profile.default_content_setting_values.notifications": 2,
        "profile.default_content_setting_values.geolocation": 2,
        "profile.default_content_setting_values.media_stream": 2,
    }
    options_uc.add_experimental_option("prefs", prefs)
    major = get_chrome_major_version()
    print(f"Detected Chrome major version: {major}")
    last_exc = None
    try:
        if major is not None:
            return uc.Chrome(options=options_uc, headless=False, use_subprocess=True, version_main=major)
        return uc.Chrome(options=options_uc, headless=False, use_subprocess=True)
    except Exception as e:
        last_exc = e
        m = re.search(r"only supports Chrome version\s+(\d+)", str(e), re.I)
        if m:
            try:
                return uc.Chrome(
                    options=options_uc,
                    headless=False,
                    use_subprocess=True,
                    version_main=int(m.group(1))
                )
            except Exception as e2:
                last_exc = e2
    raise last_exc

FALLBACK_URL = "https://soyjak.forum"
ATTACH_HREF_REGEX = re.compile(r'/(attachments|attachment)/', re.I)
COMMON_FILE_EXTS = {
    ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".mp4", ".webm",
    ".ogg", ".mp3", ".wav", ".m4a", ".pdf", ".zip", ".rar", ".7z",
    ".tar", ".gz", ".txt", ".csv", ".doc", ".docx", ".xls", ".xlsx",
    ".ppt", ".pptx", ".svg"
}

def sanitize_filename(url):
    parsed = urlparse(url)
    path = unquote(parsed.path)
    filename = os.path.basename(path)
    filename = re.sub(r'\?.*$', '', filename)
    filename = re.sub(r'[^\w\-_.]', '_', filename)
    if not filename or len(filename) > 200:
        filename = f"asset_{int(time.time())}_{random.randint(1000,9999)}"
    if "." not in filename:
        ext = os.path.splitext(path)[1].lower()
        if ext:
            filename += ext
    return filename

def normalize_url(url):
    if not url or url.startswith("data:"):
        return url
    if url.startswith("//"):
        return "https:" + url
    if url.startswith("/") or url.startswith("data/") or url.startswith("styles/") or url.startswith("attachments/"):
        return urljoin(BASE_DOMAIN, url)
    return url

def parse_srcset(value):
    if not value:
        return []
    result = []
    for part in (p.strip() for p in value.split(",") if p.strip()):
        tokens = part.split()
        result.append((tokens[0], tokens[1] if len(tokens) > 1 else None))
    return result

def pick_best_srcset_candidate(value):
    candidates = parse_srcset(value)
    if not candidates:
        return None

    def score(desc):
        if not desc:
            return 0
        m = re.match(r'(\d+(?:\.\d+)?)(x|w)$', desc)
        return float(m.group(1)) if m else 0

    return max(candidates, key=lambda x: score(x[1]))[0]

def setup_requests_session(driver):
    session = requests.Session()
    try:
        ua = driver.execute_script("return navigator.userAgent;")
    except Exception:
        ua = "Mozilla/5.0 (compatible; ThreadArchiver/1.0)"

    session.headers.update({
        "User-Agent": ua,
        "Referer": BASE_DOMAIN,
        "Accept": "*/*",
        "Accept-Language": "en-US,en;q=0.9",
        "Connection": "keep-alive"
    })

    for cookie in driver.get_cookies():
        name = cookie.get("name")
        if not name:
            continue
        session.cookies.set(
            name,
            cookie.get("value"),
            domain=(cookie.get("domain") or HOST).lstrip("."),
            path=cookie.get("path") or "/"
        )
    return session

def download_asset(session, url, target_folder, referer=None):
    original = url
    url = normalize_url(url)
    if not url or url.startswith("data:") or not url.startswith("http"):
        return original

    local_name = sanitize_filename(url)
    dest = os.path.join(target_folder, local_name)

    if os.path.exists(dest):
        return os.path.relpath(dest, OUTPUT_DIR).replace("\\", "/")

    headers = {"Referer": referer or BASE_DOMAIN}

    for attempt in range(MAX_RETRIES):
        try:
            resp = session.get(
                url,
                headers=headers,
                timeout=REQUEST_TIMEOUT,
                stream=True,
                verify=False
            )

            if resp.status_code == 200:
                os.makedirs(os.path.dirname(dest), exist_ok=True)
                with open(dest, "wb") as f:
                    for chunk in resp.iter_content(65536):
                        if chunk:
                            f.write(chunk)
                return os.path.relpath(dest, OUTPUT_DIR).replace("\\", "/")

            if resp.status_code in (403, 429):
                wait = BACKOFF_BASE ** attempt + random.random()
                print(f"    … HTTP {resp.status_code} retry {attempt}/{MAX_RETRIES} in {wait:.1f}s")
                time.sleep(wait)
                continue

            print(f"    ✗ HTTP {resp.status_code}: {original}")
            return original

        except Exception:
            if attempt >= MAX_RETRIES - 1:
                print(f"    ✗ Exception: {original}")
                return original
            wait = BACKOFF_BASE ** attempt + random.random()
            print(f"    … Exception, retry in {wait:.1f}s")
            time.sleep(wait)

    return original

def download_assets_parallel(session, urls, target_folder, referer=None):
    normalized = []
    seen = set()

    for url in urls:
        norm = normalize_url(url)
        if not norm or not norm.startswith("http") or norm in seen:
            continue
        seen.add(norm)
        normalized.append(norm)

    if not normalized:
        return {}

    results = {}
    workers = min(ASSET_WORKERS, len(normalized))

    with ThreadPoolExecutor(max_workers=workers, thread_name_prefix="asset") as executor:
        futures = {
            executor.submit(download_asset, session, url, target_folder, referer): url
            for url in normalized
        }

        for future in as_completed(futures):
            url = futures[future]
            try:
                results[url] = future.result()
            except Exception as e:
                print(f"    ✗ Download worker failed: {url} ({e})")
                results[url] = url

    return results

def collect_image_assets(clean_soup):
    assets = []

    for img in clean_soup.find_all("img"):
        candidates = []
        best = pick_best_srcset_candidate(img.get("srcset")) or pick_best_srcset_candidate(img.get("data-srcset"))

        for attr in ("src", "data-src"):
            value = normalize_url(img.get(attr))
            if value and value.startswith("http"):
                candidates.append(value)

        if best:
            best = normalize_url(best)
            if best and best.startswith("http"):
                candidates.append(best)

        assets.extend(candidates)

    return assets

def collect_media_assets(clean_soup):
    assets = []

    for media in clean_soup.find_all(["video", "audio"]):
        value = normalize_url(media.get("src"))
        if value and value.startswith("http"):
            assets.append(value)

        for source in media.find_all("source"):
            value = normalize_url(source.get("src"))
            if value and value.startswith("http"):
                assets.append(value)

    return assets

def collect_attachment_assets(clean_soup):
    assets = []

    for a in clean_soup.find_all("a", href=True):
        href = normalize_url(a["href"])
        if not href or not href.startswith("http"):
            continue

        path = urlparse(href).path.lower()
        ext = os.path.splitext(path)[1] if "." in path else ""

        if ATTACH_HREF_REGEX.search(path) or ext in COMMON_FILE_EXTS:
            assets.append(href)

    return assets

def collect_sprite_assets(clean_soup, session, referer):
    try:
        sprite_classes = set()

        for img in clean_soup.find_all("img"):
            for c in img.get("class") or []:
                if isinstance(c, str) and c.startswith("smilie--sprite") and c != "smilie--sprite":
                    sprite_classes.add(c)

        if not sprite_classes:
            return {}, {}

        css_urls = []

        for link in clean_soup.find_all("link", rel=True):
            if link.get("rel") and "stylesheet" in link.get("rel") and link.get("href"):
                css_url = normalize_url(link["href"])
                if css_url and css_url.startswith("http"):
                    css_urls.append(css_url)

        rule_re = re.compile(r"\.(smilie--sprite\d+)\s*\{([^}]*)\}", re.I | re.S)

        def prop(block, name):
            m = re.search(rf"{re.escape(name)}\s*:\s*([^;]+);", block, re.I)
            return m.group(1).strip() if m else None

        sprite_props = {}

        for css_url in dict.fromkeys(css_urls):
            try:
                resp = session.get(
                    css_url,
                    headers={"Referer": referer},
                    timeout=30,
                    verify=False
                )

                if resp.status_code != 200:
                    continue

                for m in rule_re.finditer(resp.text):
                    cls = m.group(1)

                    if cls not in sprite_classes:
                        continue

                    block = m.group(2)
                    bg = prop(block, "background-image")
                    bg_url = None

                    if bg:
                        u = re.search(
                            r"url\(\s*['\"]?([^'\")]+)['\"]?\s*\)",
                            bg,
                            re.I
                        )
                        if u:
                            bg_url = u.group(1).strip()

                    sprite_props[cls] = {
                        "background-image": bg_url,
                        "background-position": prop(block, "background-position"),
                        "background-repeat": prop(block, "background-repeat"),
                        "width": prop(block, "width"),
                        "height": prop(block, "height"),
                        "background-size": prop(block, "background-size")
                    }

            except Exception:
                pass

        sprite_urls = []

        for props in sprite_props.values():
            remote = normalize_url(props.get("background-image"))
            if remote and remote.startswith("http"):
                sprite_urls.append(remote)

        return sprite_props, dict.fromkeys(sprite_urls)

    except Exception:
        return {}, {}

def rewrite_images_and_media(clean_soup, asset_map):
    for img in clean_soup.find_all("img"):
        best = pick_best_srcset_candidate(img.get("srcset")) or pick_best_srcset_candidate(img.get("data-srcset"))
        primary = None

        for attr in ("src", "data-src"):
            value = normalize_url(img.get(attr))
            if value and value.startswith("http"):
                primary = value
                break

        if best:
            best = normalize_url(best)
            if best and best.startswith("http"):
                primary = best

        if primary:
            local = asset_map.get(primary, primary)

            if img.get("src") is not None:
                img["src"] = local
            if img.get("data-src") is not None:
                img["data-src"] = local
            if img.get("srcset") is not None:
                img["srcset"] = local
            if img.get("data-srcset") is not None:
                img["data-srcset"] = local

    for media in clean_soup.find_all(["video", "audio"]):
        if media.get("src"):
            src = normalize_url(media["src"])
            media["src"] = asset_map.get(src, src)

        for source in media.find_all("source"):
            if source.get("src"):
                src = normalize_url(source["src"])
                source["src"] = asset_map.get(src, src)

def rewrite_attachment_links(clean_soup, asset_map):
    for a in clean_soup.find_all("a", href=True):
        href = normalize_url(a["href"])

        if not href or not href.startswith("http"):
            continue

        path = urlparse(href).path.lower()
        ext = os.path.splitext(path)[1] if "." in path else ""

        if not (ATTACH_HREF_REGEX.search(path) or ext in COMMON_FILE_EXTS):
            continue

        local = asset_map.get(href, href)

        if local != href:
            a["href"] = local

    return clean_soup

def rewrite_sprite_styles(clean_soup, sprite_props, asset_map):
    if not sprite_props:
        return

    lines = []

    for cls, props in sprite_props.items():
        remote = normalize_url(props.get("background-image"))

        if not remote:
            continue

        local = asset_map.get(remote)

        if not local:
            continue

        line = f".{cls}{{background-image:url('{local}');"

        for p in (
            "background-position",
            "background-repeat",
            "width",
            "height",
            "background-size"
        ):
            if props.get(p):
                line += f"{p}:{props[p]};"

        if not props.get("background-repeat"):
            line += "background-repeat:no-repeat;"

        line += "display:inline-block;}"
        lines.append(line)

    if lines:
        style = clean_soup.new_tag("style")
        style.string = "\n".join(lines)
        (clean_soup.find("head") or clean_soup).append(style)

def extract_reactions(post):
    reactions_bar = post.select_one(".reactionsBar, .js-reactionsList")

    if not reactions_bar:
        return ""

    reaction_items = reactions_bar.select("li .reaction, .reactionSummary .reaction")
    reaction_details = []

    for item in reaction_items:
        react_span = item if item.name == "span" else item.find_parent("span")

        if not react_span:
            continue

        img = react_span.find("img")
        alt = (img.get("alt") or img.get("title")) if img else ""
        title = react_span.get("title") or alt

        if title:
            reaction_details.append(title)

    user_link = reactions_bar.select_one(".reactionsBar-link")
    users_text = user_link.get_text(strip=True) if user_link else ""

    if not users_text:
        bdis = reactions_bar.select("bdi")
        if bdis:
            users_text = ", ".join(bd.get_text(strip=True) for bd in bdis)

    if not reaction_details and not users_text:
        return ""

    display = (
        f"{users_text} reacted with: {' | '.join(reaction_details)}"
        if reaction_details
        else users_text
    )

    return f'<div class="reactions-bar"><strong>Reactions:</strong> {display}</div>'

def extract_post_date(post):
    time_tag = (
        post.select_one("time.u-dt")
        or post.select_one("time[itemprop='datePublished']")
    )

    if not time_tag:
        return "", "", ""

    date_text = time_tag.get("data-date", "").strip()
    time_text = time_tag.get("data-time", "").strip()

    if date_text and time_text:
        display = f"{date_text} at {time_text}"
    else:
        display = (
            time_tag.get("title", "").strip()
            or time_tag.get_text(" ", strip=True)
        )

    return (
        display,
        time_tag.get("datetime", "").strip(),
        date_text
    )

def extract_post_user_key(post):
    user_link = post.select_one(
        ".message-userDetails .message-name a.username[data-user-id],"
        ".message-name a.username[data-user-id],"
        "a.username[data-user-id]"
    )

    if user_link and user_link.get("data-user-id"):
        return str(user_link.get("data-user-id"))

    username_tag = (
        post.select_one(".message-userDetails .username")
        or post.find("a", class_="username")
        or post.find(class_="username")
    )

    username = username_tag.get_text(" ", strip=True) if username_tag else "Unknown"
    return "name:" + username.lower()

def extract_user_awards(post):
    badges = post.select_one(".featuredBadges")

    if not badges:
        return ""

    output = []

    for img in badges.find_all("img"):
        label = (
            img.get("alt")
            or img.get("title")
            or img.find_parent("a").get("title")
            if img.find_parent("a")
            else img.get("alt") or img.get("title")
        )

        label = (label or "").strip()

        if label:
            img["alt"] = label
            img["title"] = label

        output.append(str(img))

    return '<div class="badges">' + "".join(output) + "</div>" if output else ""

def extract_user_profiles(soup, existing, options):
    profiles = dict(existing)

    for post in soup.find_all("article", class_="message"):
        user_key = extract_post_user_key(post)

        username_tag = (
            post.select_one(".message-userDetails .username")
            or post.find("a", class_="username")
            or post.find(class_="username")
        )

        username = username_tag.get_text(" ", strip=True) if username_tag else "Unknown"

        profile = profiles.setdefault(
            user_key,
            {
                "id": user_key,
                "name": username,
                "signature": "",
                "signature_scraped": False,
                "title": "",
                "title_class": "",
                "title_scraped": False,
                "banners": [],
                "banners_scraped": False
            }
        )

        if username and profile.get("name") in (None, "", "Unknown"):
            profile["name"] = username

        details = post.select_one(".message-userDetails")

        if not details:
            continue

        if options.get("signatures") and not profile.get("signature_scraped"):
            signature = post.select_one("aside.message-signature")

            if signature:
                bb = signature.select_one(".bbWrapper")
                profile["signature"] = str(bb) if bb else str(signature)
            else:
                profile["signature"] = ""

            profile["signature_scraped"] = True

        if options.get("titles") and not profile.get("title_scraped"):
            title_wrap = details.select_one(".custom-title-color-")
            title_tag = details.select_one(".userTitle.message-userTitle")

            if title_tag:
                profile["title"] = title_tag.get_text(" ", strip=True)
                profile["title_class"] = " ".join(
                    title_wrap.get("class") or []
                ) if title_wrap else ""

            profile["title_scraped"] = True

        if options.get("banners") and not profile.get("banners_scraped"):
            banners = []

            for banner in details.select(".userBanner.message-userBanner, .userBanner"):
                clone = BeautifulSoup(str(banner), "html.parser").find()
                if clone:
                    for tag in clone.select(".userBanner-before,.userBanner-after"):
                        tag.decompose()
                    banners.append(str(clone))

            profile["banners"] = banners
            profile["banners_scraped"] = True

    return profiles

def profile_asset_urls(profiles):
    urls = []

    for profile in profiles.values():
        signature = profile.get("signature", "")

        if not signature:
            continue

        frag = BeautifulSoup(signature, "html.parser")
        urls.extend(collect_image_assets(frag))
        urls.extend(collect_media_assets(frag))
        urls.extend(collect_attachment_assets(frag))

    return list(dict.fromkeys(
        normalize_url(u)
        for u in urls
        if u and normalize_url(u) and normalize_url(u).startswith("http")
    ))

def rewrite_profile_assets(profiles, asset_map):
    for profile in profiles.values():
        signature = profile.get("signature", "")

        if not signature:
            continue

        frag = BeautifulSoup(signature, "html.parser")
        rewrite_images_and_media(frag, asset_map)
        rewrite_attachment_links(frag, asset_map)
        profile["signature"] = str(frag)

    return profiles

def save_thread_users(output_dir, profiles):
    path = os.path.join(output_dir, "thread_users.js")
    data = {
        "version": 1,
        "users": profiles
    }

    js = "window.THREAD_USERS=" + json.dumps(
        data,
        ensure_ascii=False,
        separators=(",", ":")
    ) + ";"

    temp = path + ".tmp"

    with open(temp, "w", encoding="utf-8", buffering=1024 * 1024) as f:
        f.write(js)

    os.replace(temp, path)

def load_thread_users(output_dir):
    path = os.path.join(output_dir, "thread_users.js")

    if not os.path.exists(path):
        return {}

    try:
        with open(path, "r", encoding="utf-8") as f:
            data = f.read()

        m = re.search(
            r"window\.THREAD_USERS\s*=\s*(\{.*\});?\s*$",
            data,
            re.S
        )

        if not m:
            return {}

        parsed = json.loads(m.group(1))
        return parsed.get("users", {})
    except Exception:
        return {}

def user_profile_js():
    return """
function hydrateUserProfiles(){
    const users=(window.THREAD_USERS&&window.THREAD_USERS.users)||{};
    document.querySelectorAll(".message[data-user-key]").forEach(post=>{
        const key=post.dataset.userKey;
        const u=users[key];
        if(!u)return;
        const titleSlot=post.querySelector(".user-title-slot");
        const bannerSlot=post.querySelector(".user-banners-slot");
        const sigSlot=post.querySelector(".signature-slot");

        if(titleSlot&&u.title){
            titleSlot.innerHTML='<h5 class="userTitle message-userTitle '+escapeHtml(u.title_class||'')+'">'+escapeHtml(u.title)+'</h5>';
        }

        if(bannerSlot&&Array.isArray(u.banners)&&u.banners.length){
            bannerSlot.innerHTML=u.banners.join("");
        }

        if(sigSlot&&u.signature){
            sigSlot.innerHTML='<aside class="message-signature"><div class="bbWrapper">'+u.signature+'</div></aside>';
        }
    });
}
"""

def create_clean_layout(soup, page_number, total_pages=9999):
    title_tag = soup.find("h1", class_="p-title-value") or soup.find("title")
    title_text = title_tag.get_text(strip=True) if title_tag else "Soyjak Forum"

    prev_html = (
        f'<a href="page_{page_number-1}.html">← Previous</a>'
        if page_number > 1 else ""
    )
    next_html = f'<a href="page_{page_number+1}.html">Next →</a>'

    term_replacements = [
        (re.compile(r"\bliar\b", re.I), "replacement"),
        (re.compile(r"\bnoob\b", re.I), "replacement"),
        (re.compile(r"\bbad\s+driver\b", re.I), "replacement"),
        (re.compile(r"\bcaca\b", re.I), "replacement"),
        (re.compile(r"\bcacaot\b", re.I), "replacement"),
        (re.compile(r"\breal\s+woman\b", re.I), "replacement")
    ]

    def replace_terms(text):
        if not text or not ENABLE_REPLACEMENTS:
            return text

        for pattern, repl in term_replacements:
            text = pattern.sub(repl, text)

        return text

    def nav_html():
        return f"""
<div class="pagination">
{prev_html}
<strong>Page {page_number}</strong>
{next_html}
<div class="page-jump">
<input type="number" class="pageInput" placeholder="Go to page" min="1">
<button type="button" onclick="goToPage(this)">Go</button>
</div>
</div>
"""

    new_html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{html.escape(title_text)} - Page {page_number}</title>
<script src="thread_users.js"></script>
<script src="thread_index.js"></script>
<style>
*{{box-sizing:border-box}}
:root{{
--page:#e9edf1;
--panel:#fff;
--border:#c8d0d7;
--header:#263d52;
--header2:#1b2e40;
--text:#20252a;
--muted:#687681;
--user:#eef2f5;
--meta:#f4f6f8;
--button:#dbe5ee;
--button-active:#c4ccd3;
--link:#286090;
}}
html,body{{margin:0;padding:0;background:var(--page);color:var(--text);font:13px Arial,sans-serif}}
body{{line-height:1.45}}
body.theme-dark{{
--page:#181c20;--panel:#252a2f;--border:#414a52;--header:#10161c;--header2:#0b1015;
--text:#e4e7e9;--muted:#9aa5ad;--user:#20252a;--meta:#20252a;
--button:#30383f;--button-active:#181d21;--link:#73a9d5;
}}
body.theme-yotsuba{{
--page:#d6d6d6;--panel:#f0e0d6;--border:#b7a59b;--header:#af0a0f;--header2:#8f0004;
--text:#800000;--muted:#6f5b52;--user:#eadbd2;--meta:#e9d8ce;
--button:#d9c5b9;--button-active:#bca79b;--link:#800000;
}}
.header{{background:var(--header);color:#fff;border-bottom:3px solid var(--header2);padding:5px 6px;position:sticky;top:0;z-index:20}}
.header h1{{font-size:12px;margin:0 0 4px;font-weight:600}}
.header p{{margin:0 0 4px;color:#cbd7e1;font-size:12px}}
.header-tools{{display:flex;justify-content:space-between;align-items:center;gap:8px}}
.theme-tools,.feature-tools{{display:flex;gap:4px;align-items:center}}
.icon-btn,.feature-btn{{
height:12px;min-width:29px;padding:0 7px;border:1px solid var(--header2);
background:var(--button);color:#263d52;border-radius:2px;cursor:pointer;font-weight:600
}}
.icon-btn{{font-size:15px;padding:0 7px}}
.feature-btn{{font-size:11px}}
.feature-btn.active{{background:var(--button-active);box-shadow:inset 0 2px 4px #0004;color:#4d5961}}
.search-row{{display:flex;gap:5px;align-items:center;margin:5px 0}}
.search-wrap{{position:relative;flex:1;min-width:0}}
.search-row input{{width:100%;height:31px;padding:3px 6px;border:1px solid #172b3d;background:#fff;color:#222;border-radius:2px;outline:0}}
.search-row input:focus{{border-color:#69a2d1;box-shadow:0 0 0 1px #69a2d1}}
.search-btn,.date-btn{{height:31px;padding:0 9px;border:1px solid #172b3d;background:var(--button);color:#1f3447;border-radius:2px;cursor:pointer;font-weight:600}}
.search-btn:hover,.date-btn:hover,.icon-btn:hover,.feature-btn:hover{{filter:brightness(1.08)}}
.autocomplete{{display:none;position:absolute;top:32px;left:0;right:0;background:#fff;border:1px solid #9aa9b6;z-index:50;box-shadow:0 2px 5px #0003;max-height:180px;overflow:auto}}
.autocomplete div{{padding:5px 8px;cursor:pointer;color:#777;background:#fff}}
.autocomplete div:hover{{background:#edf3f8;color:#555}}
.autocomplete strong{{color:#555}}
.date-panel{{display:none;position:absolute;right:12px;top:113px;background:#fff;color:#222;border:1px solid #9aa9b6;padding:8px;z-index:40;box-shadow:0 2px 6px #0003}}
.date-panel label{{font-size:11px;color:#555;margin-right:5px}}
.date-panel input{{height:27px;border:1px solid #aaa;padding:3px}}
.date-panel button{{height:27px;margin-left:4px;border:1px solid #8b9aa7;background:#e7edf2;cursor:pointer}}
.results{{max-width:1100px;margin:8px auto;padding:0 8px}}
.message{{background:var(--panel);border:1px solid var(--border);border-radius:0;margin:0 0 8px;display:grid;grid-template-columns:145px minmax(0,1fr);box-shadow:0 1px 1px #0001}}
.message-user{{background:var(--user);border-right:1px solid var(--border);padding:9px;text-align:center;overflow:hidden}}
.avatar{{width:75px;height:75px;margin:0 auto 6px}}
.avatar img{{width:75px;height:75px;border:1px solid #c2cbd2;object-fit:cover;background:#fff}}
.username{{font-weight:700;font-size:13px;color:var(--link);overflow-wrap:anywhere}}
.badges{{margin-top:4px;display:flex;justify-content:center;align-items:center;gap:2px;flex-wrap:wrap}}
.badges img{{max-height:24px;max-width:35px;margin:1px;vertical-align:middle}}
.user-title-slot,.user-banners-slot{{margin-top:4px}}
.userTitle{{
display:block;margin:2px 0;font-size:11px;line-height:1.25;font-weight:600;
overflow-wrap:anywhere
}}
.custom-title-color-cyan{{color:#00a7b5}}
.custom-title-color-red{{color:#c00}}
.custom-title-color-blue{{color:#2670b8}}
.custom-title-color-green{{color:#31833b}}
.custom-title-color-yellow{{color:#a58b00}}
.custom-title-color-orange{{color:#c66b00}}
.custom-title-color-purple{{color:#7040a0}}
.custom-title-color-pink{{color:#c04c82}}
.custom-title-color-gray{{color:#777}}
.userBanner{{
font-size:10px;font-weight:600;padding:2px 5px;margin:2px auto;border:1px solid transparent;
border-radius:2px;text-align:center;display:block;max-width:100%;overflow-wrap:anywhere
}}
.userBanner--staff,.userBanner--primary{{background:#d7e6f2;color:#286090;border-color:#b8ccd9}}
.userBanner--accent{{background:#008b8b;color:#fff;border-color:#006f6f}}
.userBanner--red{{background:#d80000;color:#fff;border-color:#b80000}}
.userBanner--green{{background:green;color:#fff;border-color:#006000}}
.userBanner--olive{{background:olive;color:#fff;border-color:#686800}}
.userBanner--lightGreen{{background:#ccf9c8;color:#111;border-color:#bee8ba}}
.userBanner--blue{{background:#0008e3;color:#fff;border-color:#0006b5}}
.userBanner--royalBlue{{background:royalblue;color:#fff;border-color:#315db2}}
.userBanner--skyBlue{{background:#7cc3e0;color:#111;border-color:#62a8c4}}
.userBanner--gray{{background:gray;color:#fff;border-color:#707070}}
.userBanner--silver{{background:silver;color:#111;border-color:#aaa}}
.userBanner--yellow{{background:#ffff91;color:#111;border-color:#e6e687}}
.userBanner--orange{{background:#ffcb00;color:#111;border-color:#e0ae00}}
.message-main{{min-width:0}}
.message-meta{{height:31px;background:var(--meta);border-bottom:1px solid var(--border);padding:7px 9px;color:var(--muted);font-size:11px}}
.message-date{{font-weight:600;color:var(--muted)}}
.content{{padding:10px;overflow-wrap:anywhere}}
.content img,.content video,.content audio{{max-width:100%;height:auto;display:block;margin:8px 0;border-radius:2px}}
.content blockquote{{margin:7px 0;padding:6px 9px;background:var(--meta);border-left:3px solid #c2ccd5}}
.message-signature{{display:none;margin:0 10px 10px;padding:7px 0;border-top:1px dashed var(--border);font-size:12px}}
.message-signature img,.message-signature video{{max-width:100%;height:auto}}
body.show-signatures .message-signature{{display:block}}
body.hide-signatures .message-signature{{display:none}}
body.show-titles .user-title-slot{{display:block}}
body:not(.show-titles) .user-title-slot{{display:none}}
body.show-banners .user-banners-slot{{display:block}}
body:not(.show-banners) .user-banners-slot{{display:none}}
.reactions-bar{{margin:0 10px 10px;padding:6px 8px;background:var(--meta);border:1px solid var(--border);color:var(--muted);font-size:11px}}
.search-result{{background:var(--panel);border:1px solid var(--border);margin:0 0 5px;padding:7px 9px;cursor:pointer}}
.search-result:hover{{filter:brightness(1.03)}}
.search-result-head{{display:flex;justify-content:space-between;gap:10px;color:var(--muted);font-size:11px}}
.search-result-user{{font-weight:700;color:var(--link)}}
.search-result-text{{margin-top:4px;color:var(--text);line-height:1.4}}
.search-result-mark{{background:#fff19a;color:#222;padding:0 1px}}
.search-result-link{{display:inline-block;margin-top:4px;color:var(--link);font-size:11px}}
.hidden-by-filter{{display:none!important}}
.no-results{{display:none;background:var(--panel);border:1px solid var(--border);padding:6px;text-align:center;color:var(--muted)}}
.search-status{{display:none;background:var(--meta);border:1px solid var(--border);padding:6px 9px;margin-bottom:5px;color:var(--muted);font-size:11px}}
.pagination{{text-align:center;margin:8px auto;padding:8px;background:var(--panel);border:1px solid var(--border);max-width:1100px;color:var(--text)}}
.pagination a,.pagination button{{display:inline-block;padding:5px 10px;margin:0 2px;background:var(--button);color:var(--link);text-decoration:none;border:1px solid var(--border);border-radius:2px;cursor:pointer}}
.pagination input{{height:15px;width:100px;border:1px solid var(--border);padding:3px 6px;background:var(--panel);color:var(--text)}}
.page-jump{{margin-top:5px}}
@media(max-width:700px){{
.message{{grid-template-columns:95px minmax(0,1fr)}}
.avatar,.avatar img{{width:60px;height:60px}}
.message-user{{padding:6px}}
.results{{padding:0 4px}}
.header-tools{{align-items:flex-start;flex-direction:column}}
.feature-tools{{width:100%}}
}}
</style>
</head>
<body>
<div class="header">
<h1>{html.escape(title_text)}</h1>
<p>Page {page_number}</p>
<div class="header-tools">
<div class="theme-tools">
<button class="icon-btn" type="button" title="Dark theme" onclick="setTheme('dark')">☾</button>
<button class="icon-btn" type="button" title="Light theme" onclick="setTheme('light')">☀</button>
<button class="icon-btn" type="button" title="Yotsuba theme" onclick="setTheme('yotsuba')">☘</button>
</div>
<div class="feature-tools">
<button class="feature-btn" id="signatureBtn" type="button" onclick="toggleFeature('signatures')">Signatures</button>
<button class="feature-btn" id="titleBtn" type="button" onclick="toggleFeature('titles')">User Titles</button>
<button class="feature-btn" id="bannerBtn" type="button" onclick="toggleFeature('banners')">User Banners</button>
</div>
</div>
<div class="search-row">
<div class="search-wrap">
<input id="postSearch" type="search" placeholder="Search entire thread by word..." autocomplete="off">
</div>
<button class="date-btn" id="dateBtn" type="button">Date</button>
</div>
<div class="search-row">
<div class="search-wrap">
<input id="userSearch" type="search" placeholder="Search entire thread by poster..." autocomplete="off">
<div class="autocomplete" id="userAutocomplete"></div>
</div>
<button class="search-btn" id="clearBtn" type="button">Clear</button>
</div>
<div class="date-panel" id="datePanel">
<label>From <input id="dateFrom" type="date"></label>
<label>To <input id="dateTo" type="date"></label>
<button id="applyDate" type="button">Apply</button>
<button id="clearDate" type="button">Clear</button>
</div>
</div>
{nav_html()}
<div class="results" id="results">
<div class="search-status" id="searchStatus"></div>
"""

    for post in soup.find_all("article", class_="message"):
        user_key = extract_post_user_key(post)

        username_tag = (
            post.select_one(".message-userDetails .username")
            or post.find("a", class_="username")
            or post.find(class_="username")
            or post.find(class_="message-name")
        )

        username = (
            username_tag.get_text(" ", strip=True)
            if username_tag else "Unknown"
        )

        username = replace_terms(username)

        avatar = None

        for selector in [
            "img.avatar",
            "a.avatar img",
            "img[data-user-id]",
            "img.avatar-u",
            ".message-avatar img"
        ]:
            avatar = post.select_one(selector)
            if avatar and avatar.get("src"):
                break

        avatar_src = avatar.get("src", "") if avatar else ""

        awards_html = extract_user_awards(post)

        content = (
            post.find("div", class_="message-content")
            or post.find("article")
            or post
        )

        content_html = (
            replace_terms(str(content))
            if content else "<p>No content</p>"
        )

        date_display, date_iso, date_only = extract_post_date(post)
        date_attr = date_iso or date_only
        reactions_html = extract_reactions(post)

        new_html += f"""
<article class="message" data-user-key="{html.escape(user_key, quote=True)}" data-user="{html.escape(username, quote=True)}" data-date="{html.escape(date_attr, quote=True)}">
<div class="message-user">
<div class="avatar"><img src="{html.escape(avatar_src, quote=True)}" alt="avatar"></div>
<div class="username">{username}</div>
{awards_html}
<div class="user-title-slot"></div>
<div class="user-banners-slot"></div>
</div>
<div class="message-main">
<div class="message-meta"><span class="message-date">{html.escape(date_display)}</span></div>
<div class="content">{content_html}</div>
<div class="signature-slot"></div>
{reactions_html}
</div>
</article>
"""

    new_html += f"""
</div>
<div class="no-results" id="noResults">No posts match the current filters.</div>
{nav_html()}
<script>
const currentPage={page_number};
const posts=[...document.querySelectorAll(".message")];
const postSearch=document.getElementById("postSearch");
const userSearch=document.getElementById("userSearch");
const datePanel=document.getElementById("datePanel");
const autocomplete=document.getElementById("userAutocomplete");
const noResults=document.getElementById("noResults");
const searchStatus=document.getElementById("searchStatus");
const localResults=document.getElementById("results");
const localMessages=posts.map(p=>({{
    page:currentPage,
    file:`page_${{currentPage}}.html`,
    user:p.dataset.user||"",
    date:p.dataset.date||"",
    date_display:p.querySelector(".message-date")?.innerText||"",
    text:p.querySelector(".content")?.innerText||""
}}));
const indexData=window.THREAD_INDEX||{{pages:[]}};
const allPosts=(indexData.pages||[]).flatMap(p=>(p.posts||[]).map(x=>({{
    ...x,page:p.page,file:p.file
}})));
const searchablePosts=allPosts.length?allPosts:localMessages;
const allUsers=[...new Set(searchablePosts.map(x=>x.user).filter(Boolean))].sort((a,b)=>a.localeCompare(b));

function escapeHtml(s){{
    return String(s).replace(/[&<>"']/g,m=>({{
        "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;"
    }})[m]);
}}

function escapeRegExp(s){{
    return s.replace(/[.*+?^${{}}()|[\\]\\\\]/g,"\\\\$&");
}}

function wordMatch(text,query){{
    const words=query.toLowerCase().trim().split(/\\s+/).filter(Boolean);
    if(!words.length)return true;
    const lower=text.toLowerCase();
    return words.every(w=>new RegExp(
        "(^|[^\\\\p{{L}}\\\\p{{N}}_])"+escapeRegExp(w)+"($|[^\\\\p{{L}}\\\\p{{N}}_])",
        "iu"
    ).test(lower));
}}

function selectedUsers(){{
    return userSearch.value.split(",").map(x=>x.trim().toLowerCase()).filter(Boolean);
}}

function postDate(p){{
    if(!p.date)return null;
    const d=new Date(p.date);
    return isNaN(d.getTime())?null:d;
}}

function dateMatches(p){{
    const from=document.getElementById("dateFrom").value;
    const to=document.getElementById("dateTo").value;
    const d=postDate(p);

    if(!from&&!to)return true;
    if(!d)return false;
    if(from&&d<new Date(from+"T00:00:00"))return false;
    if(to&&d>new Date(to+"T23:59:59.999"))return false;

    return true;
}}

function highlight(text,query){{
    if(!query)return escapeHtml(text);

    const words=query.trim().split(/\\s+/).filter(Boolean).sort((a,b)=>b.length-a.length);

    if(!words.length)return escapeHtml(text);

    const pattern=new RegExp("("+words.map(escapeRegExp).join("|")+")","ig");
    let out="",last=0;

    for(const m of text.matchAll(pattern)){{
        out+=escapeHtml(text.slice(last,m.index));
        out+='<span class="search-result-mark">'+escapeHtml(m[0])+"</span>";
        last=m.index+m[0].length;
    }}

    return out+escapeHtml(text.slice(last));
}}

function renderSearchResults(){{
    const q=postSearch.value.trim();
    const users=selectedUsers();
    const activeDate=document.getElementById("dateFrom").value||document.getElementById("dateTo").value;
    const searching=!!(q||users.length||activeDate);

    if(!searching){{
        searchStatus.style.display="none";
        document.querySelectorAll(".search-result").forEach(x=>x.remove());
        posts.forEach(p=>p.classList.remove("hidden-by-filter"));
        noResults.style.display="none";
        return;
    }}

    posts.forEach(p=>p.classList.add("hidden-by-filter"));
    document.querySelectorAll(".search-result").forEach(x=>x.remove());

    const matches=searchablePosts.filter(p=>{{
        if(q&&!wordMatch(p.text||"",q))return false;
        if(users.length&&!users.some(u=>(p.user||"").toLowerCase()===u))return false;
        return dateMatches(p);
    }});

    searchStatus.style.display="block";
    searchStatus.textContent=`${{matches.length}} matching post${{matches.length===1?"":"s"}} across the thread`;

    if(!matches.length){{
        noResults.style.display="block";
        return;
    }}

    noResults.style.display="none";

    const fragment=document.createDocumentFragment();

    matches.forEach(p=>{{
        const item=document.createElement("div");
        item.className="search-result";

        const snippet=(p.text||"").replace(/\\s+/g," ").trim();
        const max=500;
        const shown=snippet.length>max?snippet.slice(0,max)+"…":snippet;

        item.innerHTML=`
<div class="search-result-head">
<span class="search-result-user">${{escapeHtml(p.user||"Unknown")}}</span>
<span>${{escapeHtml(p.date_display||p.date||"")}} · Page ${{p.page}}</span>
</div>
<div class="search-result-text">${{highlight(shown,q)}}</div>
<a class="search-result-link" href="${{escapeHtml(p.file)}}">Open page ${{p.page}} →</a>
`;

        item.onclick=e=>{{
            if(e.target.tagName.toLowerCase()==="a")return;
            window.location.href=p.file;
        }};

        fragment.appendChild(item);
    }});

    localResults.appendChild(fragment);
}}

function showSuggestions(){{
    const parts=userSearch.value.split(",");
    const fragment=parts[parts.length-1].trim().toLowerCase();

    autocomplete.innerHTML="";

    if(!fragment){{
        autocomplete.style.display="none";
        return;
    }}

    const matches=allUsers.filter(u=>u.toLowerCase().includes(fragment)).slice(0,15);

    if(!matches.length){{
        autocomplete.style.display="none";
        return;
    }}

    matches.forEach(name=>{{
        const div=document.createElement("div");
        const i=name.toLowerCase().indexOf(fragment);

        div.innerHTML=i>=0
            ?escapeHtml(name.slice(0,i))+"<strong>"+escapeHtml(name.slice(i,i+fragment.length))+"</strong>"+escapeHtml(name.slice(i+fragment.length))
            :escapeHtml(name);

        div.onclick=()=>{{
            parts[parts.length-1]=" "+name;
            userSearch.value=parts.join(",").replace(/^\\s+/,"");
            autocomplete.style.display="none";
            renderSearchResults();
            userSearch.focus();
        }};

        autocomplete.appendChild(div);
    }});

    autocomplete.style.display="block";
}}

function cookieGet(name){{
    const m=document.cookie.match(new RegExp("(?:^|; )"+name.replace(/[.*+?^${{}}()|[\\]\\\\]/g,"\\\\$&")+"=([^;]*)"));
    return m?decodeURIComponent(m[1]):null;
}}

function cookieSet(name,value){{
    document.cookie=name+"="+encodeURIComponent(value)+"; path=/; max-age=31536000; SameSite=Lax";
}}

function setTheme(theme){{
    document.body.classList.remove("theme-dark","theme-yotsuba");

    if(theme==="dark")document.body.classList.add("theme-dark");
    if(theme==="yotsuba")document.body.classList.add("theme-yotsuba");

    cookieSet("shroom_theme",theme);
}}

function featureState(name){{
    const v=cookieGet("shroom_"+name);
    return v==="1";
}}

function applyFeature(name,on){{
    document.body.classList.toggle("show-"+name,on);
    document.body.classList.toggle("hide-"+name,!on);

    const map={{
        signatures:"signatureBtn",
        titles:"titleBtn",
        banners:"bannerBtn"
    }};

    const btn=document.getElementById(map[name]);

    if(btn)btn.classList.toggle("active",on);
    cookieSet("shroom_"+name,on?"1":"0");
}}

function toggleFeature(name){{
    applyFeature(name,!featureState(name));
}}

function initPreferences(){{
    const theme=cookieGet("shroom_theme")||"light";
    setTheme(theme);

    applyFeature("signatures",featureState("signatures"));
    applyFeature("titles",featureState("titles"));
    applyFeature("banners",featureState("banners"));
}}

function goToPage(button){{
    const page=button.parentElement.querySelector(".pageInput").value.trim();
    if(page)window.location.href=`page_${{page}}.html`;
}}

postSearch.addEventListener("input",renderSearchResults);

userSearch.addEventListener("input",()=>{{
    showSuggestions();
    renderSearchResults();
}});

document.addEventListener("click",e=>{{
    if(!userSearch.contains(e.target)&&!autocomplete.contains(e.target))
        autocomplete.style.display="none";
}});

document.getElementById("dateBtn").onclick=()=>{{
    datePanel.style.display=datePanel.style.display==="block"?"none":"block";
}};

document.getElementById("applyDate").onclick=()=>{{
    datePanel.style.display="none";
    renderSearchResults();
}};

document.getElementById("clearDate").onclick=()=>{{
    document.getElementById("dateFrom").value="";
    document.getElementById("dateTo").value="";
    datePanel.style.display="none";
    renderSearchResults();
}};

document.getElementById("clearBtn").onclick=()=>{{
    postSearch.value="";
    userSearch.value="";
    document.getElementById("dateFrom").value="";
    document.getElementById("dateTo").value="";
    datePanel.style.display="none";
    autocomplete.style.display="none";
    renderSearchResults();
}};

document.addEventListener("keydown",e=>{{
    if(e.key==="Escape"){{
        autocomplete.style.display="none";
        datePanel.style.display="none";
    }}
}});

{user_profile_js()}
hydrateUserProfiles();
initPreferences();
</script>
</body>
</html>
"""

    return BeautifulSoup(new_html, "html.parser")

def extract_index_posts(clean_soup):
    posts = []

    for post in clean_soup.select("article.message"):
        user = post.get("data-user", "").strip()
        date = post.get("data-date", "").strip()
        date_display_tag = post.select_one(".message-date")
        date_display = (
            date_display_tag.get_text(" ", strip=True)
            if date_display_tag else ""
        )
        content = post.select_one(".content")
        text = content.get_text(" ", strip=True) if content else ""

        posts.append({
            "user": user,
            "date": date,
            "date_display": date_display,
            "text": text
        })

    return posts

def initialize_thread_index(output_dir):
    index_path = os.path.join(output_dir, "thread_index.js")

    if not os.path.exists(index_path):
        with open(index_path, "w", encoding="utf-8", buffering=1024 * 1024) as f:
            f.write("window.THREAD_INDEX={version:1,pages:[]};\n")

def get_indexed_pages(output_dir):
    index_path = os.path.join(output_dir, "thread_index.js")

    if not os.path.exists(index_path):
        return set()

    try:
        with open(index_path, "r", encoding="utf-8") as f:
            data = f.read()

        return {
            int(x)
            for x in re.findall(r'"page":(\d+)', data)
        }
    except Exception:
        return set()

def append_thread_index_page(output_dir, page_number, filename, posts):
    index_path = os.path.join(output_dir, "thread_index.js")
    initialize_thread_index(output_dir)

    page_data = {
        "page": page_number,
        "file": filename,
        "posts": posts
    }

    js = json.dumps(
        page_data,
        ensure_ascii=False,
        separators=(",", ":")
    )

    with open(index_path, "a", encoding="utf-8", buffering=1024 * 1024) as f:
        f.write(f"window.THREAD_INDEX.pages.push({js});\n")

def rebuild_thread_index(output_dir):
    pages = []
    filenames = []

    try:
        names = os.listdir(output_dir)
    except OSError:
        names = []

    for name in names:
        m = re.match(r"^page_(\d+)\.html$", name, re.I)
        if m:
            filenames.append((int(m.group(1)), name))

    filenames.sort()

    for page_number, filename in filenames:
        try:
            with open(
                os.path.join(output_dir, filename),
                "r",
                encoding="utf-8"
            ) as f:
                soup = BeautifulSoup(f.read(), "html.parser")

            pages.append({
                "page": page_number,
                "file": filename,
                "posts": extract_index_posts(soup)
            })

        except Exception as e:
            print(f"    ! Could not index {filename}: {e}")

    index = {"version": 1, "pages": pages}
    index_path = os.path.join(output_dir, "thread_index.js")
    temp_path = index_path + ".tmp"

    js = "window.THREAD_INDEX=" + json.dumps(
        index,
        ensure_ascii=False,
        separators=(",", ":")
    ) + ";"

    with open(
        temp_path,
        "w",
        encoding="utf-8",
        buffering=1024 * 1024
    ) as f:
        f.write(js)

    os.replace(temp_path, index_path)

    print(
        f"✓ Rebuilt thread search index: {len(pages)} page(s), "
        f"{sum(len(p['posts']) for p in pages)} post(s)"
    )

    return {p["page"] for p in pages}

def repair_thread_index(output_dir):
    index_path = os.path.join(output_dir, "thread_index.js")

    if os.path.exists(index_path):
        try:
            with open(index_path, "r", encoding="utf-8") as f:
                existing = f.read()

            if "window.THREAD_INDEX=" in existing and "pages" in existing:
                indexed_pages = get_indexed_pages(output_dir)

                if indexed_pages:
                    return indexed_pages
        except Exception:
            pass

    initialize_thread_index(output_dir)
    indexed_pages = get_indexed_pages(output_dir)
    filenames = []

    try:
        names = os.listdir(output_dir)
    except OSError:
        names = []

    for name in names:
        m = re.match(r"^page_(\d+)\.html$", name, re.I)
        if m:
            filenames.append((int(m.group(1)), name))

    filenames.sort()

    missing = [
        (n, f)
        for n, f in filenames
        if n not in indexed_pages
    ]

    if not missing:
        return indexed_pages

    print(f"⚠ Repairing {len(missing)} missing index page(s)...")

    for page_number, filename in missing:
        try:
            with open(
                os.path.join(output_dir, filename),
                "r",
                encoding="utf-8"
            ) as f:
                soup = BeautifulSoup(f.read(), "html.parser")

            append_thread_index_page(
                output_dir,
                page_number,
                filename,
                extract_index_posts(soup)
            )

            indexed_pages.add(page_number)

        except Exception as e:
            print(f"    ! Could not repair index for {filename}: {e}")

    return indexed_pages

def get_highest_existing_page(output_dir):
    if not os.path.isdir(output_dir):
        return 0

    highest = 0

    for name in os.listdir(output_dir):
        m = re.match(r"^page_(\d+)\.html$", name, re.I)

        if m:
            try:
                highest = max(highest, int(m.group(1)))
            except ValueError:
                pass

    return highest

def build_page_url_from_current(current_url, page_number):
    url = current_url.split("#")[0].rstrip("/")
    url = re.sub(r"/page-\d+$", "", url, flags=re.I)

    return url if page_number <= 1 else f"{url}/page-{page_number}"

def wait_for_page_content(driver, timeout=25):
    WebDriverWait(driver, timeout).until(
        EC.presence_of_element_located((By.CLASS_NAME, "p-pageWrapper"))
    )

def scroll_until_idle(driver, max_seconds=20, idle_checks=3, check_interval=0.35):
    start = time.monotonic()
    stable_count = 0

    driver.execute_script("window.scrollTo(0, 0);")
    time.sleep(0.25)

    while time.monotonic() - start < max_seconds:
        state = driver.execute_script("""
            const body=document.body;
            const doc=document.documentElement;
            return {
                height:Math.max(
                    body?body.scrollHeight:0,
                    doc?doc.scrollHeight:0
                ),
                elements:document.getElementsByTagName("*").length
            };
        """)

        height = state.get("height", 0)
        dom_count = state.get("elements", 0)

        driver.execute_script(
            "window.scrollTo(0, arguments[0]);",
            height
        )

        time.sleep(check_interval)

        new_state = driver.execute_script("""
            const body=document.body;
            const doc=document.documentElement;
            return {
                height:Math.max(
                    body?body.scrollHeight:0,
                    doc?doc.scrollHeight:0
                ),
                elements:document.getElementsByTagName("*").length
            };
        """)

        new_height = new_state.get("height", 0)
        new_dom_count = new_state.get("elements", 0)

        if new_height == height and new_dom_count == dom_count:
            stable_count += 1

            if stable_count >= idle_checks:
                break
        else:
            stable_count = 0

    driver.execute_script("window.scrollTo(0, 0);")

BASE_DOMAIN = None
HOST = None
OUTPUT_DIR = None
ATTACH_DIR = None
PROFILE_DIR = r".\chrome_profile"
MAX_RETRIES = 1
REQUEST_TIMEOUT = 20
BACKOFF_BASE = 1.7
ASSET_WORKERS = 10
ENABLE_REPLACEMENTS = True
SCRAPE_SIGNATURES = True
SCRAPE_USER_TITLES = True
SCRAPE_USER_BANNERS = True

def archive_thread(target_url, driver, tab_handle):
    global BASE_DOMAIN, HOST, OUTPUT_DIR, ATTACH_DIR

    TARGET_URL = target_url.strip() or FALLBACK_URL
    parsed_target = urlparse(TARGET_URL)

    BASE_DOMAIN = f"{parsed_target.scheme}://{parsed_target.netloc}"
    HOST = parsed_target.netloc

    thread_match = re.search(
        r"threads/([^/]+)",
        parsed_target.path
    )

    folder_suffix = (
        thread_match.group(1)
        if thread_match
        else f"thread_{int(time.time())}"
    )

    OUTPUT_DIR = os.path.join(".", folder_suffix)
    ATTACH_DIR = os.path.join(OUTPUT_DIR, "Attachments")

    os.makedirs(OUTPUT_DIR, exist_ok=True)
    os.makedirs(ATTACH_DIR, exist_ok=True)

    highest = get_highest_existing_page(OUTPUT_DIR)
    start_page = highest + 1

    if highest:
        print(
            f"Found existing pages up to page_{highest}.html "
            f"→ resuming at page {start_page}"
        )
        indexed_pages = repair_thread_index(OUTPUT_DIR)
    else:
        print("No existing pages found. Starting from page 1.")
        initialize_thread_index(OUTPUT_DIR)
        indexed_pages = set()

    user_profiles = load_thread_users(OUTPUT_DIR)

    try:
        driver.switch_to.window(tab_handle)
    except Exception:
        raise RuntimeError(
            f"Could not switch to the browser tab assigned to {TARGET_URL}"
        )

    if driver.current_window_handle != tab_handle:
        raise RuntimeError(
            f"Browser tab verification failed for {TARGET_URL}"
        )

    print(f"Loading thread start URL: {TARGET_URL}")
    driver.get(TARGET_URL)

    wait_for_page_content(driver)
    time.sleep(1)

    if start_page > 1:
        current_url = build_page_url_from_current(
            driver.current_url,
            start_page
        )

        page_number = start_page

        print(f"Navigating to resume point: {current_url}")
        driver.get(current_url)
        wait_for_page_content(driver)
        time.sleep(1)

    else:
        current_url = TARGET_URL
        page_number = 1

    total_pages = 9999

    scrape_options = {
        "signatures": SCRAPE_SIGNATURES,
        "titles": SCRAPE_USER_TITLES,
        "banners": SCRAPE_USER_BANNERS
    }

    while current_url:
        print(f"\n=== Scraping Page {page_number} ===")

        if page_number != start_page or start_page == 1:
            driver.get(current_url)
            wait_for_page_content(driver)
            time.sleep(1)

        scroll_until_idle(driver)

        session = setup_requests_session(driver)
        referer = current_url
        soup = BeautifulSoup(driver.page_source, "html.parser")

        print("Collecting per-user profile data...")
        before_users = len(user_profiles)

        user_profiles = extract_user_profiles(
            soup,
            user_profiles,
            scrape_options
        )

        if len(user_profiles) != before_users:
            print(
                f"✓ User profile cache now contains "
                f"{len(user_profiles)} user(s)"
            )

        print("Building clean layout...")
        clean_soup = create_clean_layout(
            soup,
            page_number,
            total_pages
        )

        print("Collecting attachment/media URLs...")

        sprite_props, sprite_urls = collect_sprite_assets(
            clean_soup,
            session,
            referer
        )

        asset_urls = []
        asset_urls.extend(collect_image_assets(clean_soup))
        asset_urls.extend(collect_media_assets(clean_soup))
        asset_urls.extend(collect_attachment_assets(clean_soup))
        asset_urls.extend(sprite_urls.keys())
        asset_urls.extend(profile_asset_urls(user_profiles))

        asset_urls = list(dict.fromkeys(
            normalize_url(url)
            for url in asset_urls
            if url
            and normalize_url(url)
            and normalize_url(url).startswith("http")
        ))

        print(
            f"Downloading {len(asset_urls)} unique assets with "
            f"{min(ASSET_WORKERS, max(1, len(asset_urls)))} workers..."
        )

        asset_map = download_assets_parallel(
            session,
            asset_urls,
            ATTACH_DIR,
            referer
        )

        print("Rewriting downloaded asset references...")

        rewrite_images_and_media(
            clean_soup,
            asset_map
        )

        rewrite_attachment_links(
            clean_soup,
            asset_map
        )

        rewrite_sprite_styles(
            clean_soup,
            sprite_props,
            asset_map
        )

        user_profiles = rewrite_profile_assets(
            user_profiles,
            asset_map
        )

        save_thread_users(
            OUTPUT_DIR,
            user_profiles
        )

        file_path = os.path.join(
            OUTPUT_DIR,
            f"page_{page_number}.html"
        )

        with open(
            file_path,
            "w",
            encoding="utf-8",
            buffering=1024 * 1024
        ) as f:
            f.write(str(clean_soup))

        print(f"✓ Saved page_{page_number}.html")

        if page_number not in indexed_pages:
            append_thread_index_page(
                OUTPUT_DIR,
                page_number,
                f"page_{page_number}.html",
                extract_index_posts(clean_soup)
            )
            indexed_pages.add(page_number)

        try:
            next_btn = driver.find_element(
                By.CSS_SELECTOR,
                "a.pageNav-jump--next"
            )

            next_url = next_btn.get_attribute("href")

            current_url = (
                urljoin(BASE_DOMAIN, next_url)
                if next_url
                else None
            )

            page_number += 1

            if current_url:
                time.sleep(1)

        except Exception:
            print("✅ End of thread reached.")
            current_url = None

    print("\n✓ Thread finished!")

class ConsoleRedirector:
    def __init__(self, text_widget):
        self.text_widget = text_widget
        self.root = text_widget.winfo_toplevel()

    def write(self, string):
        if not string:
            return

        try:
            self.root.after(
                0,
                self._append,
                string
            )
        except Exception:
            pass

    def _append(self, string):
        try:
            self.text_widget.config(state="normal")
            self.text_widget.insert("end", string)
            self.text_widget.see("end")
            self.text_widget.config(state="disabled")
        except tk.TclError:
            pass

    def flush(self):
        pass

class ThreadArchiverGUI:
    def __init__(self):
        self.root = tk.Tk()
        self.root.title("Shroomchiver")
        self.root.geometry("1000x720")
        self.root.minsize(850, 600)
        self.root.configure(bg="#e9edf1")

        self.colors = {
            "page": "#e9edf1",
            "panel": "#ffffff",
            "border": "#c7d0d8",
            "header": "#263d52",
            "header_text": "#ffffff",
            "header_muted": "#cbd7e1",
            "blue": "#286090",
            "blue_hover": "#337ab7",
            "blue_dark": "#1f4d73",
            "button": "#e5ebf0",
            "button_hover": "#d5e0e8",
            "text": "#263238",
            "muted": "#687681",
            "console": "#111820",
            "console_text": "#9be28f"
        }

        self.title_font = ("Arial", 14, "bold")
        self.normal_font = ("Arial", 9)
        self.console_font = ("Consolas", 9)

        self.enable_replacements = tk.BooleanVar(value=True)
        self.enable_signatures = tk.BooleanVar(value=True)
        self.enable_titles = tk.BooleanVar(value=True)
        self.enable_banners = tk.BooleanVar(value=True)

        self.archive_thread = None
        self.stop_event = threading.Event()
        self.driver = None
        self._closing = False

        self.root.protocol(
            "WM_DELETE_WINDOW",
            self.close
        )

        style = ttk.Style()

        try:
            style.theme_use("clam")
        except Exception:
            pass

        style.configure(
            "XF.Horizontal.TProgressbar",
            troughcolor="#d5dde4",
            background=self.colors["blue"],
            bordercolor="#b8c4ce",
            lightcolor=self.colors["blue"],
            darkcolor=self.colors["blue_dark"],
            thickness=10
        )

        outer = tk.Frame(
            self.root,
            bg=self.colors["page"]
        )
        outer.pack(
            fill="both",
            expand=True
        )

        # ------------------------------------------------------------
        # Header
        # ------------------------------------------------------------

        header = tk.Frame(
            outer,
            bg=self.colors["header"],
            height=74
        )
        header.pack(fill="x")
        header.pack_propagate(False)

        header_inner = tk.Frame(
            header,
            bg=self.colors["header"]
        )
        header_inner.pack(
            fill="both",
            expand=True,
            padx=18
        )

        title_frame = tk.Frame(
            header_inner,
            bg=self.colors["header"]
        )
        title_frame.pack(
            side="left",
            fill="y"
        )

        tk.Label(
            title_frame,
            text="Shroomchiver",
            bg=self.colors["header"],
            fg=self.colors["header_text"],
            font=("Arial", 17, "bold")
        ).pack(
            anchor="w",
            pady=(13, 0)
        )

        tk.Label(
            title_frame,
            text="Thread archiver",
            bg=self.colors["header"],
            fg=self.colors["header_muted"],
            font=("Arial", 9)
        ).pack(anchor="w")

        self.header_status = tk.Label(
            header_inner,
            text="● Ready",
            bg=self.colors["header"],
            fg="#91c89a",
            font=("Arial", 9, "bold")
        )
        self.header_status.pack(
            side="right",
            anchor="center",
            padx=5
        )

        # ------------------------------------------------------------
        # Main content
        # ------------------------------------------------------------

        content = tk.Frame(
            outer,
            bg=self.colors["page"]
        )
        content.pack(
            fill="both",
            expand=True,
            padx=12,
            pady=12
        )

        # ------------------------------------------------------------
        # Left panel
        # ------------------------------------------------------------

        left_panel = tk.Frame(
            content,
            bg=self.colors["panel"],
            highlightbackground=self.colors["border"],
            highlightthickness=1
        )
        left_panel.pack(
            side="left",
            fill="y",
            padx=(0, 10)
        )

        left_inner = tk.Frame(
            left_panel,
            bg=self.colors["panel"],
            width=410
        )
        left_inner.pack(
            fill="both",
            expand=True,
            padx=14,
            pady=14
        )
        left_inner.pack_propagate(False)

        tk.Label(
            left_inner,
            text="Archive Threads",
            bg=self.colors["panel"],
            fg=self.colors["text"],
            font=("Arial", 12, "bold")
        ).pack(anchor="w")

        tk.Label(
            left_inner,
            text="Add one or more XenForo thread URLs to the queue.",
            bg=self.colors["panel"],
            fg=self.colors["muted"],
            font=("Arial", 9)
        ).pack(
            anchor="w",
            pady=(2, 12)
        )

        tk.Label(
            left_inner,
            text="Thread URLs",
            bg=self.colors["panel"],
            fg=self.colors["text"],
            font=("Arial", 9, "bold")
        ).pack(
            anchor="w",
            pady=(0, 4)
        )

        self.url_text = scrolledtext.ScrolledText(
            left_inner,
            font=("Arial", 9),
            height=10,
            wrap="none",
            bg="#ffffff",
            fg=self.colors["text"],
            insertbackground=self.colors["text"],
            selectbackground="#c8dced",
            selectforeground="#172b3d",
            relief="solid",
            borderwidth=1
        )
        self.url_text.pack(
            fill="x"
        )

        tk.Label(
            left_inner,
            text="One URL per line",
            bg=self.colors["panel"],
            fg="#8a969f",
            font=("Arial", 8)
        ).pack(
            anchor="w",
            pady=(3, 8)
        )

        # ------------------------------------------------------------
        # Options
        # ------------------------------------------------------------

        options_bar = tk.Frame(
            left_inner,
            bg=self.colors["panel"]
        )
        options_bar.pack(
            fill="x",
            pady=(0, 5)
        )

        tk.Label(
            options_bar,
            text="Options",
            bg=self.colors["panel"],
            fg=self.colors["text"],
            font=("Arial", 9, "bold")
        ).pack(
            side="left"
        )

        self.options_button = tk.Button(
            options_bar,
            text="☰",
            width=3,
            height=1,
            bg=self.colors["button"],
            fg=self.colors["text"],
            activebackground=self.colors["button_hover"],
            activeforeground=self.colors["text"],
            relief="solid",
            borderwidth=1,
            font=("Arial", 10, "bold"),
            cursor="hand2",
            command=self.toggle_options
        )
        self.options_button.pack(
            side="right"
        )

        self.options_panel = tk.Frame(
            left_inner,
            bg="#f4f6f8",
            highlightbackground="#d6dde3",
            highlightthickness=1
        )

        self._option_check(
            self.options_panel,
            "Enable wordfilter bypass",
            self.enable_replacements
        )

        self._option_check(
            self.options_panel,
            "Scrape signatures",
            self.enable_signatures
        )

        self._option_check(
            self.options_panel,
            "Scrape user titles",
            self.enable_titles
        )

        self._option_check(
            self.options_panel,
            "Scrape user banners",
            self.enable_banners
        )

        # ------------------------------------------------------------
        # Progress
        # ------------------------------------------------------------

        tk.Label(
            left_inner,
            text="Queue Progress",
            bg=self.colors["panel"],
            fg=self.colors["text"],
            font=("Arial", 9, "bold")
        ).pack(
            anchor="w",
            pady=(7, 0)
        )

        self.progress = ttk.Progressbar(
            left_inner,
            orient="horizontal",
            mode="determinate",
            style="XF.Horizontal.TProgressbar"
        )
        self.progress.pack(
            fill="x",
            pady=(6, 5)
        )

        self.progress_label = tk.Label(
            left_inner,
            text="0 / 0 threads",
            bg=self.colors["panel"],
            fg=self.colors["muted"],
            font=("Arial", 8)
        )
        self.progress_label.pack(
            anchor="w"
        )

        # ------------------------------------------------------------
        # Buttons
        # ------------------------------------------------------------

        self.start_button = tk.Button(
            left_inner,
            text="Start Archiving",
            bg=self.colors["blue"],
            fg="#ffffff",
            activebackground=self.colors["blue_hover"],
            activeforeground="#ffffff",
            relief="flat",
            borderwidth=0,
            font=("Arial", 10, "bold"),
            cursor="hand2",
            padx=10,
            pady=7,
            command=self.start_archiving
        )
        self.start_button.pack(
            fill="x",
            pady=(12, 5)
        )

        self.stop_button = tk.Button(
            left_inner,
            text="Stop",
            bg=self.colors["button"],
            fg=self.colors["muted"],
            activebackground=self.colors["button_hover"],
            activeforeground=self.colors["text"],
            relief="solid",
            borderwidth=1,
            font=("Arial", 9, "bold"),
            cursor="hand2",
            state="disabled",
            command=self.stop_archiving
        )
        self.stop_button.pack(
            fill="x"
        )

        # ------------------------------------------------------------
        # Right panel / console
        # ------------------------------------------------------------

        right_panel = tk.Frame(
            content,
            bg=self.colors["panel"],
            highlightbackground=self.colors["border"],
            highlightthickness=1
        )
        right_panel.pack(
            side="left",
            fill="both",
            expand=True
        )

        right_inner = tk.Frame(
            right_panel,
            bg=self.colors["panel"]
        )
        right_inner.pack(
            fill="both",
            expand=True,
            padx=12,
            pady=12
        )

        console_header = tk.Frame(
            right_inner,
            bg=self.colors["panel"]
        )
        console_header.pack(
            fill="x",
            pady=(0, 6)
        )

        tk.Label(
            console_header,
            text="Archive Console",
            bg=self.colors["panel"],
            fg=self.colors["text"],
            font=("Arial", 11, "bold")
        ).pack(
            side="left"
        )

        self.console_status = tk.Label(
            console_header,
            text="Ready",
            bg=self.colors["panel"],
            fg=self.colors["muted"],
            font=("Arial", 8)
        )
        self.console_status.pack(
            side="right"
        )

        self.console = scrolledtext.ScrolledText(
            right_inner,
            font=self.console_font,
            wrap="word",
            bg=self.colors["console"],
            fg=self.colors["console_text"],
            insertbackground="#ffffff",
            selectbackground="#30465a",
            selectforeground="#ffffff",
            relief="solid",
            borderwidth=1,
            state="disabled"
        )
        self.console.pack(
            fill="both",
            expand=True
        )

        # Save the original streams and redirect print() to the GUI.
        self._stdout = sys.stdout
        self._stderr = sys.stderr

        self.console_redirector = ConsoleRedirector(
            self.console
        )

        sys.stdout = self.console_redirector
        sys.stderr = self.console_redirector

        self.root.after(
            100,
            self._update_ui_state
        )

    def _option_check(self, parent, text, variable):
        check = tk.Checkbutton(
            parent,
            text=text,
            variable=variable,
            bg="#f4f6f8",
            fg=self.colors["text"],
            activebackground="#f4f6f8",
            activeforeground=self.colors["text"],
            selectcolor="#ffffff",
            anchor="w",
            font=("Arial", 9),
            padx=7,
            pady=3
        )

        check.pack(
            fill="x"
        )

        return check

    def toggle_options(self):
        if self.options_panel.winfo_ismapped():
            self.options_panel.pack_forget()
        else:
            self.options_panel.pack(
                fill="x",
                pady=(0, 7)
            )

    def _set_status(self, text, color=None):
        def update():
            if self._closing:
                return

            self.header_status.config(
                text=text,
                fg=color or "#91c89a"
            )

        try:
            self.root.after(
                0,
                update
            )
        except Exception:
            pass

    def _set_console_status(self, text):
        def update():
            if self._closing:
                return

            self.console_status.config(
                text=text
            )

        try:
            self.root.after(
                0,
                update
            )
        except Exception:
            pass

    def _update_progress(self, current, total):
        def update():
            if self._closing:
                return

            self.progress["maximum"] = max(
                1,
                total
            )

            self.progress["value"] = min(
                current,
                total
            )

            self.progress_label.config(
                text=f"{current} / {total} threads"
            )

        try:
            self.root.after(
                0,
                update
            )
        except Exception:
            pass

    def _update_ui_state(self):
        if self._closing:
            return

        running = (
            self.archive_thread is not None
            and self.archive_thread.is_alive()
        )

        if running:
            self.start_button.config(
                state="disabled"
            )
            self.stop_button.config(
                state="normal"
            )
        else:
            self.start_button.config(
                state="normal"
            )
            self.stop_button.config(
                state="disabled"
            )

        try:
            self.root.after(
                250,
                self._update_ui_state
            )
        except Exception:
            pass

    def _get_urls(self):
        raw = self.url_text.get(
            "1.0",
            "end"
        ).strip()

        urls = []

        for line in raw.splitlines():
            line = line.strip()

            if not line:
                continue

            if not re.match(
                r"^https?://",
                line,
                re.I
            ):
                continue

            urls.append(line)

        return list(dict.fromkeys(urls))

    def start_archiving(self):
        if (
            self.archive_thread is not None
            and self.archive_thread.is_alive()
        ):
            return

        urls = self._get_urls()

        if not urls:
            messagebox.showwarning(
                "No URLs",
                "Enter at least one valid HTTP/HTTPS thread URL."
            )
            return

        self.stop_event.clear()

        self.progress["maximum"] = len(urls)
        self.progress["value"] = 0

        self.progress_label.config(
            text=f"0 / {len(urls)} threads"
        )

        self._set_status(
            "● Archiving",
            "#f0c674"
        )

        self._set_console_status(
            "Running"
        )

        print()
        print("=" * 72)
        print("Shroomchiver")
        print(f"Queued threads: {len(urls)}")
        print("=" * 72)

        self.archive_thread = threading.Thread(
            target=self._archive_worker,
            args=(urls,),
            daemon=True,
            name="ShroomchiverWorker"
        )

        self.archive_thread.start()

    def stop_archiving(self):
        if (
            self.archive_thread is None
            or not self.archive_thread.is_alive()
        ):
            return

        self.stop_event.set()

        self._set_status(
            "● Stopping…",
            "#e0a15d"
        )

        self._set_console_status(
            "Stopping"
        )

        print()
        print(
            "⚠ Stop requested. "
            "The current page/download operation will finish."
        )

    def _archive_worker(self, urls):
        global ENABLE_REPLACEMENTS
        global SCRAPE_SIGNATURES
        global SCRAPE_USER_TITLES
        global SCRAPE_USER_BANNERS

        ENABLE_REPLACEMENTS = (
            self.enable_replacements.get()
        )

        SCRAPE_SIGNATURES = (
            self.enable_signatures.get()
        )

        SCRAPE_USER_TITLES = (
            self.enable_titles.get()
        )

        SCRAPE_USER_BANNERS = (
            self.enable_banners.get()
        )

        total = len(urls)
        completed = 0
        driver = None

        try:
            print()
            print("Configuration:")
            print(
                "  Wordfilter bypass: "
                + (
                    "ON"
                    if ENABLE_REPLACEMENTS
                    else "OFF"
                )
            )
            print(
                "  Signatures: "
                + (
                    "ON"
                    if SCRAPE_SIGNATURES
                    else "OFF"
                )
            )
            print(
                "  User titles: "
                + (
                    "ON"
                    if SCRAPE_USER_TITLES
                    else "OFF"
                )
            )
            print(
                "  User banners: "
                + (
                    "ON"
                    if SCRAPE_USER_BANNERS
                    else "OFF"
                )
            )

            print()
            print("Starting Chrome...")

            driver = make_driver(
                PROFILE_DIR
            )

            self.driver = driver

            tab_handle = driver.current_window_handle

            for index, target_url in enumerate(
                urls,
                start=1
            ):
                if self.stop_event.is_set():
                    print(
                        "\n⚠ Archive stopped by user."
                    )
                    break

                print()
                print("=" * 72)
                print(
                    f"THREAD {index}/{total}"
                )
                print(target_url)
                print("=" * 72)

                try:
                    archive_thread(
                        target_url,
                        driver,
                        tab_handle
                    )

                    completed += 1

                    self._update_progress(
                        completed,
                        total
                    )

                    print(
                        f"\n✓ Completed thread "
                        f"{index}/{total}"
                    )

                except Exception as e:
                    print(
                        f"\n✗ Failed thread "
                        f"{index}/{total}: "
                        f"{type(e).__name__}: {e}"
                    )

                    import traceback
                    traceback.print_exc()

                    self._update_progress(
                        completed,
                        total
                    )

                    try:
                        driver.switch_to.window(
                            tab_handle
                        )
                    except Exception:
                        pass

            if self.stop_event.is_set():
                status = (
                    f"● Stopped — "
                    f"{completed}/{total} completed"
                )
                color = "#e0a15d"
                console_status = "Stopped"

            elif completed == total:
                status = (
                    f"● Complete — "
                    f"{completed}/{total}"
                )
                color = "#91c89a"
                console_status = "Complete"

            else:
                status = (
                    f"● Finished — "
                    f"{completed}/{total}"
                )
                color = "#e0a15d"
                console_status = "Finished"

            print()
            print("=" * 72)
            print(status)
            print("=" * 72)

            self._set_status(
                status,
                color
            )

            self._set_console_status(
                console_status
            )

        except Exception as e:
            print()
            print(
                f"✗ Fatal archiver error: "
                f"{type(e).__name__}: {e}"
            )

            import traceback
            traceback.print_exc()

            self._set_status(
                "● Error",
                "#d9534f"
            )

            self._set_console_status(
                "Error"
            )

        finally:
            if driver is not None:
                print(
                    "\nClosing Chrome..."
                )

                try:
                    driver.quit()
                except Exception:
                    pass

            self.driver = None

            if (
                not self.stop_event.is_set()
                and completed == total
            ):
                self._set_status(
                    "● Ready",
                    "#91c89a"
                )

            self._set_console_status(
                "Ready"
                if not self.stop_event.is_set()
                else "Stopped"
            )

    def clear_console(self):
        try:
            self.console.config(
                state="normal"
            )

            self.console.delete(
                "1.0",
                "end"
            )

            self.console.config(
                state="disabled"
            )

        except tk.TclError:
            pass

    def close(self):
        if self._closing:
            return

        self._closing = True
        self.stop_event.set()

        driver = self.driver

        if driver is not None:
            try:
                driver.quit()
            except Exception:
                pass

            self.driver = None

        # Restore stdout/stderr before destroying the widgets they use.
        sys.stdout = self._stdout
        sys.stderr = self._stderr

        try:
            self.root.destroy()
        except Exception:
            pass

    def run(self):
        try:
            self.root.mainloop()
        finally:
            if not self._closing:
                self.close()


if __name__ == "__main__":
    app = ThreadArchiverGUI()
    app.run()