#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Erzeugt aus mpc_obscode_snapshot.tsv eine sortierbare HTML-Statistik.

Wichtig:
  Das Skript ist defensiv gegen alte/beschaedigte TSV-Zeilen:
  - Datumsfelder werden nur angezeigt, wenn sie wirklich als Datum lesbar sind.
  - Wenn Name/NameUtf8/ShortName leer sind, wird ein plausibler Name aus anderen Feldern gesucht.
  - Mit --diagnose werden auffaellige Zeilen gemeldet.

Beispiele:
  python mpc_obscode_tsv_to_html.py
  python mpc_obscode_tsv_to_html.py --diagnose
"""

from __future__ import annotations

import argparse
import calendar
from collections import Counter
from concurrent.futures import ThreadPoolExecutor, as_completed
import json
import html
from html.parser import HTMLParser
import math
import re
import shutil
import subprocess
import sys
from io import BytesIO
from dataclasses import dataclass, field
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from pathlib import Path
from typing import Iterable
from urllib.parse import urlparse
from zipfile import ZIP_DEFLATED, ZipFile
import requests
from requests.exceptions import SSLError

try:
    requests.packages.urllib3.disable_warnings()  # type: ignore[attr-defined]
except Exception:
    pass


VERBOSE = True
PROGRESS_INTERVAL = 250


def log(message: str) -> None:
    if VERBOSE:
        print(f"[{datetime.now().strftime('%H:%M:%S')}] {message}", flush=True)


def log_progress(label: str, current: int, total: int | None = None) -> None:
    if total:
        log(f"{label}: {current}/{total}")
    else:
        log(f"{label}: {current}")


def utc_now_text() -> str:
    return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")


def iso_utc_now_text() -> str:
    return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")


def display_country_name(country_name: str) -> str:
    stripped_name = (country_name or "Unknown").strip()
    if stripped_name.lower() == "space":
        return "Space"
    if stripped_name.lower() == "rover":
        return "Rover"
    if stripped_name.lower() == "welt":
        return "World"
    return stripped_name


def country_filter_key(country_name: str) -> str:
    return display_country_name(country_name).strip() or "Unknown"


def title_lines(*parts: str) -> str:
    return "\n".join(part for part in parts if part)


def header_lines_html(*parts: str) -> str:
    line_html = "".join(f"<span>{html_escape(part)}</span>" for part in parts if part)
    return f'<span class="head-lines">{line_html}</span>'


SITE_NAV_ITEMS = [
    ("Overview", "index.html"),
    ("Generated data", "index.html#generated-data-first"),
    ("Main station table", "mpc_obscode_stats.html"),
    ("Country statistics", "mpc_code_stats_country.html"),
    ("Type statistics", "mpc_code_stats_type.html"),
    ("Status statistics", "mpc_code_stats_status.html"),
    ("Web link status", "mpc_web_link_status.html"),
    ("Daily changes", "mpc_daily_changes.html"),
    ("Doubles", "mpc_obscode_doubles.html"),
    ("KMZ station map", "mpc_obscode_stations.kmz"),
    ("Country list", "mpc_obscode_country_list.txt"),
    ("Technical files", "index.html#main-files"),
]


def site_sidebar_html(active_href: str = "", base_prefix: str = "") -> str:
    links = []
    for label, href in SITE_NAV_ITEMS:
        active_class = " active" if active_href and href == active_href else ""
        resolved_href = href if href.startswith(("http://", "https://", "#")) else f"{base_prefix}{href}"
        links.append(f'<a class="site-nav-link{active_class}" href="{html_escape(resolved_href)}">{html_escape(label)}</a>')
    title_href = f"{base_prefix}index.html#top"
    return (
        '<aside class="site-sidebar" aria-label="Site navigation">'
        f'<div class="site-sidebar-title"><a class="site-sidebar-title-link" href="{html_escape(title_href)}">MPC stations</a></div>'
        f'{"".join(links)}'
        "</aside>"
    )


def site_layout_css() -> str:
    return """
    :root {
      --site-sidebar-width: 230px;
    }
    .site-sidebar {
      position: fixed;
      left: 0;
      top: 0;
      bottom: 0;
      width: var(--site-sidebar-width);
      box-sizing: border-box;
      overflow-y: auto;
      padding: 1rem 0.8rem;
      border-right: 1px solid #cfd8e3;
      background: #102235;
      color: #f7fafc;
      z-index: 50;
    }
    .site-sidebar-title {
      font-weight: 700;
      margin: 0 0 0.75rem;
      color: #fff;
    }
    .site-sidebar-title a.site-sidebar-title-link {
      color: #fff;
      text-decoration: none;
      display: inline-block;
      padding: 0;
      border-radius: 0;
    }
    .site-sidebar-title a.site-sidebar-title-link:hover {
      background: transparent;
      color: #fff;
      text-decoration: underline;
    }
    .site-sidebar a.site-nav-link {
      display: block;
      color: #dce9f7;
      text-decoration: none;
      padding: 0.42rem 0.5rem;
      border-radius: 4px;
      line-height: 1.25;
    }
    .site-sidebar a.site-nav-link:hover,
    .site-sidebar a.site-nav-link.active {
      background: #1f4f78;
      color: #fff;
      text-decoration: none;
    }
    .page {
      margin-left: var(--site-sidebar-width) !important;
      width: auto !important;
      min-width: 0 !important;
      box-sizing: border-box;
    }
    .export-bar {
      display: flex;
      justify-content: flex-start;
      align-items: center;
      flex-wrap: wrap;
      gap: 0.35rem;
      margin: 0 0 0.65rem;
      max-width: 100%;
      box-sizing: border-box;
    }
    .export-label {
      font-weight: 700;
      margin-right: 0.25rem;
    }
    .export-button,
    .export-format {
      font: inherit;
      padding: 0.28rem 0.6rem;
      border: 1px solid #9baabe;
      border-radius: 4px;
      background: #fff;
      color: #132238;
      cursor: pointer;
      line-height: 1.2;
    }
    .export-button:hover,
    .export-format:hover {
      background: #eef4fb;
    }
    .export-format.active {
      background: #174a73;
      border-color: #174a73;
      color: #fff;
    }
    table[data-sortable-table="1"] th {
      cursor: pointer;
      user-select: none;
    }
    table[data-sortable-table="1"] th::after {
      content: " \\2195";
      color: #777;
      font-weight: normal;
    }
    table[data-sortable-table="1"] th.sort-asc::after {
      content: " \\2191";
      color: #111;
    }
    table[data-sortable-table="1"] th.sort-desc::after {
      content: " \\2193";
      color: #111;
    }
    .panel {
      box-sizing: border-box;
      max-width: 100%;
    }
    .country-cell {
      flex-wrap: wrap;
    }
    .country-cell span:last-child {
      min-width: 0;
      white-space: normal;
      overflow-wrap: normal;
      word-break: normal;
    }
    td.name,
    td.name a,
    td.country,
    td.country a {
      overflow-wrap: anywhere;
      word-break: break-word;
    }
    a.country-map-link {
      color: inherit;
      text-decoration: none;
    }
    a.country-map-link:hover {
      color: #0645ad;
      text-decoration: underline;
    }
    @media (max-width: 900px) {
      .site-sidebar {
        position: static;
        width: auto;
        display: flex;
        flex-wrap: wrap;
        gap: 0.25rem;
        border-right: 0;
        border-bottom: 1px solid #cfd8e3;
      }
      .site-sidebar-title {
        width: 100%;
      }
      .page {
        margin-left: 0 !important;
        width: auto;
      }
    }
    """


def table_export_button(table_id: str) -> str:
    escaped_table_id = html_escape(table_id)
    return (
        f'<div class="export-bar" data-export-panel="{escaped_table_id}">'
        '<span class="export-label">Export table</span>'
        '<button type="button" class="export-format" data-export-format="text">Text</button>'
        '<button type="button" class="export-format active" data-export-format="csv">CSV</button>'
        '<button type="button" class="export-format" data-export-format="xml">XML</button>'
        '<button type="button" class="export-format" data-export-format="json">JSON</button>'
        '<button type="button" class="export-format" data-export-format="excel">Excel</button>'
        f'<button type="button" class="export-button" data-export-table="{escaped_table_id}">Export selected</button>'
        "</div>"
    )


def table_export_script() -> str:
    return r"""
  <script>
    (function () {
      function cleanText(value) {
        return (value || "").replace(/\s+/g, " ").trim();
      }

      function visibleTableRows(table, sectionName) {
        const section = table.querySelector(sectionName);
        if (!section) return [];
        return Array.from(section.querySelectorAll("tr")).filter(row => !row.hidden && row.style.display !== "none");
      }

      function tableData(table) {
        const headers = visibleTableRows(table, "thead").flatMap(row =>
          Array.from(row.children).map(cell => cleanText(cell.innerText || cell.textContent))
        );
        const rows = visibleTableRows(table, "tbody").map(row =>
          Array.from(row.children).map(cell => cleanText(cell.innerText || cell.textContent))
        );
        return {headers, rows};
      }

      function downloadFile(filename, content, mimeType) {
        const blob = new Blob([content], {type: mimeType});
        const link = document.createElement("a");
        link.href = URL.createObjectURL(blob);
        link.download = filename;
        document.body.appendChild(link);
        link.click();
        URL.revokeObjectURL(link.href);
        link.remove();
      }

      function csvCell(value) {
        const text = String(value ?? "");
        if (/[",\r\n;]/.test(text)) {
          return '"' + text.replace(/"/g, '""') + '"';
        }
        return text;
      }

      function xmlName(value, fallback) {
        const name = String(value || fallback).replace(/[^A-Za-z0-9_]+/g, "_").replace(/^_+|_+$/g, "");
        return /^[A-Za-z_]/.test(name) ? name : "_" + name;
      }

      function tableToHtml(data) {
        const escape = value => String(value ?? "").replace(/[&<>"]/g, char => ({
          "&": "&amp;",
          "<": "&lt;",
          ">": "&gt;",
          '"': "&quot;"
        }[char]));
        const head = "<tr>" + data.headers.map(value => "<th>" + escape(value) + "</th>").join("") + "</tr>";
        const body = data.rows.map(row => "<tr>" + row.map(value => "<td>" + escape(value) + "</td>").join("") + "</tr>").join("");
        return '<html><head><meta charset="utf-8"></head><body><table>' + head + body + "</table></body></html>";
      }

      function exportTable(table, format) {
        const data = tableData(table);
        const baseName = (document.title || table.id || "table").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "table";
        if (format === "text") {
          const content = [data.headers.join("\t"), ...data.rows.map(row => row.join("\t"))].join("\n");
          downloadFile(baseName + ".txt", content, "text/plain;charset=utf-8");
          return;
        }
        if (format === "csv") {
          const content = [data.headers.map(csvCell).join(";"), ...data.rows.map(row => row.map(csvCell).join(";"))].join("\n");
          downloadFile(baseName + ".csv", content, "text/csv;charset=utf-8");
          return;
        }
        if (format === "json") {
          const content = JSON.stringify(data.rows.map(row => Object.fromEntries(data.headers.map((header, index) => [header || "Column " + (index + 1), row[index] || ""]))), null, 2);
          downloadFile(baseName + ".json", content, "application/json;charset=utf-8");
          return;
        }
        if (format === "xml") {
          const content = "<rows>\n" + data.rows.map(row => {
            const cells = data.headers.map((header, index) => {
              const name = xmlName(header, "Column_" + (index + 1));
              const value = String(row[index] || "").replace(/[&<>]/g, char => ({"&": "&amp;", "<": "&lt;", ">": "&gt;"}[char]));
              return "    <" + name + ">" + value + "</" + name + ">";
            }).join("\n");
            return "  <row>\n" + cells + "\n  </row>";
          }).join("\n") + "\n</rows>\n";
          downloadFile(baseName + ".xml", content, "application/xml;charset=utf-8");
          return;
        }
        if (format === "excel") {
          downloadFile(baseName + ".xls", tableToHtml(data), "application/vnd.ms-excel;charset=utf-8");
        }
      }

      function compareSortableCells(rowA, rowB, columnIndex, direction, type) {
        const cellA = rowA.children[columnIndex];
        const cellB = rowB.children[columnIndex];
        const valueA = cleanText(cellA?.dataset.sort || cellA?.textContent || "");
        const valueB = cleanText(cellB?.dataset.sort || cellB?.textContent || "");
        let comparison = 0;
        if (type === "number") {
          const numberA = Number(valueA.replace(/\./g, "").replace(",", "."));
          const numberB = Number(valueB.replace(/\./g, "").replace(",", "."));
          comparison = (Number.isFinite(numberA) ? numberA : -Infinity) - (Number.isFinite(numberB) ? numberB : -Infinity);
        } else if (type === "date") {
          comparison = valueA.localeCompare(valueB);
        } else {
          comparison = valueA.localeCompare(valueB, "de", {numeric: true, sensitivity: "base"});
        }
        return direction === "asc" ? comparison : -comparison;
      }

      function setupSortableTables() {
        document.querySelectorAll('table[data-sortable-table="1"]').forEach(table => {
          const tbody = table.querySelector("tbody");
          if (!tbody) return;
          const headers = Array.from(table.querySelectorAll("th"));
          headers.forEach((header, columnIndex) => {
            header.addEventListener("click", () => {
              const direction = header.classList.contains("sort-asc") ? "desc" : "asc";
              headers.forEach(item => item.classList.remove("sort-asc", "sort-desc"));
              header.classList.add(direction === "asc" ? "sort-asc" : "sort-desc");
              const rows = Array.from(tbody.querySelectorAll("tr")).filter(row => row.children.length === headers.length);
              rows.sort((rowA, rowB) => compareSortableCells(rowA, rowB, columnIndex, direction, header.dataset.type || "text"));
              rows.forEach(row => tbody.appendChild(row));
            });
          });
        });
      }

      document.addEventListener("click", event => {
        const formatButton = event.target.closest("[data-export-format]");
        if (formatButton) {
          formatButton.classList.toggle("active");
          return;
        }
        const button = event.target.closest("[data-export-table]");
        if (!button) return;
        const table = document.getElementById(button.dataset.exportTable || "");
        if (!table) return;
        const panel = button.closest("[data-export-panel]");
        const formats = Array.from(panel ? panel.querySelectorAll("[data-export-format].active") : [])
          .map(item => item.dataset.exportFormat)
          .filter(format => ["text", "csv", "xml", "json", "excel"].includes(format));
        if (!formats.length) {
          alert("Please select at least one export format.");
          return;
        }
        formats.forEach(format => exportTable(table, format));
      });
      setupSortableTables();
    })();
  </script>
"""


@dataclass
class ObsCodeRecord:
    obscode: str
    last_checked_date: str
    longitude: str
    rhocosphi: str
    rhosinphi: str
    name: str
    name_utf8: str
    name_latex: str
    short_name: str
    first_date: str
    last_date: str
    web_link: str
    created_at: str
    updated_at: str
    uses_two_line_observations: str
    old_names_json: str
    observations_type: str
    status_text: str
    last_error_code: str
    raw_fields: list[str] = field(default_factory=list)
    line_number: int = 0
    country_name: str = ""
    country_iso2: str = ""
    country_reason: str = ""
    observations_count: int | None = None
    jost_first_date: str = ""
    jost_last_date: str = ""
    web_link_status: "WebLinkStatus | None" = None

    @property
    def display_name(self) -> str:
        for direct_name in (self.name_utf8, self.name, self.short_name):
            cleaned_name = clean_display_text(direct_name)
            if cleaned_name:
                return cleaned_name
        return clean_display_text(fallback_name_from_fields(self.raw_fields))

    @property
    def sort_datetime(self) -> datetime:
        return parse_mpc_datetime(self.updated_at) or parse_mpc_datetime(self.created_at) or datetime.min

    @property
    def sort_date_text(self) -> str:
        if parse_mpc_datetime(self.updated_at):
            return self.updated_at.strip()
        if parse_mpc_datetime(self.created_at):
            return self.created_at.strip()
        return ""


@dataclass
class CountryBoundary:
    name: str
    iso2: str
    iso3: str
    polygons: list[list[list[tuple[float, float]]]]
    bounds: tuple[float, float, float, float]


@dataclass
class WebLinkStatus:
    url: str
    checked_at_utc: str
    reachable: bool
    status_code: int | None = None
    final_url: str = ""
    error: str = ""


@dataclass
class StationIndexEntry:
    code: str
    first_date: str
    last_date: str
    observations_count: int | None
    station_name: str = ""


COUNTRY_BOUNDARY_SOURCE_URL = "https://datahub.io/core/geo-countries/_r/-/data/countries.geojson"
COUNTRY_BOUNDARY_SOURCE_PAGE = "https://github.com/datasets/geo-countries"
FLAG_ICON_SOURCE_PAGE = "https://github.com/lipis/flag-icons"
JOST_STATION_INDEX_URL = "https://www.jostjahn.de/stations/index.html"
COUNTRY_BOUNDARY_CACHE_PATH = Path("data/countries.geojson")
FLAG_ICON_CACHE_DIR = Path("flags/4x3")
WEB_LINK_STATUS_CACHE_PATH = Path("web_link_status_cache.json")
OBSERVATION_COUNT_SOURCE_PATH = Path("jost_station_observation_counts.json")
UNMATCHED_OBSERVATION_COUNT_PATH = Path("jost_station_observation_counts_unmatched.txt")
MPC_SYNC_EXE_NAME = "mpc_obscode_api.exe"
DEFAULT_MPC_SYNC_REQUEST_DELAY_MS = 50
DEFAULT_MPC_SYNC_MAX_AGE_DAYS = 0
WEB_LINK_TIMEOUT_SECONDS = 8
WEB_LINK_MAX_WORKERS = 18
HTTP_USER_AGENT = "mpc-stations-generator/1.0 (+https://www.jostjahn.de/stations/)"
GOOGLE_MAPS_SATELLITE_ZOOM = 19
WGS84_A_KM = 6378.137
COUNTRY_NAME_TO_ISO2 = {
    "Czechia": "cz",
    "Algeria": "dz",
    "China": "cn",
    "Denmark": "dk",
    "England": "gb-eng",
    "France": "fr",
    "Germany": "de",
    "France": "fr",
    "Indonesia": "id",
    "Italy": "it",
    "Japan": "jp",
    "New Zealand": "nz",
    "Northern Cyprus": "nc",
    "Norway": "no",
    "Rover": "rover",
    "South Africa": "za",
    "Spain": "es",
    "Sweden": "se",
    "Taiwan": "tw",
    "Ukraine": "ua",
    "United Kingdom": "gb",
    "United States of America": "us",
    "USA": "us",
    "South Korea": "kr",
    "North Korea": "kp",
    "Russia": "ru",
    "Republic of Serbia": "rs",
    "Hong Kong S.A.R.": "hk",
    "Macau S.A.R.": "mo",
    "Venezuela": "ve",
    "Tanzania": "tz",
    "Laos": "la",
    "Moldova": "md",
    "Bolivia": "bo",
    "Portugal": "pt",
    "Poland": "pl",
    "Austria": "at",
    "Switzerland": "ch",
    "Belgium": "be",
    "Netherlands": "nl",
    "Luxembourg": "lu",
    "Ireland": "ie",
    "Iceland": "is",
    "Finland": "fi",
    "Czech Republic": "cz",
    "Slovakia": "sk",
    "Japan": "jp",
    "Mexico": "mx",
    "Canada": "ca",
    "Argentina": "ar",
    "Brazil": "br",
    "Chile": "cl",
    "Peru": "pe",
    "Uruguay": "uy",
    "Paraguay": "py",
    "Colombia": "co",
}
SPACE_FLAG_ISO2 = "space"
COUNTRY_CACHE_PATH = Path("country_cache.json")
COUNTRY_OVERRIDE_PATH = Path("country_overrides.txt")
WGS84_E2 = 0.0066943799901413165
COUNTRY_MAP_DIR = Path("country_maps")
MAP_MOCKUP_DIR = Path("map_mockups")
MAP_MARKER_MIN_RADIUS_PX = 1.0
MAP_MARKER_MAX_RADIUS_PX = 15.0
UKRAINE_CRIMEA_DISPLAY_RING = [
    (32.30, 46.18),
    (33.05, 46.08),
    (34.18, 45.98),
    (35.28, 45.70),
    (36.66, 45.38),
    (36.50, 44.85),
    (35.55, 44.42),
    (34.42, 44.37),
    (33.56, 44.51),
    (32.84, 44.82),
    (32.46, 45.25),
    (32.30, 46.18),
]

COUNTRY_GUESS_ALIASES: list[tuple[str, str, str]] = [
    ("taiwan", "Taiwan", "tw"),
    ("norway", "Norway", "no"),
    ("northern cyprus", "Northern Cyprus", "nc"),
    ("france", "France", "fr"),
    ("germany", "Germany", "de"),
    ("japan", "Japan", "jp"),
    ("italy", "Italy", "it"),
    ("spain", "Spain", "es"),
    ("portugal", "Portugal", "pt"),
    ("sweden", "Sweden", "se"),
    ("finland", "Finland", "fi"),
    ("denmark", "Denmark", "dk"),
    ("poland", "Poland", "pl"),
    ("czech", "Czech Republic", "cz"),
    ("slovakia", "Slovakia", "sk"),
    ("austria", "Austria", "at"),
    ("switzerland", "Switzerland", "ch"),
    ("belgium", "Belgium", "be"),
    ("netherlands", "Netherlands", "nl"),
    ("luxembourg", "Luxembourg", "lu"),
    ("ireland", "Ireland", "ie"),
    ("iceland", "Iceland", "is"),
    ("chile", "Chile", "cl"),
    ("argentina", "Argentina", "ar"),
    ("brazil", "Brazil", "br"),
    ("peru", "Peru", "pe"),
    ("bolivia", "Bolivia", "bo"),
    ("uruguay", "Uruguay", "uy"),
    ("paraguay", "Paraguay", "py"),
    ("colombia", "Colombia", "co"),
    ("mexico", "Mexico", "mx"),
    ("canada", "Canada", "ca"),
    ("united states", "United States of America", "us"),
    ("usa", "United States of America", "us"),
    ("uk", "United Kingdom", "gb"),
]
COUNTRY_TLD_GUESSES: dict[str, tuple[str, str]] = {
    ".ar": ("Argentina", "ar"),
    ".at": ("Austria", "at"),
    ".au": ("Australia", "au"),
    ".be": ("Belgium", "be"),
    ".br": ("Brazil", "br"),
    ".ca": ("Canada", "ca"),
    ".ch": ("Switzerland", "ch"),
    ".cl": ("Chile", "cl"),
    ".cz": ("Czech Republic", "cz"),
    ".de": ("Germany", "de"),
    ".dk": ("Denmark", "dk"),
    ".es": ("Spain", "es"),
    ".fi": ("Finland", "fi"),
    ".fr": ("France", "fr"),
    ".gb": ("United Kingdom", "gb"),
    ".hk": ("Hong Kong S.A.R.", "hk"),
    ".ie": ("Ireland", "ie"),
    ".is": ("Iceland", "is"),
    ".it": ("Italy", "it"),
    ".jp": ("Japan", "jp"),
    ".la": ("Laos", "la"),
    ".lu": ("Luxembourg", "lu"),
    ".md": ("Moldova", "md"),
    ".mx": ("Mexico", "mx"),
    ".nl": ("Netherlands", "nl"),
    ".no": ("Norway", "no"),
    ".pl": ("Poland", "pl"),
    ".pt": ("Portugal", "pt"),
    ".rs": ("Republic of Serbia", "rs"),
    ".se": ("Sweden", "se"),
    ".sk": ("Slovakia", "sk"),
    ".tw": ("Taiwan", "tw"),
    ".uk": ("United Kingdom", "gb"),
    ".uy": ("Uruguay", "uy"),
    ".ve": ("Venezuela", "ve"),
    ".za": ("South Africa", "za"),
}

TYPE_EXPLANATIONS = {
    "optical": "ground-based or space-based optical astrometry.",
    "radar": "radar astrometry or radar-ranging station data.",
    "spacecraft": "spacecraft or mission-related observing source.",
    "satellite": "artificial satellite or space-based symbolic entry.",
    "unknown": "type is not explicitly classified in the source data.",
    "(leer)": "empty type value in the source data.",
}


def is_numeric_text(value: str) -> bool:
    value = value.strip()
    if not value:
        return False
    try:
        float(value)
        return True
    except ValueError:
        return False


def is_probably_code(value: str) -> bool:
    return re.fullmatch(r"[0-9A-Z][0-9][0-9]", value.strip().upper()) is not None


def is_probably_date(value: str) -> bool:
    return parse_mpc_datetime(value) is not None


def fallback_name_from_fields(fields: list[str]) -> str:
    rejected = {
        "optical",
        "occultation",
        "satellite",
        "radar",
        "roving",
        "new",
        "changed",
        "same",
        "missing_from_list",
    }

    candidates: list[str] = []
    for value in fields:
        candidate = value.strip()
        lowered = candidate.lower()

        if not candidate:
            continue
        if candidate in rejected or lowered in rejected:
            continue
        if is_probably_code(candidate):
            continue
        if is_numeric_text(candidate):
            continue
        if is_probably_date(candidate):
            continue
        if candidate.startswith("http://") or candidate.startswith("https://"):
            continue
        if "gmt" in lowered and "," in candidate:
            continue
        if len(candidate) < 3:
            continue
        if not re.search(r"[A-Za-z]", candidate):
            continue

        candidates.append(candidate)

    if not candidates:
        return ""

    return max(candidates, key=len)


def parse_mpc_datetime(value: str) -> datetime | None:
    value = value.strip()
    if not value:
        return None

    parsers = (
        lambda text: parsedate_to_datetime(text).replace(tzinfo=None),
        lambda text: datetime.strptime(text, "%Y-%m-%d %H:%M:%S"),
        lambda text: datetime.strptime(text, "%Y-%m-%d"),
        lambda text: datetime.strptime(text, "%Y%m%d"),
    )

    for parser in parsers:
        try:
            return parser(value)
        except Exception:
            pass

    return None


def format_datetime(value: str) -> str:
    value = value.strip()
    if not value:
        return ""

    parsed_value = parse_mpc_datetime(value)
    if parsed_value is None:
        return ""

    has_time = (
        ":" in value
        or "GMT" in value.upper()
        or "UTC" in value.upper()
        or "," in value
        or "T" in value
    )

    if has_time:
        return parsed_value.strftime("%Y-%m-%d %H:%M:%S")

    return parsed_value.strftime("%Y-%m-%d")


def format_date_only(value: str) -> str:
    parsed_value = parse_mpc_datetime(value)
    if parsed_value is None:
        return ""
    return parsed_value.strftime("%Y-%m-%d")


def sort_key_datetime(value: str) -> str:
    parsed_value = parse_mpc_datetime(value)
    if parsed_value is None:
        return ""
    return parsed_value.strftime("%Y-%m-%d %H:%M:%S")


def format_integer_with_dots(value: int | None) -> str:
    if value is None:
        return ""
    return f"{value:,}".replace(",", ".")


def normalize_fields(fields: list[str]) -> list[str]:
    fields = list(fields)
    while len(fields) < 19:
        fields.append("")
    return fields


def record_has_suspicious_dates(record: ObsCodeRecord) -> bool:
    suspicious_values = [
        record.created_at,
        record.updated_at,
        record.first_date,
        record.last_date,
        record.last_checked_date,
    ]
    return any(value.strip() and parse_mpc_datetime(value) is None for value in suspicious_values)


def read_snapshot(snapshot_path: Path, diagnose: bool = False) -> list[ObsCodeRecord]:
    log(f"Lese TSV-Snapshot: {snapshot_path}")
    records: list[ObsCodeRecord] = []
    skipped_rows = 0
    suspicious_rows = 0

    with snapshot_path.open("r", encoding="utf-8", errors="replace") as snapshot_file:
        for line_number, raw_line in enumerate(snapshot_file, start=1):
            line = raw_line.rstrip("\n\r")
            if not line.strip():
                continue

            fields = line.split("\t")
            if len(fields) < 18:
                skipped_rows += 1
                if diagnose:
                    print(f"Warnung: Zeile {line_number} hat nur {len(fields)} Felder und wird uebersprungen.")
                continue

            normalized_fields = normalize_fields(fields)

            record = ObsCodeRecord(
                obscode=normalized_fields[0],
                last_checked_date=normalized_fields[1],
                longitude=normalized_fields[2],
                rhocosphi=normalized_fields[3],
                rhosinphi=normalized_fields[4],
                name=normalized_fields[5],
                name_utf8=normalized_fields[6],
                name_latex=normalized_fields[7],
                short_name=normalized_fields[8],
                first_date=normalized_fields[9],
                last_date=normalized_fields[10],
                web_link=normalized_fields[11],
                created_at=normalized_fields[12],
                updated_at=normalized_fields[13],
                uses_two_line_observations=normalized_fields[14],
                old_names_json=normalized_fields[15],
                observations_type=normalized_fields[16],
                status_text=normalized_fields[17],
                last_error_code=normalized_fields[18],
                raw_fields=normalized_fields,
                line_number=line_number,
            )

            if record_has_suspicious_dates(record):
                suspicious_rows += 1
                if diagnose:
                    print(
                        "Warnung: Zeile "
                        f"{line_number} hat unplausible Datumsfelder "
                        f"(CreatedAt={record.created_at!r}, UpdatedAt={record.updated_at!r})."
                    )

            records.append(record)
            if len(records) % PROGRESS_INTERVAL == 0:
                log_progress("TSV-Datensaetze gelesen", len(records))

    if diagnose:
        print(f"Uebersprungene Zeilen: {skipped_rows}")
        print(f"Auffaellige Datensaetze: {suspicious_rows}")

    log(f"TSV-Snapshot gelesen: {len(records)} Datensaetze, {skipped_rows} Zeilen uebersprungen")
    return records


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


def html_escape_with_breaks(value: str) -> str:
    parts: list[str] = []
    for char in value.strip():
        parts.append(html.escape(char, quote=True))
        if not char.isalpha() and not char.isdigit():
            parts.append("<wbr>")
    return "".join(parts)


def clean_display_text(value: str) -> str:
    cleaned = value.strip()
    if not cleaned:
        return ""

    def replace_unicode_fragment(match: re.Match[str]) -> str:
        return chr(int(match.group(1), 16))

    return re.sub(r"(?<!\\)u([0-9a-fA-F]{4})", replace_unicode_fragment, cleaned)


def ensure_downloaded_file(url: str, target_path: Path) -> None:
    if target_path.exists():
        log(f"Cache vorhanden: {target_path}")
        return

    log(f"Lade Datei herunter: {url}")
    target_path.parent.mkdir(parents=True, exist_ok=True)
    response = requests.get(url, timeout=120)
    response.raise_for_status()
    target_path.write_bytes(response.content)
    log(f"Download gespeichert: {target_path} ({target_path.stat().st_size} Bytes)")


def normalize_longitude(value: float) -> float:
    while value > 180.0:
        value -= 360.0
    while value <= -180.0:
        value += 360.0
    return value


def parse_float_text(value: str) -> float | None:
    try:
        return float(value.strip())
    except Exception:
        return None


def derive_geocentric_latitude(record: ObsCodeRecord) -> float | None:
    rhocosphi = parse_float_text(record.rhocosphi)
    rhosinphi = parse_float_text(record.rhosinphi)
    if rhocosphi is None or rhosinphi is None:
        return None
    return math.degrees(math.atan2(rhosinphi, rhocosphi))


def derive_geodetic_latitude(record: ObsCodeRecord) -> float | None:
    geocentric_latitude = derive_geocentric_latitude(record)
    if geocentric_latitude is None:
        return None
    theta = math.radians(geocentric_latitude)
    geodetic_radians = math.atan2(math.sin(theta), math.cos(theta) * (1.0 - WGS84_E2))
    return math.degrees(geodetic_radians)


def derive_height_meters(record: ObsCodeRecord) -> float | None:
    rhocosphi = parse_float_text(record.rhocosphi)
    rhosinphi = parse_float_text(record.rhosinphi)
    latitude = derive_geodetic_latitude(record)
    if rhocosphi is None or rhosinphi is None or latitude is None:
        return None

    latitude_radians = math.radians(latitude)
    sin_lat = math.sin(latitude_radians)
    cos_lat = math.cos(latitude_radians)
    prime_vertical_radius = WGS84_A_KM / math.sqrt(1.0 - WGS84_E2 * sin_lat * sin_lat)
    p = WGS84_A_KM * rhocosphi
    z = WGS84_A_KM * rhosinphi

    if abs(cos_lat) > 1e-9:
        height_km = (p / cos_lat) - prime_vertical_radius
    elif abs(sin_lat) > 1e-9:
        height_km = (z / sin_lat) - (prime_vertical_radius * (1.0 - WGS84_E2))
    else:
        return None
    return height_km * 1000.0


def format_degrees(value: float | None, positive_suffix: str, negative_suffix: str) -> str:
    if value is None:
        return "unknown"
    suffix = positive_suffix if value >= 0 else negative_suffix
    return f"{abs(value):.6f} {suffix}"


def format_height_meters(value: float | None) -> str:
    if value is None:
        return ""
    return f"{round(value):d}"


def format_compass_dms(value: float | None, positive_suffix: str, negative_suffix: str, degree_width: int) -> str:
    if value is None:
        return "unknown"
    suffix = positive_suffix if value >= 0 else negative_suffix
    absolute_value = min(abs(value), 180.0 if degree_width == 3 else 90.0)
    total_seconds = int(round(absolute_value * 3600.0))
    max_seconds = (180 if degree_width == 3 else 90) * 3600
    total_seconds = min(total_seconds, max_seconds)
    degrees = total_seconds // 3600
    minutes = (total_seconds % 3600) // 60
    seconds = total_seconds % 60
    return f"{degrees:>{degree_width}d}:{minutes:02d}:{seconds:02d} {suffix}"


def format_country_list_height(value: float | None) -> str:
    if value is None:
        return "    "
    return f"{round(value):>4d}"


def elevation_is_unrealistic(record: ObsCodeRecord) -> bool:
    height_meters = derive_height_meters(record)
    return height_meters is not None and (height_meters < 0.0 or height_meters > 8000.0)


def station_geometry_title(record: ObsCodeRecord) -> str:
    longitude = parse_float_text(record.longitude)
    if longitude is not None:
        longitude = normalize_longitude(longitude)
    latitude = derive_geodetic_latitude(record)
    height_meters = derive_height_meters(record)
    height_text = f"{round(height_meters):d} m" if height_meters is not None else "unknown"
    warning_text = ""
    if elevation_is_unrealistic(record):
        warning_text = (
            "Coordinate warning: the coordinates were incorrectly specified by the user "
            "because they produce an unrealistic elevation."
        )
    return title_lines(
        f"Longitude: {format_degrees(longitude, 'E', 'W')}",
        f"Longitude (DMS): {format_compass_dms(longitude, 'E', 'W', 3)}",
        f"Latitude: {format_degrees(latitude, 'N', 'S')}",
        f"Latitude (DMS): {format_compass_dms(latitude, 'N', 'S', 2)}",
        f"Elevation: {height_text}",
        f"Click opens a Google Maps satellite view at about a 250 m map width.",
        warning_text,
    )


def normalize_longitude_360(value: float) -> float:
    while value < 0.0:
        value += 360.0
    while value >= 360.0:
        value -= 360.0
    return value


def station_satellite_url(record: ObsCodeRecord) -> str:
    latitude = derive_geodetic_latitude(record)
    longitude = parse_float_text(record.longitude)
    if latitude is None or longitude is None:
        return ""
    longitude_geo = normalize_longitude(longitude)
    return f"https://www.google.com/maps/@{latitude:.8f},{longitude_geo:.8f},{GOOGLE_MAPS_SATELLITE_ZOOM}z/data=!3m1!1e3"


def station_name_css_class(record: ObsCodeRecord) -> str:
    css_class = "name"
    if elevation_is_unrealistic(record):
        css_class += " name-elevation-alert"
    return css_class


def station_name_link_html(record: ObsCodeRecord) -> str:
    display_name = html_escape_with_breaks(record.display_name)
    satellite_url = station_satellite_url(record)
    if not satellite_url:
        return display_name
    return f'<a href="{html_escape(satellite_url)}">{display_name}</a>'


def station_name_cell(record: ObsCodeRecord) -> str:
    return (
        f'<td class="{station_name_css_class(record)}" '
        f'data-sort="{html_escape(record.display_name)}" '
        f'title="{html_escape(station_geometry_title(record))}">'
        f'{station_name_link_html(record)}</td>'
    )


def find_mpc_sync_exe() -> Path | None:
    candidates = [Path.cwd() / MPC_SYNC_EXE_NAME]
    if getattr(sys, "frozen", False):
        candidates.append(Path(sys.executable).resolve().parent / MPC_SYNC_EXE_NAME)
    else:
        candidates.append(Path(__file__).resolve().parent / MPC_SYNC_EXE_NAME)

    seen: set[Path] = set()
    for candidate in candidates:
        resolved = candidate.resolve()
        if resolved in seen:
            continue
        seen.add(resolved)
        if resolved.exists():
            return resolved
    return None


def run_mpc_sync_before_generation(enabled: bool, request_delay_ms: int, max_age_days: int) -> bool:
    if not enabled:
        log("MPC-Sync vor HTML-Erzeugung: uebersprungen")
        return True

    sync_exe = find_mpc_sync_exe()
    if sync_exe is None:
        print(
            f"Fehler: {MPC_SYNC_EXE_NAME} nicht gefunden. "
            "Der Generator bricht ab, damit keine veralteten MPC-Daten verwendet werden."
        )
        return False

    command = [str(sync_exe), "--sync", f"--max-age-days={max_age_days}", f"--request-delay-ms={request_delay_ms}"]
    log(f"MPC-Sync vor HTML-Erzeugung: {sync_exe}")
    log(f"MPC-Sync-Befehl: {' '.join(command)}")
    completed = subprocess.run(command, cwd=Path.cwd())
    if completed.returncode != 0:
        print(f"Fehler: MPC-Sync fehlgeschlagen, Exit-Code {completed.returncode}")
        return False
    log("MPC-Sync vor HTML-Erzeugung abgeschlossen")
    return True


def point_in_ring(lon: float, lat: float, ring: list[tuple[float, float]]) -> bool:
    if len(ring) < 3:
        return False

    inside = False
    previous_x, previous_y = ring[-1]
    for current_x, current_y in ring:
        if ((current_y > lat) != (previous_y > lat)) and (
            lon < (previous_x - current_x) * (lat - current_y) / ((previous_y - current_y) or 1e-20) + current_x
        ):
            inside = not inside
        previous_x, previous_y = current_x, current_y
    return inside


def point_in_polygon(lon: float, lat: float, polygon: list[list[tuple[float, float]]]) -> bool:
    if not polygon:
        return False
    if not point_in_ring(lon, lat, polygon[0]):
        return False
    for hole in polygon[1:]:
        if point_in_ring(lon, lat, hole):
            return False
    return True


def load_country_boundaries() -> list[CountryBoundary]:
    log("Lade Laendergrenzen")
    ensure_downloaded_file(COUNTRY_BOUNDARY_SOURCE_URL, COUNTRY_BOUNDARY_CACHE_PATH)
    raw_data = json.loads(COUNTRY_BOUNDARY_CACHE_PATH.read_text(encoding="utf-8"))
    boundaries: list[CountryBoundary] = []

    for feature in raw_data.get("features", []):
        properties = feature.get("properties", {})
        geometry = feature.get("geometry", {})
        country_name = (
            properties.get("name")
            or properties.get("NAME")
            or properties.get("ADMIN")
            or properties.get("country")
            or ""
        ).strip()
        iso2 = (
            properties.get("ISO3166-1-Alpha-2")
            or properties.get("ISO_A2")
            or properties.get("iso_a2")
            or ""
        ).strip().lower()
        iso3 = (
            properties.get("ISO3166-1-Alpha-3")
            or properties.get("ISO_A3")
            or properties.get("iso_a3")
            or ""
        ).strip().upper()

        if iso2 in {"-99", "xx", ""}:
            iso2 = ""

        geom_type = geometry.get("type", "")
        coordinates = geometry.get("coordinates", [])
        polygons: list[list[list[tuple[float, float]]]] = []
        bounds = [180.0, 90.0, -180.0, -90.0]

        def update_bounds(x: float, y: float) -> None:
            bounds[0] = min(bounds[0], x)
            bounds[1] = min(bounds[1], y)
            bounds[2] = max(bounds[2], x)
            bounds[3] = max(bounds[3], y)

        if geom_type == "Polygon":
            polygon: list[list[tuple[float, float]]] = []
            for ring in coordinates:
                ring_points: list[tuple[float, float]] = []
                for x, y in ring:
                    x = normalize_longitude(float(x))
                    y = float(y)
                    update_bounds(x, y)
                    ring_points.append((x, y))
                polygon.append(ring_points)
            if polygon:
                polygons.append(polygon)
        elif geom_type == "MultiPolygon":
            for polygon_coords in coordinates:
                polygon: list[list[tuple[float, float]]] = []
                for ring in polygon_coords:
                    ring_points: list[tuple[float, float]] = []
                    for x, y in ring:
                        x = normalize_longitude(float(x))
                        y = float(y)
                        update_bounds(x, y)
                        ring_points.append((x, y))
                    polygon.append(ring_points)
                if polygon:
                    polygons.append(polygon)

        if polygons and country_name:
            boundaries.append(
                CountryBoundary(
                    name=country_name,
                    iso2=iso2,
                    iso3=iso3,
                    polygons=polygons,
                    bounds=tuple(bounds),
                )
            )

    log(f"Laendergrenzen geladen: {len(boundaries)} Eintraege")
    return boundaries


def lookup_country(lon: float, lat: float, boundaries: list[CountryBoundary]) -> CountryBoundary | None:
    for boundary in boundaries:
        min_lon, min_lat, max_lon, max_lat = boundary.bounds
        if lon < min_lon or lon > max_lon or lat < min_lat or lat > max_lat:
            continue
        for polygon in boundary.polygons:
            if point_in_polygon(lon, lat, polygon):
                return boundary
    return None


def ensure_flag_icon(iso2: str) -> Path | None:
    code = iso2.strip().lower()
    if not code:
        return None

    if code == SPACE_FLAG_ISO2:
        target_path = FLAG_ICON_CACHE_DIR / f"{code}.svg"
        if target_path.exists():
            return target_path
        target_path.parent.mkdir(parents=True, exist_ok=True)
        target_path.write_text(
            """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2">
  <rect width="3" height="2" fill="#000"/>
  <rect y="0.68" width="3" height="0.64" fill="#9b1d20"/>
  <circle cx="1.5" cy="1" r="0.26" fill="#d9b24c"/>
  <path d="M1.1,0.66 L1.5,1 L1.9,0.66" fill="none" stroke="#d9b24c" stroke-width="0.08"/>
  <path d="M1.1,1.34 L1.5,1 L1.9,1.34" fill="none" stroke="#d9b24c" stroke-width="0.08"/>
</svg>
""",
            encoding="utf-8",
        )
        return target_path

    if code == "rover":
        target_path = FLAG_ICON_CACHE_DIR / "rover.svg"
        if target_path.exists():
            return target_path
        target_path.parent.mkdir(parents=True, exist_ok=True)
        target_path.write_text(
            """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2">
  <rect width="3" height="2" fill="#1f2937"/>
  <circle cx="1" cy="1.35" r="0.28" fill="#e5e7eb"/>
  <circle cx="2.1" cy="1.35" r="0.28" fill="#e5e7eb"/>
  <rect x="0.7" y="0.75" width="1.55" height="0.48" rx="0.12" fill="#9ca3af"/>
  <path d="M0.95 0.75 L1.2 0.42 H1.75 L1.95 0.75 Z" fill="#d1d5db"/>
</svg>
""",
            encoding="utf-8",
        )
        return target_path

    if code == "nc":
        target_path = FLAG_ICON_CACHE_DIR / "nc.svg"
        if target_path.exists():
            return target_path
        target_path.parent.mkdir(parents=True, exist_ok=True)
        target_path.write_text(
            """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2">
  <rect width="3" height="2" fill="#fff"/>
  <rect width="3" height="0.7" y="0.65" fill="#c62828"/>
  <circle cx="1.15" cy="1" r="0.28" fill="#fff"/>
  <circle cx="1.25" cy="1" r="0.2" fill="#c62828"/>
  <path d="M1.95 0.72 L2.05 0.98 L2.32 0.98 L2.1 1.14 L2.18 1.4 L1.95 1.25 L1.72 1.4 L1.8 1.14 L1.58 0.98 L1.85 0.98 Z" fill="#c62828"/>
</svg>
""",
            encoding="utf-8",
        )
        return target_path

    target_path = FLAG_ICON_CACHE_DIR / f"{code}.svg"
    if target_path.exists():
        return target_path

    target_path.parent.mkdir(parents=True, exist_ok=True)
    flag_url = f"https://cdn.jsdelivr.net/gh/lipis/flag-icons@7.5.0/flags/4x3/{code}.svg"
    try:
        response = requests.get(flag_url, timeout=120)
    except Exception:
        return None
    if response.status_code != 200:
        return None
    target_path.write_bytes(response.content)
    return target_path


def flag_markup(country_iso2: str, country_name: str) -> str:
    flag_path = ensure_flag_icon(country_iso2)
    if flag_path is None:
        return '<span class="flag-spacer" aria-hidden="true"></span>'
    flag_label = "Klingon Empire" if country_iso2.strip().lower() == SPACE_FLAG_ISO2 else country_name
    return (
        f'<img class="flag" src="{html_escape(flag_path.as_posix())}" '
        f'alt="{html_escape(flag_label)} flag" title="{html_escape(flag_label)} flag">'
    )


def country_display_html(country_name: str, country_iso2: str) -> str:
    display_name = display_country_name(country_name or "Unknown")
    if display_name.endswith("?"):
        lookup_name = display_name.rstrip("?").strip()
    else:
        lookup_name = display_name
    escaped_name = html_escape_with_breaks(display_name)
    normalized_iso2 = (country_iso2 or COUNTRY_NAME_TO_ISO2.get(lookup_name, "")).strip().lower()
    if lookup_name == "Unknown":
        normalized_iso2 = ""
    flag_html = flag_markup(normalized_iso2, country_name or "Unknown")
    return f'<span class="country-cell">{flag_html}<span>{escaped_name}</span></span>'


def country_map_href(country_name: str) -> str:
    display_name = normalize_country_display_name(display_country_name(country_name or "Unknown"))
    if display_name == "Unknown" or display_name.lower() in {"space", "rover"}:
        return ""
    return (COUNTRY_MAP_DIR / f"{country_map_slug(display_name)}.html").as_posix()


def country_map_link_html(country_name: str, country_iso2: str) -> str:
    country_html = country_display_html(country_name, country_iso2)
    href = country_map_href(country_name)
    if not href:
        return country_html
    return f'<a class="country-map-link" href="{html_escape(href)}" title="Open the generated country station map.">{country_html}</a>'


def guess_country_from_text(record: ObsCodeRecord) -> tuple[str, str] | None:
    haystack = " ".join(
        part.strip()
        for part in [
            record.display_name,
            record.name,
            record.short_name,
            record.web_link,
            record.old_names_json,
            record.name_utf8,
            record.name_latex,
        ]
        if part.strip()
    ).lower()

    for token, country_name, iso2 in COUNTRY_GUESS_ALIASES:
        if token in haystack:
            return country_name, iso2

    try:
        from urllib.parse import urlparse

        host = (urlparse(record.web_link).hostname or "").lower()
    except Exception:
        host = ""

    for suffix, (country_name, iso2) in COUNTRY_TLD_GUESSES.items():
        if host.endswith(suffix):
            return country_name, iso2

    return None


def load_country_cache() -> dict[str, dict[str, str]]:
    if not COUNTRY_CACHE_PATH.exists():
        return {}
    try:
        raw_cache = json.loads(COUNTRY_CACHE_PATH.read_text(encoding="utf-8"))
        filtered_cache: dict[str, dict[str, str]] = {}
        for obscode, entry in raw_cache.items():
            country_name = str(entry.get("country_name", "")).strip()
            if not country_name or country_name == "Unknown" or country_name.endswith("?"):
                continue
            filtered_cache[str(obscode)] = {
                "country_name": country_name,
                "country_iso2": str(entry.get("country_iso2", "")).strip(),
            }
        return filtered_cache
    except Exception:
        return {}


def load_country_overrides() -> dict[str, str]:
    if not COUNTRY_OVERRIDE_PATH.exists():
        return {}

    overrides: dict[str, str] = {}
    for raw_line in COUNTRY_OVERRIDE_PATH.read_text(encoding="utf-8").splitlines():
        line = raw_line.strip()
        if not line or line.startswith("#"):
            continue
        if "=" not in line:
            continue
        code_text, country_text = line.split("=", 1)
        code = code_text.strip().upper()
        country = country_text.strip()
        if not code or not country:
            continue
        overrides[code] = country
    return overrides


def save_country_cache(records: list[ObsCodeRecord]) -> None:
    log(f"Schreibe Laender-Cache: {COUNTRY_CACHE_PATH}")
    cache: dict[str, dict[str, str]] = {}
    for record in records:
        country_name = record.country_name.strip()
        if country_name and country_name != "Unknown" and not country_name.endswith("?"):
            cache[record.obscode.strip()] = {
                "country_name": country_name,
                "country_iso2": record.country_iso2.strip(),
            }
    COUNTRY_CACHE_PATH.write_text(json.dumps(cache, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8")
    log(f"Laender-Cache geschrieben: {len(cache)} Eintraege")


def enrich_records_with_country(
    records: list[ObsCodeRecord],
    boundaries: list[CountryBoundary],
    country_cache: dict[str, dict[str, str]],
    country_overrides: dict[str, str],
) -> None:
    log(f"Ordne Laender zu: {len(records)} Datensaetze")
    summary = Counter()
    for record_index, record in enumerate(records, start=1):
        override_country = country_overrides.get(record.obscode.strip().upper(), "").strip()
        if override_country:
            record.country_name = override_country
            if override_country.lower() == "space":
                record.country_iso2 = SPACE_FLAG_ISO2
            else:
                record.country_iso2 = COUNTRY_NAME_TO_ISO2.get(override_country, "")
            record.country_reason = "Manual country override"
            summary["override"] += 1
            if record_index % PROGRESS_INTERVAL == 0:
                log_progress("Laenderzuordnung", record_index, len(records))
            continue

        cached_entry = country_cache.get(record.obscode.strip())
        if cached_entry:
            cached_country = cached_entry.get("country_name", "").strip()
            if cached_country and cached_country != "Unknown":
                record.country_name = cached_country
                cached_iso2 = cached_entry.get("country_iso2", "").strip()
                if not cached_iso2:
                    cached_iso2 = COUNTRY_NAME_TO_ISO2.get(cached_country, "")
                record.country_iso2 = cached_iso2
                record.country_reason = ""
                summary["cache"] += 1
                if record_index % PROGRESS_INTERVAL == 0:
                    log_progress("Laenderzuordnung", record_index, len(records))
                continue

        if record.observations_type.strip().lower() == "satellite":
            record.country_name = "space"
            record.country_iso2 = SPACE_FLAG_ISO2
            record.country_reason = "Satellite object represented as space"
            summary["satellite"] += 1
            if record_index % PROGRESS_INTERVAL == 0:
                log_progress("Laenderzuordnung", record_index, len(records))
            continue

        longitude_value = parse_float_text(record.longitude)
        latitude_value = derive_geodetic_latitude(record)
        if longitude_value is None or latitude_value is None:
            record.country_name = "Unknown"
            record.country_iso2 = ""
            record.country_reason = "Missing coordinate values"
            summary["missing_coordinates"] += 1
            if record_index % PROGRESS_INTERVAL == 0:
                log_progress("Laenderzuordnung", record_index, len(records))
            continue

        country = lookup_country(normalize_longitude(longitude_value), latitude_value, boundaries)
        if country is None:
            guessed_country = guess_country_from_text(record)
            if guessed_country is not None:
                guessed_name, guessed_iso2 = guessed_country
                record.country_name = f"{guessed_name}?"
                record.country_iso2 = guessed_iso2
                record.country_reason = "Guessed from station name or web address"
                summary["guessed"] += 1
                if record_index % PROGRESS_INTERVAL == 0:
                    log_progress("Laenderzuordnung", record_index, len(records))
                continue
            record.country_name = "Unknown"
            record.country_iso2 = ""
            record.country_reason = "Coordinates not matched by the boundary data"
            summary["unknown"] += 1
            if record_index % PROGRESS_INTERVAL == 0:
                log_progress("Laenderzuordnung", record_index, len(records))
            continue

        record.country_name = country.name
        record.country_iso2 = country.iso2
        record.country_reason = ""
        summary["boundary"] += 1
        if record_index % PROGRESS_INTERVAL == 0:
            log_progress("Laenderzuordnung", record_index, len(records))

    log(
        "Laenderzuordnung fertig: "
        f"boundary={summary['boundary']}, cache={summary['cache']}, override={summary['override']}, "
        f"satellite={summary['satellite']}, guessed={summary['guessed']}, "
        f"missing={summary['missing_coordinates']}, unknown={summary['unknown']}"
    )

def station_link(obscode: str, base_url: str) -> str:
    code = obscode.strip()
    return f"{base_url.rstrip('/')}/{html_escape(code)}.html"


def table_cell(
    value: str,
    *,
    sort_value: str | None = None,
    css_class: str = "",
    title: str = "",
    extra_attrs: dict[str, str] | None = None,
) -> str:
    display_value = value.lstrip()
    sort_text = (sort_value if sort_value is not None else value).lstrip()
    escaped_value = html_escape(display_value)
    escaped_sort_value = html_escape(sort_text)
    class_part = f' class="{css_class}"' if css_class else ""
    title_part = f' title="{html_escape(title)}"' if title else ""
    attrs = ""
    for attr_name, attr_value in (extra_attrs or {}).items():
        attrs += f' {html_escape(attr_name)}="{html_escape(attr_value)}"'
    return f'<td{class_part} data-sort="{escaped_sort_value}"{title_part}{attrs}>{escaped_value}</td>'


def date_table_cell(display_value: str, raw_value: str, css_class: str = "date") -> str:
    full_value = format_datetime(raw_value)
    return table_cell(
        display_value,
        sort_value=sort_key_datetime(raw_value),
        css_class=css_class,
        title=full_value,
    )


def collect_date_column_stats(values: Iterable[str]) -> tuple[int, str, str]:
    parsed_values: list[tuple[datetime, str]] = []

    for value in values:
        parsed_value = parse_mpc_datetime(value)
        if parsed_value is None:
            continue
        parsed_values.append((parsed_value, value.strip()))

    if not parsed_values:
        return 0, "", ""

    parsed_values.sort(key=lambda item: item[0])
    return (
        len(parsed_values),
        format_datetime(parsed_values[0][1]),
        format_datetime(parsed_values[-1][1]),
    )


def percentage_text(part: int, total: int) -> str:
    if total <= 0:
        return "0"
    return str(int((part * 100) / total))


def percentage_text_precise(part: int, total: int) -> str:
    if total <= 0:
        return "0.00"
    return f"{(part * 100) / total:.2f}"


def choose_pie_startangle(labels: list[str], counts: list[int]) -> int:
    total_count = sum(counts)
    if total_count <= 0:
        return 0

    candidates = range(0, 360, 15)
    best_angle = 0
    best_score = None

    for startangle in candidates:
        current_angle = startangle
        score = 0.0

        for label, count in zip(labels, counts):
            span = (count / total_count) * 360.0
            mid_angle = current_angle - (span / 2.0)
            angle_rad = math.radians(mid_angle)
            x = math.cos(angle_rad)
            y = math.sin(angle_rad)

            label_weight = max(1.0, float(len(label)))
            score += label_weight * max(x, 0.0)
            score -= label_weight * max(-x, 0.0) * 0.35
            score -= label_weight * abs(y) * 0.08

            current_angle -= span

        if best_score is None or score > best_score:
            best_score = score
            best_angle = startangle

    return best_angle


def collect_web_link_stats(records: Iterable[ObsCodeRecord]) -> tuple[int, int, int]:
    total_count = 0
    url_count = 0

    for record in records:
        total_count += 1
        if record.web_link.strip():
            url_count += 1

    return total_count, url_count, total_count - url_count


class StationIndexParser(HTMLParser):
    def __init__(self) -> None:
        super().__init__()
        self._in_cell = False
        self._cell_parts: list[str] = []
        self._current_colspan = 1
        self._current_row: list[str] = []
        self.rows: list[list[str]] = []

    def handle_starttag(self, tag: str, attrs) -> None:
        if tag.lower() == "tr":
            self._current_row = []
        elif tag.lower() in {"td", "th"}:
            self._in_cell = True
            self._cell_parts = []
            attrs_dict = {str(name).lower(): str(value) for name, value in attrs}
            try:
                self._current_colspan = max(1, int(attrs_dict.get("colspan", "1")))
            except Exception:
                self._current_colspan = 1

    def handle_data(self, data: str) -> None:
        if self._in_cell:
            self._cell_parts.append(data)

    def handle_endtag(self, tag: str) -> None:
        lowered_tag = tag.lower()
        if lowered_tag in {"td", "th"} and self._in_cell:
            cell_text = " ".join("".join(self._cell_parts).split())
            self._current_row.append(cell_text)
            for _ in range(self._current_colspan - 1):
                self._current_row.append("")
            self._in_cell = False
            self._current_colspan = 1
        elif lowered_tag == "tr" and self._current_row:
            self.rows.append(self._current_row)
            self._current_row = []


def load_station_index_data(source_url: str = JOST_STATION_INDEX_URL) -> dict[str, StationIndexEntry]:
    log(f"Lade Stationsindexdaten: {source_url}")
    try:
        response = requests.get(source_url, timeout=120, headers={"User-Agent": HTTP_USER_AGENT})
        response.raise_for_status()
    except Exception as exc:
        log(f"Stationsindexdaten konnten nicht geladen werden: {exc}")
        return {}

    parser = StationIndexParser()
    parser.feed(response.text)
    entries: dict[str, StationIndexEntry] = {}
    for row in parser.rows:
        if len(row) < 10:
            continue
        station_code = row[0].strip().upper()
        if not re.fullmatch(r"[0-9A-Z][0-9][0-9]", station_code):
            continue
        count_text = re.sub(r"[^0-9]", "", row[9])
        observations_count = int(count_text) if count_text else None
        entries[station_code] = StationIndexEntry(
            code=station_code,
            first_date=row[6].strip(),
            last_date=row[7].strip(),
            observations_count=observations_count,
            station_name=clean_display_text(row[18]) if len(row) > 18 else "",
        )

    OBSERVATION_COUNT_SOURCE_PATH.write_text(
        json.dumps(
            {
                "source": source_url,
                "fetched_at_utc": iso_utc_now_text(),
                "count": len(entries),
                "stations": {
                    code: {
                        "first_date": entry.first_date,
                        "last_date": entry.last_date,
                        "observations": entry.observations_count,
                        "station_name": entry.station_name,
                    }
                    for code, entry in entries.items()
                },
            },
            ensure_ascii=False,
            indent=2,
            sort_keys=True,
        ),
        encoding="utf-8",
    )
    log(f"Stationsindexdaten geladen: {len(entries)} Stationen")
    return entries


def assign_station_index_data(records: list[ObsCodeRecord], station_index_data: dict[str, StationIndexEntry]) -> None:
    matched = 0
    for record in records:
        entry = station_index_data.get(record.obscode.strip().upper())
        if entry is None:
            record.observations_count = None
            record.jost_first_date = ""
            record.jost_last_date = ""
            continue
        record.observations_count = entry.observations_count
        record.jost_first_date = entry.first_date
        record.jost_last_date = entry.last_date
        if entry.observations_count is not None:
            matched += 1
    log(f"Beobachtungszahlen zugeordnet: {matched}/{len(records)}")


def write_unmatched_observation_count_output(
    records: list[ObsCodeRecord],
    station_index_data: dict[str, StationIndexEntry],
) -> None:
    record_codes = {record.obscode.strip().upper() for record in records}
    unmatched_entries = [
        entry
        for code, entry in station_index_data.items()
        if code not in record_codes and entry.observations_count is not None
    ]
    unmatched_entries.sort(key=lambda entry: entry.code)
    lines = ["Code First_date Last_date Observations Station_name"]
    for entry in unmatched_entries:
        lines.append(
            f"{entry.code} {entry.first_date or '-'} {entry.last_date or '-'} "
            f"{entry.observations_count if entry.observations_count is not None else ''} {entry.station_name}"
        )
    UNMATCHED_OBSERVATION_COUNT_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8")
    log(f"Nicht zugeordnete Beobachtungszahlen geschrieben: {UNMATCHED_OBSERVATION_COUNT_PATH} ({len(unmatched_entries)} Zeilen)")


def check_single_web_link(url: str) -> WebLinkStatus:
    checked_at = utc_now_text()
    parsed_url = urlparse(url)
    if parsed_url.scheme.lower() not in {"http", "https"}:
        return WebLinkStatus(url=url, checked_at_utc=checked_at, reachable=False, error="Unsupported URL scheme")

    headers = {"User-Agent": HTTP_USER_AGENT}
    try:
        response = requests.head(url, allow_redirects=True, timeout=WEB_LINK_TIMEOUT_SECONDS, headers=headers)
        if response.status_code in {405, 406, 403} or response.status_code >= 500:
            response = requests.get(url, allow_redirects=True, timeout=WEB_LINK_TIMEOUT_SECONDS, headers=headers, stream=True)
        reachable = 200 <= response.status_code < 400
        final_url = response.url or url
        response.close()
        return WebLinkStatus(
            url=url,
            checked_at_utc=checked_at,
            reachable=reachable,
            status_code=response.status_code,
            final_url=final_url,
            error="" if reachable else f"HTTP {response.status_code}",
        )
    except SSLError as exc:
        try:
            response = requests.head(
                url,
                allow_redirects=True,
                timeout=WEB_LINK_TIMEOUT_SECONDS,
                headers=headers,
                verify=False,
            )
            if response.status_code in {405, 406, 403} or response.status_code >= 500:
                response = requests.get(
                    url,
                    allow_redirects=True,
                    timeout=WEB_LINK_TIMEOUT_SECONDS,
                    headers=headers,
                    stream=True,
                    verify=False,
                )
            reachable = 200 <= response.status_code < 400
            final_url = response.url or url
            response.close()
            return WebLinkStatus(
                url=url,
                checked_at_utc=checked_at,
                reachable=reachable,
                status_code=response.status_code,
                final_url=final_url,
                error="" if reachable else f"HTTP {response.status_code} after certificate fallback",
            )
        except Exception as fallback_exc:
            return WebLinkStatus(url=url, checked_at_utc=checked_at, reachable=False, error=f"{exc}; certificate fallback failed: {fallback_exc}")
    except Exception as exc:
        return WebLinkStatus(url=url, checked_at_utc=checked_at, reachable=False, error=str(exc))


def normalize_station_web_url(url: str) -> str:
    cleaned_url = url.strip()
    if not cleaned_url:
        return ""
    if "//" not in cleaned_url:
        return f"https://{cleaned_url}"
    return cleaned_url


def load_web_link_status_cache() -> dict[str, WebLinkStatus]:
    if not WEB_LINK_STATUS_CACHE_PATH.exists():
        return {}
    try:
        raw_data = json.loads(WEB_LINK_STATUS_CACHE_PATH.read_text(encoding="utf-8"))
    except Exception as exc:
        log(f"Web-Link-Cache konnte nicht gelesen werden: {exc}")
        return {}

    statuses: dict[str, WebLinkStatus] = {}
    for url, values in raw_data.get("statuses", {}).items():
        if not isinstance(values, dict):
            continue
        statuses[url] = WebLinkStatus(
            url=url,
            checked_at_utc=str(values.get("checked_at_utc", "")),
            reachable=bool(values.get("reachable", False)),
            status_code=values.get("status_code"),
            final_url=str(values.get("final_url", "")),
            error=str(values.get("error", "")),
        )
    return statuses


def web_link_status_is_from_utc_day(status: WebLinkStatus, utc_day: str) -> bool:
    return status.checked_at_utc[:10] == utc_day


def check_web_links(records: list[ObsCodeRecord], enabled: bool = True, force: bool = False) -> dict[str, WebLinkStatus]:
    urls = sorted({normalize_station_web_url(record.web_link) for record in records if normalize_station_web_url(record.web_link)})
    cached_statuses = load_web_link_status_cache()
    today_utc = datetime.now(timezone.utc).date().isoformat()
    if not enabled:
        log(f"Web-Link-Pruefung deaktiviert: {len(urls)} Links bleiben ungeprueft, Cache wird verwendet")
        statuses = {url: cached_statuses[url] for url in urls if url in cached_statuses}
        for record in records:
            record.web_link_status = statuses.get(normalize_station_web_url(record.web_link))
        return statuses
    if not urls:
        log("Keine Web-Links zu pruefen")
        return {}

    statuses: dict[str, WebLinkStatus] = {
        url: cached_statuses[url]
        for url in urls
        if not force and url in cached_statuses and web_link_status_is_from_utc_day(cached_statuses[url], today_utc)
    }
    urls_to_check = [url for url in urls if url not in statuses]
    log(
        f"Pruefe Web-Links: {len(urls)} eindeutige Adressen, "
        f"{len(statuses)} heute aus Cache, {len(urls_to_check)} neu zu pruefen"
        + (" (erzwungen)" if force else "")
    )
    completed = 0
    if urls_to_check:
        with ThreadPoolExecutor(max_workers=WEB_LINK_MAX_WORKERS) as executor:
            futures = {executor.submit(check_single_web_link, url): url for url in urls_to_check}
            for future in as_completed(futures):
                url = futures[future]
                try:
                    statuses[url] = future.result()
                except Exception as exc:
                    statuses[url] = WebLinkStatus(url=url, checked_at_utc=utc_now_text(), reachable=False, error=str(exc))
                completed += 1
                if completed % 25 == 0 or completed == len(urls_to_check):
                    reachable_count = sum(1 for status in statuses.values() if status.reachable)
                    log(f"Web-Link-Pruefung: {completed}/{len(urls_to_check)} neu fertig, erreichbar={reachable_count}")
    else:
        reachable_count = sum(1 for status in statuses.values() if status.reachable)
        log(f"Web-Link-Pruefung: keine neue Pruefung noetig, erreichbar={reachable_count}")

    WEB_LINK_STATUS_CACHE_PATH.write_text(
        json.dumps(
            {
                "checked_at_utc": iso_utc_now_text(),
                "statuses": {
                    url: {
                        "checked_at_utc": status.checked_at_utc,
                        "reachable": status.reachable,
                        "status_code": status.status_code,
                        "final_url": status.final_url,
                        "error": status.error,
                    }
                    for url, status in statuses.items()
                },
            },
            ensure_ascii=False,
            indent=2,
            sort_keys=True,
        ),
        encoding="utf-8",
    )

    for record in records:
        record.web_link_status = statuses.get(normalize_station_web_url(record.web_link))
    return statuses


def web_link_cell(record: ObsCodeRecord) -> str:
    url = normalize_station_web_url(record.web_link)
    if not url:
        return '<td class="web" data-sort=""></td>'

    status = record.web_link_status
    escaped_url = html_escape(url)
    if status is None:
        title = f"This web address was not checked in this generator run. URL: {url}"
        return f'<td class="web" data-sort="{escaped_url}" title="{html_escape(title)}">Link</td>'

    if status.reachable:
        status_text = f"HTTP {status.status_code}" if status.status_code is not None else "reachable"
        title = f"Website reachable when checked at {status.checked_at_utc} ({status_text}). URL: {url}"
        return f'<td class="web" data-sort="{escaped_url}" title="{html_escape(title)}"><a href="{escaped_url}">Link</a></td>'

    error_text = status.error or "not reachable"
    title = f"Website checked at {status.checked_at_utc} and was not reachable at that time. URL: {url}. {error_text}"
    return f'<td class="web web-unreachable" data-sort="{escaped_url}" title="{html_escape(title)}">Link</td>'


def collect_latest_unreachable_web_link_records(records: Iterable[ObsCodeRecord]) -> list[ObsCodeRecord]:
    latest_by_code: dict[str, ObsCodeRecord] = {}
    for record in records:
        status = record.web_link_status
        if status is None or status.reachable or not record.web_link.strip():
            continue
        code = record.obscode.strip().upper()
        existing = latest_by_code.get(code)
        if existing is None:
            latest_by_code[code] = record
            continue
        existing_checked = existing.web_link_status.checked_at_utc if existing.web_link_status else ""
        if status.checked_at_utc >= existing_checked:
            latest_by_code[code] = record

    return sorted(
        latest_by_code.values(),
        key=lambda item: (
            item.web_link_status.checked_at_utc if item.web_link_status else "",
            item.obscode,
        ),
        reverse=True,
    )


def web_link_record_category(record: ObsCodeRecord) -> str:
    normalized_url = normalize_station_web_url(record.web_link)
    if not normalized_url:
        return "No URL"
    if record.web_link_status is None:
        return "Unchecked"
    if record.web_link_status.reachable:
        return "Reachable"
    return "Unreachable"


def web_link_status_code_text(status: WebLinkStatus | None) -> str:
    if status is None:
        return ""
    if status.status_code is None:
        return ""
    return str(status.status_code)


def http_status_title(status_code: int | None) -> str:
    if status_code is None:
        return "No HTTP status code was returned."
    if 100 <= status_code < 200:
        return f"HTTP {status_code}: informational response."
    if 200 <= status_code < 300:
        return f"HTTP {status_code}: successful response."
    if 300 <= status_code < 400:
        return f"HTTP {status_code}: redirect response."
    if 400 <= status_code < 500:
        return f"HTTP {status_code}: client error response."
    if 500 <= status_code < 600:
        return f"HTTP {status_code}: server error response."
    return f"HTTP {status_code}: non-standard HTTP status code."


def classify_web_link_error(record: ObsCodeRecord) -> str:
    normalized_url = normalize_station_web_url(record.web_link)
    if not normalized_url:
        return "No URL"
    status = record.web_link_status
    if status is None:
        return "Unchecked"
    if status.reachable:
        return "Reachable"
    if status.status_code is not None:
        return f"HTTP {status.status_code // 100}xx"

    error_text = (status.error or "").lower()
    if "certificate" in error_text or "ssl" in error_text or "tls" in error_text:
        return "TLS/certificate"
    if "timed out" in error_text or "timeout" in error_text or "read timed" in error_text:
        return "Timeout"
    if "getaddrinfo" in error_text or "name resolution" in error_text or "nodename" in error_text or "dns" in error_text:
        return "DNS/name"
    if "refused" in error_text:
        return "Connection refused"
    if "unsupported url scheme" in error_text:
        return "Unsupported scheme"
    if "connection" in error_text or "connect" in error_text:
        return "Connection"
    return "Other error"


def web_link_status_url_cell(record: ObsCodeRecord) -> str:
    normalized_url = normalize_station_web_url(record.web_link)
    if not normalized_url:
        return '<td class="web" data-sort=""></td>'
    status = record.web_link_status
    title = "This URL was normalized with https:// because the source text did not contain //." if "//" not in record.web_link.strip() else ""
    escaped_url = html_escape(normalized_url)
    if status is not None and status.reachable:
        status_text = f" Reachable when checked at {status.checked_at_utc}."
        return f'<td class="web" data-sort="{escaped_url}" title="{html_escape(title + status_text)}"><a href="{escaped_url}">{escaped_url}</a></td>'
    if status is not None:
        status_text = f" Checked at {status.checked_at_utc}; not reachable at that time."
        return f'<td class="web web-unreachable" data-sort="{escaped_url}" title="{html_escape(title + status_text)}">{escaped_url}</td>'
    return f'<td class="web" data-sort="{escaped_url}" title="{html_escape(title)}">{escaped_url}</td>'


def write_web_link_status_output(
    records: list[ObsCodeRecord],
    base_url: str,
    output_html_name: str = "mpc_web_link_status.html",
) -> None:
    log(f"Erzeuge Web-Link-Statusseite: {output_html_name}")
    generated_at = utc_now_text()
    total_stations = len(records)
    stations_with_url = [record for record in records if normalize_station_web_url(record.web_link)]
    stations_without_url = total_stations - len(stations_with_url)
    checked_stations = [record for record in stations_with_url if record.web_link_status is not None]
    reachable_stations = [record for record in checked_stations if record.web_link_status and record.web_link_status.reachable]
    unreachable_stations = [record for record in checked_stations if record.web_link_status and not record.web_link_status.reachable]
    unchecked_stations = [record for record in stations_with_url if record.web_link_status is None]
    unique_urls = {normalize_station_web_url(record.web_link) for record in stations_with_url}
    checked_unique_urls = {normalize_station_web_url(record.web_link) for record in checked_stations}
    error_class_counts = Counter(classify_web_link_error(record) for record in stations_with_url)

    summary_buttons = [
        ("All with given URL", len(stations_with_url), "__with_url"),
        ("All", total_stations),
        ("Reachable", len(reachable_stations)),
        ("Unreachable", len(unreachable_stations)),
        ("No URL", stations_without_url),
        ("Unchecked", len(unchecked_stations)),
    ]
    summary_buttons_html = "\n".join(
        f'          <button type="button" class="{"active" if filter_value == "__with_url" else ""}" data-filter="{html_escape(filter_value)}">{html_escape(label)} ({format_integer_with_dots(count)})</button>'
        for button in summary_buttons
        for label, count, filter_value in [(button[0], button[1], button[2] if len(button) > 2 else button[0])]
    )
    error_buttons_html = "\n".join(
        f'          <button type="button" data-filter="error:{html_escape(error_class)}">{html_escape(error_class)} ({format_integer_with_dots(count)})</button>'
        for error_class, count in sorted(error_class_counts.items(), key=lambda item: (item[0] != "Reachable", item[0].lower()))
    )

    table_rows: list[str] = []
    for record in sorted(records, key=lambda item: (web_link_record_category(item), item.obscode)):
        status = record.web_link_status
        checked_at = status.checked_at_utc if status else ""
        status_code = web_link_status_code_text(status)
        error_text = status.error if status else ""
        category = web_link_record_category(record)
        normalized_url = normalize_station_web_url(record.web_link)
        has_url = "1" if normalized_url else "0"
        error_class = classify_web_link_error(record)
        code_link = station_link(record.obscode, base_url)
        table_rows.append(
            f'          <tr data-category="{html_escape(category)}" data-has-url="{has_url}" data-error-class="{html_escape(error_class)}">'
            f'<td class="category" data-sort="{html_escape(category)}">{html_escape(category)}</td>'
            f'<td class="date">{html_escape(checked_at)}</td>'
            f'<td class="code" data-sort="{html_escape(record.obscode)}"><a href="{html_escape(code_link)}" title="Open the station page for this code.">{html_escape(record.obscode)}</a></td>'
            f"{station_name_cell(record)}"
            f"{web_link_status_url_cell(record)}"
            f'<td class="num" title="{html_escape(http_status_title(status.status_code if status else None))}">{html_escape(status_code)}</td>'
            f'<td title="{html_escape(error_class)}">{html_escape(error_text)}</td>'
            "</tr>"
        )

    html_text = f"""<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>MPC Observatory Code Web Link Status</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <style>
    body {{
      font-family: Arial, Helvetica, sans-serif;
      margin: 0;
      color: #111;
      background: linear-gradient(180deg, #f6f8fb 0%, #ffffff 100%);
      overflow-x: auto;
    }}
    .page {{
      max-width: none;
      margin: 0;
      padding: 2rem;
    }}
    .panel {{
      background: #fff;
      border: 1px solid #d8dde6;
      border-radius: 12px;
      box-shadow: 0 8px 24px rgba(30, 42, 62, 0.08);
      padding: 1rem 1.1rem;
    }}
    table {{
      border-collapse: collapse;
      width: 100%;
      font-size: 0.92rem;
    }}
    th, td {{
      border: 1px solid #ccc;
      padding: 0.35rem 0.45rem;
      text-align: left;
      vertical-align: top;
    }}
    th {{
      background: #eee;
      cursor: pointer;
      user-select: none;
    }}
    th::after {{ content: " \\2195"; color: #777; font-weight: normal; }}
    th.sort-asc::after {{ content: " \\2191"; color: #111; }}
    th.sort-desc::after {{ content: " \\2193"; color: #111; }}
    td.num, td.date {{
      text-align: right;
      white-space: nowrap;
      font-variant-numeric: tabular-nums;
    }}
    td.code {{
      white-space: nowrap;
      font-weight: 700;
    }}
    td.name {{
      width: 20ch;
      min-width: 20ch;
      max-width: 20ch;
      overflow-wrap: normal;
      word-break: normal;
    }}
    .name-elevation-alert, .name-elevation-alert a {{
      color: #b00020;
      font-weight: 700;
    }}
    td.web {{
      max-width: 34rem;
      overflow-wrap: anywhere;
      word-break: break-word;
      color: #7a1c1c;
    }}
    .meta {{
      color: #555;
      margin-bottom: 1.2rem;
    }}
    .filter-buttons {{
      display: flex;
      flex-wrap: wrap;
      gap: 0.3rem;
      margin: 0.75rem 0 1rem;
      font-size: 0.92rem;
    }}
    .filter-buttons button {{
      font: inherit;
      padding: 0.18rem 0.5rem;
      border: 1px solid #b9c3d3;
      border-radius: 4px;
      background: #f8fafc;
      color: #132238;
      cursor: pointer;
      line-height: 1.25;
    }}
    .filter-buttons button.active {{
      background: #174a73;
      border-color: #174a73;
      color: #fff;
    }}
    .summary-line {{
      color: #4d5b6f;
      margin: 0.35rem 0 0.2rem;
    }}
    @media (max-width: 980px) {{
      .page {{
        padding: 1rem;
      }}
    }}
    {site_layout_css()}
  </style>
</head>
<body>
  {site_sidebar_html("mpc_web_link_status.html")}
  <div class="page">
    <h1>MPC Observatory Code Web Link Status</h1>
    <div class="meta">Generated (UTC): {html_escape(generated_at)}</div>
    <div class="panel">
      <h2>URL check summary and station table</h2>
      <p class="summary-line">Unique URLs present: {format_integer_with_dots(len(unique_urls))} | Unique URLs checked: {format_integer_with_dots(len(checked_unique_urls))} | Missing // was normalized by prefixing https:// before checking. Certificate errors are retried with certificate verification disabled.</p>
      <div class="filter-buttons" id="web-filter">
{summary_buttons_html}
{error_buttons_html}
          <span id="web-filter-count">{format_integer_with_dots(len(stations_with_url))} visible</span>
      </div>
      {table_export_button("web-status-table")}
      <table id="web-status-table">
          <thead>
            <tr>
              <th data-type="text" title="Reachability category used by the filter buttons.">Category</th>
              <th data-type="date" title="UTC timestamp of the latest URL check.">Checked (UTC)</th>
              <th data-type="text" title="MPC station code, linked to the usual station page.">Code</th>
              <th data-type="text" title="Station name, linked to a Google Maps satellite view.">Name</th>
              <th data-type="text" title="Normalized URL. If the source value did not contain //, https:// was added before checking.">URL</th>
              <th data-type="number" title="HTTP status code, if one was returned.">Status</th>
              <th data-type="text" title="Error text from the latest check, if any.">Error</th>
            </tr>
          </thead>
          <tbody>
{chr(10).join(table_rows)}
          </tbody>
        </table>
    </div>
  </div>
{table_export_script()}
  <script>
    (function () {{
      const table = document.getElementById("web-status-table");
      const tbody = table.querySelector("tbody");
      const headers = Array.from(table.querySelectorAll("th"));
      const filter = document.getElementById("web-filter");
      const filterCount = document.getElementById("web-filter-count");
      const selectedFilters = new Set(["__with_url"]);

      function visibleRows() {{
        return Array.from(tbody.querySelectorAll("tr")).filter(row => row.style.display !== "none");
      }}

      function updateFilter() {{
        const rows = Array.from(tbody.querySelectorAll("tr"));
        rows.forEach(row => {{
          const category = row.dataset.category || "";
          const hasUrl = row.dataset.hasUrl === "1";
          const errorClass = row.dataset.errorClass || "";
          const visible = selectedFilters.size === 0 || Array.from(selectedFilters).some(filterName => {{
            if (filterName === "__with_url") return hasUrl;
            if (filterName.startsWith("error:")) return errorClass === filterName.slice(6);
            return category === filterName;
          }});
          row.style.display = visible ? "" : "none";
        }});
        filter.querySelectorAll("button").forEach(button => {{
          const filterName = button.dataset.filter || "";
          button.classList.toggle("active", filterName === "All" ? selectedFilters.size === 0 : selectedFilters.has(filterName));
        }});
        filterCount.textContent = visibleRows().length.toLocaleString("de-DE") + " visible";
      }}

      function compareCells(rowA, rowB, columnIndex, direction, type) {{
        const valueA = (rowA.children[columnIndex].dataset.sort || rowA.children[columnIndex].textContent || "").trim();
        const valueB = (rowB.children[columnIndex].dataset.sort || rowB.children[columnIndex].textContent || "").trim();
        let comparison = 0;
        if (type === "number") {{
          const numberA = Number(valueA.replace(/\\./g, "").replace(",", "."));
          const numberB = Number(valueB.replace(/\\./g, "").replace(",", "."));
          comparison = (Number.isFinite(numberA) ? numberA : -Infinity) - (Number.isFinite(numberB) ? numberB : -Infinity);
        }} else {{
          comparison = valueA.localeCompare(valueB, "de", {{ numeric: true, sensitivity: "base" }});
        }}
        return direction === "asc" ? comparison : -comparison;
      }}

      headers.forEach((header, columnIndex) => {{
        header.addEventListener("click", () => {{
          const direction = header.classList.contains("sort-asc") ? "desc" : "asc";
          headers.forEach(item => item.classList.remove("sort-asc", "sort-desc"));
          header.classList.add(direction === "asc" ? "sort-asc" : "sort-desc");
          const rows = Array.from(tbody.querySelectorAll("tr"));
          rows.sort((rowA, rowB) => compareCells(rowA, rowB, columnIndex, direction, header.dataset.type || "text"));
          rows.forEach(row => tbody.appendChild(row));
        }});
      }});

      filter.addEventListener("click", event => {{
        const button = event.target.closest("button");
        if (!button) return;
        const filterName = button.dataset.filter || "";
        if (filterName === "All") {{
          selectedFilters.clear();
        }} else if (filterName === "__with_url") {{
          selectedFilters.clear();
          selectedFilters.add(filterName);
        }} else if (selectedFilters.has(filterName)) {{
          selectedFilters.delete(filterName);
        }} else {{
          selectedFilters.delete("__with_url");
          selectedFilters.add(filterName);
        }}
        updateFilter();
      }});
      updateFilter();
    }})();
  </script>
</body>
</html>
"""
    Path(output_html_name).write_text(html_text, encoding="utf-8")
    log(f"HTML geschrieben: {output_html_name} ({len(unreachable_stations)} nicht erreichbare Stations-URLs)")


def collect_frequency_rows(records: Iterable[ObsCodeRecord], attribute_name: str) -> list[tuple[str, int, str]]:
    counter: Counter[str] = Counter()

    for record in records:
        value = getattr(record, attribute_name).strip()
        if not value:
            value = "(leer)"
        counter[value] += 1

    total_count = sum(counter.values())
    rows = sorted(counter.items(), key=lambda item: (-item[1], item[0].lower(), item[0]))

    return [(label, count, percentage_text_precise(count, total_count)) for label, count in rows]


def build_type_summary_title(records: list[ObsCodeRecord]) -> str:
    rows = collect_frequency_rows(records, "observations_type")
    lines = ["Observation type categories:"]
    for label, count, _percent in rows:
        explanation = TYPE_EXPLANATIONS.get(label.lower(), "category text from the MPC source data.")
        lines.append(f"{label} ({count}): {explanation}")
    return "\n".join(lines)


def collect_country_frequency_rows(records: Iterable[ObsCodeRecord]) -> list[tuple[str, str, int, str]]:
    counter: Counter[tuple[str, str]] = Counter()

    for record in records:
        country_name = display_country_name(record.country_name.strip() or "Unknown")
        country_iso2 = record.country_iso2.strip().lower()
        counter[(country_name, country_iso2)] += 1

    total_count = sum(counter.values())
    rows = sorted(counter.items(), key=lambda item: (-item[1], item[0][0].lower(), item[0][0], item[0][1]))

    return [
        (country_name, country_iso2, count, percentage_text_precise(count, total_count))
        for (country_name, country_iso2), count in rows
    ]


COUNTRY_PIE_COLORS = [
    "#1f77b4", "#d62728", "#2ca02c", "#ff7f0e", "#9467bd",
    "#17becf", "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22",
    "#0b7285", "#c92a2a", "#2b8a3e", "#e8590c", "#5f3dc4",
    "#0c8599", "#7048e8", "#a61e4d", "#364fc7", "#5c940d",
    "#495057",
]


def summarize_country_pie_rows(rows: list[tuple[str, str, int, str]]) -> list[tuple[str, int, float, str]]:
    total_count = sum(count for _, _, count, _ in rows)
    slices: list[tuple[str, int, float, str]] = []
    other_count = 0
    other_started = False

    for index, (country_name, _country_iso2, count, _percent_text) in enumerate(rows):
        percent = (count * 100.0 / total_count) if total_count else 0.0
        if index >= 20 or percent < 1.0:
            other_started = True
        if other_started:
            other_count += count
        else:
            color = COUNTRY_PIE_COLORS[len(slices) % (len(COUNTRY_PIE_COLORS) - 1)]
            slices.append((display_country_name(country_name), count, percent, color))

    if other_count:
        percent = (other_count * 100.0 / total_count) if total_count else 0.0
        slices.append(("Other", other_count, percent, COUNTRY_PIE_COLORS[-1]))
    return slices


def svg_arc_path(cx: float, cy: float, radius: float, start_angle: float, end_angle: float) -> str:
    start_x = cx + radius * math.cos(start_angle)
    start_y = cy + radius * math.sin(start_angle)
    end_x = cx + radius * math.cos(end_angle)
    end_y = cy + radius * math.sin(end_angle)
    large_arc = 1 if abs(end_angle - start_angle) > math.pi else 0
    return f"M {cx:.2f} {cy:.2f} L {start_x:.2f} {start_y:.2f} A {radius:.2f} {radius:.2f} 0 {large_arc} 1 {end_x:.2f} {end_y:.2f} Z"


def build_country_pie_html(rows: list[tuple[str, str, int, str]]) -> str:
    slices = summarize_country_pie_rows(rows)
    total_count = sum(count for _, _, count, _ in rows)
    if not slices or total_count <= 0:
        return '<div class="country-pie-empty">No country data.</div>'

    cx = 150.0
    cy = 150.0
    radius = 104.0
    current_angle = -math.pi / 2.0
    paths: list[str] = []
    labels: list[str] = []
    legend_rows: list[str] = []

    for index, (label, count, percent, color) in enumerate(slices):
        angle_span = (count / total_count) * math.tau
        next_angle = current_angle + angle_span
        if count == total_count:
            path = (
                f'<circle cx="{cx:.2f}" cy="{cy:.2f}" r="{radius:.2f}" '
                f'fill="{html_escape(color)}"><title>{html_escape(label)}: {count} ({percent:.2f} %)</title></circle>'
            )
        else:
            path_data = svg_arc_path(cx, cy, radius, current_angle, next_angle)
            path = (
                f'<path d="{path_data}" fill="{html_escape(color)}" stroke="#fff" stroke-width="1.2">'
                f'<title>{html_escape(label)}: {count} ({percent:.2f} %)</title></path>'
            )
        paths.append(path)

        mid_angle = current_angle + angle_span / 2.0
        label_radius = radius + 13.0
        label_x = cx + label_radius * math.cos(mid_angle)
        label_y = cy + label_radius * math.sin(mid_angle)
        anchor = "start" if math.cos(mid_angle) >= 0 else "end"
        labels.append(
            f'<text x="{label_x:.2f}" y="{label_y:.2f}" text-anchor="{anchor}" '
            f'fill="{html_escape(color)}" font-size="6.8">{html_escape(label)}</text>'
        )

        legend_rows.append(
            f'<li><span class="pie-swatch" style="background:{html_escape(color)}"></span>'
            f'<span style="color:{html_escape(color)}">{html_escape(label)}</span> '
            f'<span class="pie-count">{count} ({percent:.2f} %)</span></li>'
        )
        current_angle = next_angle

    return f"""
<div class="country-pie">
  <svg viewBox="0 0 300 300" role="img" aria-label="Country distribution pie chart">
    {''.join(paths)}
    {''.join(labels)}
  </svg>
  <ul class="pie-legend">
    {''.join(legend_rows)}
  </ul>
</div>
"""


def special_country_title(country_name: str, records: list[ObsCodeRecord]) -> str:
    normalized = display_country_name(country_name).lower()
    if normalized not in {"space", "rover"}:
        return ""
    matching_records = sorted(
        (
            record
            for record in records
            if display_country_name(record.country_name).lower() == normalized
        ),
        key=lambda item: item.obscode,
    )
    lines = [f"{record.obscode} {record.display_name}" for record in matching_records]
    return title_lines("Observatories:", *lines)


def build_frequency_html_page(
    title: str,
    header_label: str,
    rows: list[tuple[str, int, str]],
    generated_at: str,
    extra_note_html: str = "",
) -> str:
    table_rows = "\n".join(
        f"        <tr><td class=\"label\">{label}</td><td class=\"count\">{count}</td><td class=\"percent\">{percent} %</td></tr>"
        for label, count, percent in rows
    )

    return f"""<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>{html_escape(title)}</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <style>
    body {{
      font-family: Arial, Helvetica, sans-serif;
      margin: 0;
      color: #111;
      background: linear-gradient(180deg, #f6f8fb 0%, #ffffff 100%);
    }}
    .page {{
      max-width: 1100px;
      margin: 0 auto;
      padding: 2rem;
    }}
    .layout {{
      display: flex;
      gap: 1.5rem;
      align-items: flex-start;
      flex-wrap: wrap;
    }}
    .panel {{
      background: #fff;
      border: 1px solid #d8dde6;
      border-radius: 12px;
      box-shadow: 0 8px 24px rgba(30, 42, 62, 0.08);
      padding: 1rem 1.1rem;
    }}
    table {{
      border-collapse: collapse;
      width: auto;
      table-layout: auto;
      display: inline-table;
      max-width: 100%;
    }}
    th, td {{
      border: 1px solid #ccc;
      padding: 0.35rem 0.5rem;
      text-align: left;
      vertical-align: top;
      white-space: normal;
    }}
    th {{
      background: #eee;
      text-align: left;
    }}
    td.label {{
      min-width: 4ch;
      text-align: left;
    }}
    td.count, td.percent {{
      text-align: right;
      font-variant-numeric: tabular-nums;
      white-space: nowrap;
    }}
    .meta {{
      color: #555;
      margin-bottom: 1.2rem;
    }}
    .source-note {{
      color: #4d5b6f;
      font-size: 0.95rem;
      margin-top: 0.35rem;
    }}
    .legend {{
      margin-top: 1rem;
      padding-top: 0.6rem;
      border-top: 1px solid #d8dde6;
    }}
    .legend ul {{
      margin: 0.45rem 0 0;
      padding-left: 1.2rem;
    }}
    .legend li {{
      margin: 0.2rem 0;
    }}
    .flag {{
      height: 1em;
      width: auto;
      vertical-align: -0.15em;
    }}
    .flag-spacer {{
      display: inline-block;
      width: 1.45em;
      height: 1em;
      vertical-align: -0.15em;
    }}
    {site_layout_css()}
  </style>
</head>
<body>
  {site_sidebar_html("mpc_code_stats_type.html" if "Type" in title else "mpc_code_stats_status.html" if "Status" in title else "")}
  <div class="page">
    <div class="panel">
      <h1>{html_escape(title)}</h1>
      <div class="meta">Generated (UTC): {html_escape(generated_at)}</div>
      {extra_note_html}
    </div>
    <div class="layout" style="margin-top: 1rem;">
      <div class="panel">
        {table_export_button("frequency-table")}
        <table id="frequency-table" data-sortable-table="1">
          <thead>
            <tr>
              <th data-type="text" title="The grouped text value from the selected column.">{html_escape(header_label)}</th>
              <th data-type="number" title="How many rows contain this value.">Count</th>
              <th data-type="number" title="Share of all rows, shown with two decimal places.">Percent</th>
            </tr>
          </thead>
          <tbody>
{chr(10).join(table_rows.splitlines())}
          </tbody>
        </table>
        <div class="legend">
          <strong>Column notes</strong>
          <ul>
            <li><strong>{html_escape(header_label)}</strong>: the value taken from the selected column and grouped by identical text.</li>
            <li><strong>Count</strong>: the number of rows that share this same value.</li>
            <li><strong>Percent</strong>: the fraction of all rows represented by this group.</li>
          </ul>
        </div>
      </div>
    </div>
  </div>
{table_export_script()}
</body>
</html>
"""


def create_frequency_pie_chart(title: str, rows: list[tuple[str, int, str]], wide_layout: bool = False):
    import matplotlib.pyplot as plt

    labels = [label for label, _, _ in rows]
    counts = [count for _, count, _ in rows]
    total_count = sum(counts)

    if total_count <= 0:
        fig, ax = plt.subplots(figsize=(10, 6), dpi=150)
        ax.text(0.5, 0.5, "No data", ha="center", va="center", fontsize=18)
        ax.axis("off")
        fig.suptitle(title)
        fig.tight_layout()
        return fig

    if wide_layout:
        fig, ax = plt.subplots(figsize=(18, 9), dpi=150)
    else:
        fig, ax = plt.subplots(figsize=(13, max(6, len(rows) * 0.42)), dpi=150)
    colors = plt.cm.tab20.colors
    startangle = choose_pie_startangle(labels, counts)
    wedges, _ = ax.pie(counts, startangle=startangle, counterclock=False, colors=colors[: len(counts)])
    ax.axis("equal")
    ax.set_title(title, fontsize=16, pad=20)

    for wedge, label, count in zip(wedges, labels, counts):
        angle = (wedge.theta2 + wedge.theta1) / 2.0
        angle_rad = math.radians(angle)
        x = math.cos(angle_rad)
        y = math.sin(angle_rad)
        percent = percentage_text_precise(count, total_count)
        text_x = 1.65 if x >= 0 else -1.65
        text_y = 1.2 * y
        ax.annotate(
            f"{label} {percent} %",
            xy=(0.98 * x, 0.98 * y),
            xytext=(text_x, text_y),
            ha="left" if x >= 0 else "right",
            va="center",
            fontsize=8.5 if wide_layout else 9,
            bbox=dict(boxstyle="round,pad=0.25", fc="white", ec="#c7cdd6", alpha=0.96),
            arrowprops=dict(
                arrowstyle="-",
                color="#666",
                lw=0.8,
                shrinkA=0,
                shrinkB=0,
                connectionstyle="arc3,rad=0.0",
            ),
        )

    fig.tight_layout()
    return fig


def save_frequency_chart_outputs(
    output_base_path: Path,
    title: str,
    rows: list[tuple[str, int, str]],
    wide_layout: bool = False,
) -> None:
    from PIL import Image
    import matplotlib.pyplot as plt

    log(f"Erzeuge Diagramm: {output_base_path.with_suffix('.png').name}")
    fig = create_frequency_pie_chart(title, rows, wide_layout=wide_layout)
    buffer = BytesIO()
    fig.savefig(buffer, format="png", bbox_inches="tight")
    plt.close(fig)
    buffer.seek(0)

    with open(output_base_path.with_suffix(".png"), "wb") as png_file:
        png_file.write(buffer.getvalue())
    log(f"PNG geschrieben: {output_base_path.with_suffix('.png')}")

    buffer.seek(0)
    with Image.open(buffer) as image:
        image.save(output_base_path.with_suffix(".gif"), format="GIF", optimize=True)
    log(f"GIF geschrieben: {output_base_path.with_suffix('.gif')}")


def plot_country_boundaries(ax, boundaries: list[CountryBoundary]) -> None:
    for boundary in boundaries:
        for polygon in boundary.polygons:
            for ring in polygon:
                if len(ring) < 2:
                    continue
                xs = [point[0] for point in ring]
                ys = [point[1] for point in ring]
                ax.plot(xs, ys, color="#99a4b2", linewidth=0.35, alpha=0.55)


def draw_approx_distance_circle(ax, lon: float, lat: float, radius_km: float = 1000.0, **kwargs) -> None:
    radius_deg_lat = radius_km / 111.32
    radius_deg_lon = radius_deg_lat / max(0.25, math.cos(math.radians(lat)))
    circle_lons: list[float] = []
    circle_lats: list[float] = []
    for step in range(181):
        angle = math.radians(step * 2.0)
        circle_lons.append(lon + radius_deg_lon * math.cos(angle))
        circle_lats.append(lat + radius_deg_lat * math.sin(angle))
    ax.plot(circle_lons, circle_lats, **kwargs)


def lonlat_to_svg(lon: float, lat: float, width: float, height: float) -> tuple[float, float]:
    x = (normalize_longitude(lon) + 180.0) / 360.0 * width
    y = (90.0 - lat) / 180.0 * height
    return x, y


def build_no_country_svg(unknown_records: list[ObsCodeRecord], boundaries: list[CountryBoundary]) -> str:
    width = 1200.0
    height = 600.0
    boundary_lines: list[str] = []

    for boundary in boundaries:
        for polygon in boundary.polygons:
            for ring in polygon:
                if len(ring) < 2:
                    continue
                points = []
                for lon, lat in ring:
                    x, y = lonlat_to_svg(lon, lat, width, height)
                    points.append(f"{x:.2f},{y:.2f}")
                boundary_lines.append(
                    f'<polyline points="{" ".join(points)}" fill="none" stroke="#9aa6b6" stroke-width="0.35" opacity="0.55" />'
                )

    point_marks: list[str] = []
    for record in unknown_records:
        lon = parse_float_text(record.longitude)
        lat = derive_geodetic_latitude(record)
        if lon is None or lat is None:
            continue
        lon = normalize_longitude(lon)
        x, y = lonlat_to_svg(lon, lat, width, height)
        circle_radius = max(3.0, 500.0 / 111.32 / 3.0)
        circle_points = []
        for step in range(121):
            angle = math.radians(step * 3.0)
            dx = circle_radius * math.cos(angle)
            dy = circle_radius * math.sin(angle)
            circle_points.append(f"{x + dx:.2f},{y + dy:.2f}")
        point_marks.append(
            "<g>"
            f'<title>{html_escape(record.obscode)} | {html_escape(record.display_name)} | {html_escape(record.country_reason or "Unknown")}</title>'
            f'<polygon points="{" ".join(circle_points)}" fill="rgba(200,0,0,0.08)" stroke="#c1121f" stroke-width="1" />'
            f'<circle cx="{x:.2f}" cy="{y:.2f}" r="4" fill="#c1121f" stroke="#8a0f18" stroke-width="0.6" />'
            "</g>"
        )

    return f"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}">
  <rect width="100%" height="100%" fill="#f7fbff" />
  <g>{''.join(boundary_lines)}</g>
  <g>{''.join(point_marks)}</g>
</svg>
"""


def write_no_country_output(records: list[ObsCodeRecord], boundaries: list[CountryBoundary], base_url: str) -> None:
    unknown_records = collect_no_country_rows(records)
    log(f"Erzeuge No-Country-Ausgabe: {len(unknown_records)} unbekannte Datensaetze")
    generated_at = utc_now_text()
    if not unknown_records:
        Path("no_country.html").write_text(
            f"<!doctype html><html><body><p>Generated (UTC): {html_escape(generated_at)}</p><p>No unknown countries.</p></body></html>",
            encoding="utf-8",
        )
        log("HTML geschrieben: no_country.html")
        return

    import matplotlib.pyplot as plt

    log("Erzeuge No-Country-Karte: no_country_map.png")
    fig, ax = plt.subplots(figsize=(14, 8), dpi=150)
    plot_country_boundaries(ax, boundaries)
    lon_values: list[float] = []
    lat_values: list[float] = []
    for record in unknown_records:
        lon = parse_float_text(record.longitude)
        lat = derive_geodetic_latitude(record)
        if lon is None or lat is None:
            continue
        lon = normalize_longitude(lon)
        lon_values.append(lon)
        lat_values.append(lat)
        ax.scatter([lon], [lat], s=12, color="#c1121f", alpha=0.8)
        draw_approx_distance_circle(ax, lon, lat, 500.0, color="#c1121f", linewidth=0.5, alpha=0.25)

    if lon_values and lat_values:
        min_lon = max(-180.0, min(lon_values) - 12.0)
        max_lon = min(180.0, max(lon_values) + 12.0)
        min_lat = max(-90.0, min(lat_values) - 12.0)
        max_lat = min(90.0, max(lat_values) + 12.0)
        ax.set_xlim(min_lon, max_lon)
        ax.set_ylim(min_lat, max_lat)
    ax.set_title("Unknown-country locations with 500 km search radius")
    ax.set_xlabel("Longitude")
    ax.set_ylabel("Latitude")
    ax.grid(True, linestyle=":", linewidth=0.5, alpha=0.4)
    fig.tight_layout()
    map_path = Path("no_country_map.png")
    fig.savefig(map_path, bbox_inches="tight")
    plt.close(fig)
    log(f"Karte geschrieben: {map_path} ({len(lon_values)} Punkte)")

    inline_svg = build_no_country_svg(unknown_records, boundaries)
    rows_html = []
    for record in unknown_records:
        reason = html_escape(record.country_reason or "Unknown by boundary lookup")
        code_link = station_link(record.obscode, base_url)
        rows_html.append(
            "<tr>"
            f'<td class="code"><a href="{html_escape(code_link)}" title="Open the station page for this code.">{html_escape(record.obscode)}</a></td>'
            f"{station_name_cell(record)}"
            f'<td class="reason">{reason}</td>'
            "</tr>"
        )

    html_text = f"""<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Unknown Countries</title>
  <style>
    body {{ font-family: Arial, Helvetica, sans-serif; margin: 0; color: #111; background: #f6f8fb; }}
    .page {{ padding: 2rem; }}
    .panel {{ background: #fff; border: 1px solid #d8dde6; border-radius: 12px; box-shadow: 0 8px 24px rgba(30, 42, 62, 0.08); padding: 1rem 1.1rem; }}
    .table-wrap {{ display: inline-block; max-width: 100%; overflow-x: auto; }}
    table {{ border-collapse: collapse; width: auto; table-layout: auto; display: inline-table; margin-top: 1rem; }}
    th, td {{ border: 1px solid #ccc; padding: 0.4rem 0.5rem; text-align: left; vertical-align: top; white-space: normal; }}
    th {{ background: #eee; text-align: left; }}
    td.code, td.name, td.reason {{ text-align: left; }}
    td.code, td.reason {{ min-width: 4ch; }}
    td.name {{ width: 20ch; min-width: 16ch; max-width: 24ch; overflow-wrap: normal; word-break: normal; }}
    .name-elevation-alert, .name-elevation-alert a {{ color: #b00020; font-weight: 700; }}
    img {{ max-width: 100%; height: auto; border: 1px solid #ddd; }}
    .legend {{
      margin-top: 1rem;
      padding-top: 0.6rem;
      border-top: 1px solid #d8dde6;
    }}
    {site_layout_css()}
  </style>
</head>
<body>
  {site_sidebar_html("no_country.html")}
  <div class="page">
  <div class="panel">
    <h1>Unknown countries</h1>
    <p>Generated (UTC): {html_escape(generated_at)}</p>
    <p>These station rows could not be matched to a country by the cached boundary data.</p>
    <p>Map excerpt with 500 km radius around unknown-country station coordinates:</p>
    <div class="map">{inline_svg}</div>
    {table_export_button("no-country-table")}
    <table id="no-country-table" data-sortable-table="1">
      <thead>
        <tr>
          <th data-type="text" title="The MPC observatory code, linked to the station page.">Code</th>
          <th data-type="text" title="The station name shown in the snapshot.">Name</th>
          <th data-type="text" title="Why the country could not be assigned.">Reason</th>
        </tr>
      </thead>
      <tbody>
        {''.join(rows_html)}
      </tbody>
    </table>
    <div class="legend">
      <strong>Column notes</strong>
      <ul>
        <li><strong>Code</strong>: the stable MPC observatory code and link to the matching station page.</li>
        <li><strong>Name</strong>: the human-readable station name from the snapshot.</li>
        <li><strong>Reason</strong>: the boundary or metadata reason why no country could be assigned.</li>
      </ul>
    </div>
  </div>
  </div>
{table_export_script()}
</body>
</html>
"""
    Path("no_country.html").write_text(html_text, encoding="utf-8")
    log("HTML geschrieben: no_country.html")


def write_no_flag_output(records: list[ObsCodeRecord]) -> None:
    rows = collect_no_flag_rows(records)
    log(f"Erzeuge No-Flag-Ausgabe: {len(rows)} Eintraege")
    generated_at = utc_now_text()
    if not rows:
        Path("no_flag.html").write_text(
            f"<!doctype html><html><body><p>Generated (UTC): {html_escape(generated_at)}</p><p>No countries without flags.</p></body></html>",
            encoding="utf-8",
        )
        log("HTML geschrieben: no_flag.html")
        return

    rows_html = "\n".join(
        f'<tr><td class="country">{html_escape(country)}</td><td class="iso2">{html_escape(iso2)}</td><td class="reason">{html_escape(reason)}</td></tr>'
        for country, iso2, reason in rows
    )
    html_text = f"""<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>No Flag Countries</title>
  <style>
    body {{ font-family: Arial, Helvetica, sans-serif; margin: 0; color: #111; background: #f6f8fb; }}
    .page {{ padding: 2rem; }}
    .panel {{ background: #fff; border: 1px solid #d8dde6; border-radius: 12px; box-shadow: 0 8px 24px rgba(30, 42, 62, 0.08); padding: 1rem 1.1rem; }}
    table {{ border-collapse: collapse; width: auto; table-layout: auto; display: inline-table; margin-top: 1rem; }}
    th, td {{ border: 1px solid #ccc; padding: 0.4rem 0.5rem; text-align: left; vertical-align: top; white-space: normal; }}
    th {{ background: #eee; text-align: left; }}
    td.country, td.iso2, td.reason {{ min-width: 4ch; }}
    .legend {{
      margin-top: 1rem;
      padding-top: 0.6rem;
      border-top: 1px solid #d8dde6;
    }}
    {site_layout_css()}
  </style>
</head>
<body>
  {site_sidebar_html("no_flag.html")}
  <div class="page">
  <div class="panel">
    <h1>Countries without a flag</h1>
    <p>Generated (UTC): {html_escape(generated_at)}</p>
    <p>These country values do not currently have an assignable SVG flag in the local cache.</p>
    {table_export_button("no-flag-table")}
    <table id="no-flag-table" data-sortable-table="1">
      <thead>
        <tr>
          <th data-type="text" title="The country name stored in the local cache.">Country</th>
          <th data-type="text" title="The two-letter or symbolic code used to fetch the flag icon.">ISO2</th>
          <th data-type="text" title="Why no local flag file was available.">Reason</th>
        </tr>
      </thead>
      <tbody>{rows_html}</tbody>
    </table>
    <div class="legend">
      <strong>Column notes</strong>
      <ul>
        <li><strong>Country</strong>: the stored country label used for the local cache lookup.</li>
        <li><strong>ISO2</strong>: the local flag code or symbolic flag identifier.</li>
        <li><strong>Reason</strong>: the reason why a matching SVG flag is not available.</li>
      </ul>
    </div>
  </div>
  </div>
{table_export_script()}
</body>
</html>
"""
    Path("no_flag.html").write_text(html_text, encoding="utf-8")
    log("HTML geschrieben: no_flag.html")


def country_map_slug(country_name: str) -> str:
    slug = re.sub(r"[^0-9A-Za-z]+", "_", country_name.strip().lower()).strip("_")
    return slug or "country"


def normalize_country_display_name(country_name: str) -> str:
    return country_name.strip().rstrip("?").strip() or "Unknown"


def collect_country_groups(records: list[ObsCodeRecord]) -> dict[tuple[str, str], list[ObsCodeRecord]]:
    groups: dict[tuple[str, str], list[ObsCodeRecord]] = {}
    for record in records:
        country_name = normalize_country_display_name(display_country_name(record.country_name or "Unknown"))
        if country_name == "Unknown":
            continue
        country_iso2 = (record.country_iso2 or COUNTRY_NAME_TO_ISO2.get(country_name, "")).strip().lower()
        key = (country_name, country_iso2)
        groups.setdefault(key, []).append(record)
    return groups


def record_geodetic_point(record: ObsCodeRecord) -> tuple[float, float] | None:
    lon = parse_float_text(record.longitude)
    lat = derive_geodetic_latitude(record)
    if lon is None or lat is None:
        return None
    return normalize_longitude(lon), lat


def horizontal_distance_meters(lon_a: float, lat_a: float, lon_b: float, lat_b: float) -> float:
    radius_meters = 6371008.8
    lat_a_rad = math.radians(lat_a)
    lat_b_rad = math.radians(lat_b)
    delta_lat = math.radians(lat_b - lat_a)
    delta_lon_degrees = abs(normalize_longitude(lon_b - lon_a))
    delta_lon = math.radians(delta_lon_degrees)
    a = (
        math.sin(delta_lat / 2.0) * math.sin(delta_lat / 2.0)
        + math.cos(lat_a_rad) * math.cos(lat_b_rad) * math.sin(delta_lon / 2.0) * math.sin(delta_lon / 2.0)
    )
    return 2.0 * radius_meters * math.asin(min(1.0, math.sqrt(a)))


def format_distance_meters(value: float | None) -> str:
    if value is None:
        return ""
    if value < 1.0:
        return f"{value:.2f}"
    return f"{value:.1f}"


def record_observation_count(record: ObsCodeRecord) -> int:
    return max(0, record.observations_count or 0)


def record_observation_period_text(record: ObsCodeRecord) -> str:
    first_date = format_date_only(record.jost_first_date)
    last_date = format_date_only(record.jost_last_date)
    if first_date or last_date:
        return f"{first_date or '?'} to {last_date or '?'}"
    return "unknown"


def observation_marker_radius(record: ObsCodeRecord, max_observations: int) -> float:
    observation_count = record_observation_count(record)
    if observation_count <= 0 or max_observations <= 0:
        weight = 0.0
    else:
        weight = observation_count / max_observations
    min_area = MAP_MARKER_MIN_RADIUS_PX * MAP_MARKER_MIN_RADIUS_PX
    max_area = MAP_MARKER_MAX_RADIUS_PX * MAP_MARKER_MAX_RADIUS_PX
    return math.sqrt(min_area + weight * (max_area - min_area))


def minimal_longitude_window(lon_values: list[float]) -> tuple[float, float]:
    if not lon_values:
        return -180.0, 180.0
    normalized_values = sorted(normalize_longitude_360(value) for value in lon_values)
    if len(normalized_values) == 1:
        center = normalized_values[0]
        return center - 5.0, center + 5.0

    largest_gap = -1.0
    largest_gap_index = 0
    for index, value in enumerate(normalized_values):
        next_value = normalized_values[(index + 1) % len(normalized_values)]
        if index == len(normalized_values) - 1:
            next_value += 360.0
        gap = next_value - value
        if gap > largest_gap:
            largest_gap = gap
            largest_gap_index = index

    start = normalized_values[(largest_gap_index + 1) % len(normalized_values)]
    end = normalized_values[largest_gap_index]
    if end < start:
        end += 360.0
    span = end - start
    if span >= 355.0:
        return -180.0, 180.0
    padding = max(2.0, min(12.0, span * 0.12 + 1.0))
    return start - padding, end + padding


def unwrap_longitude_to_center(lon: float, center: float) -> float:
    base = normalize_longitude_360(lon)
    candidates = [base - 720.0, base - 360.0, base, base + 360.0, base + 720.0]
    return min(candidates, key=lambda candidate: abs(candidate - center))


def country_boundary_matches(boundary: CountryBoundary, country_name: str, country_iso2: str) -> bool:
    normalized_name = normalize_country_display_name(display_country_name(country_name)).lower()
    boundary_name = normalize_country_display_name(display_country_name(boundary.name)).lower()
    normalized_iso2 = country_iso2.strip().lower()
    return bool(
        (normalized_iso2 and boundary.iso2.strip().lower() == normalized_iso2)
        or (normalized_name and boundary_name == normalized_name)
        or (normalized_name == "united states of america" and boundary_name in {"united states of america", "united states"})
    )


def selected_country_rings(
    boundaries: list[CountryBoundary],
    country_name: str,
    country_iso2: str,
) -> list[list[tuple[float, float]]]:
    rings: list[list[tuple[float, float]]] = []
    for boundary in boundaries:
        if not country_boundary_matches(boundary, country_name, country_iso2):
            continue
        for polygon in boundary.polygons:
            if polygon and len(polygon[0]) >= 2:
                rings.append(polygon[0])
    if normalize_country_display_name(country_name) == "Ukraine":
        rings.append(UKRAINE_CRIMEA_DISPLAY_RING)
    return rings


def country_center_longitude(
    records: list[ObsCodeRecord],
    boundaries: list[CountryBoundary],
    country_name: str,
    country_iso2: str,
    *,
    world: bool = False,
) -> float:
    if world:
        return 0.0
    lon_values: list[float] = []
    for ring in selected_country_rings(boundaries, country_name, country_iso2):
        lon_values.extend(lon for lon, _lat in ring)
    for record in records:
        point = record_geodetic_point(record)
        if point is not None:
            lon_values.append(point[0])
    if not lon_values:
        return 0.0
    min_lon, max_lon = minimal_longitude_window(lon_values)
    return normalize_longitude((min_lon + max_lon) / 2.0)


def country_center_latitude(
    records: list[ObsCodeRecord],
    boundaries: list[CountryBoundary],
    country_name: str,
    country_iso2: str,
    *,
    world: bool = False,
) -> float:
    if world:
        return 0.0
    lat_values: list[float] = []
    for ring in selected_country_rings(boundaries, country_name, country_iso2):
        lat_values.extend(lat for _lon, lat in ring)
    for record in records:
        point = record_geodetic_point(record)
        if point is not None:
            lat_values.append(point[1])
    if not lat_values:
        return 0.0
    sorted_values = sorted(lat_values)
    middle = len(sorted_values) // 2
    if len(sorted_values) % 2:
        value = sorted_values[middle]
    else:
        value = (sorted_values[middle - 1] + sorted_values[middle]) / 2.0
    return max(-85.0, min(85.0, value))


def mollweide_project(lon: float, lat: float, center_lon: float) -> tuple[float, float]:
    lon_delta = math.radians(normalize_longitude(lon - center_lon))
    lat_radians = math.radians(max(-89.999999, min(89.999999, lat)))
    if abs(abs(lat_radians) - math.pi / 2.0) < 1e-12:
        theta = math.copysign(math.pi / 2.0, lat_radians)
    else:
        theta = lat_radians
        target = math.pi * math.sin(lat_radians)
        for _ in range(12):
            denominator = 2.0 + 2.0 * math.cos(2.0 * theta)
            if abs(denominator) < 1e-12:
                break
            delta = (2.0 * theta + math.sin(2.0 * theta) - target) / denominator
            theta -= delta
            if abs(delta) < 1e-12:
                break
    x = (2.0 * math.sqrt(2.0) / math.pi) * lon_delta * math.cos(theta)
    y = math.sqrt(2.0) * math.sin(theta)
    return x, y


def orthographic_project(lon: float, lat: float, center_lon: float, center_lat: float) -> tuple[float, float] | None:
    lon_delta = math.radians(normalize_longitude(lon - center_lon))
    lat_radians = math.radians(max(-89.999999, min(89.999999, lat)))
    center_lat_radians = math.radians(max(-89.999999, min(89.999999, center_lat)))
    sin_lat = math.sin(lat_radians)
    cos_lat = math.cos(lat_radians)
    sin_center = math.sin(center_lat_radians)
    cos_center = math.cos(center_lat_radians)
    cos_c = sin_center * sin_lat + cos_center * cos_lat * math.cos(lon_delta)
    if cos_c < -0.015:
        return None
    x = cos_lat * math.sin(lon_delta)
    y = cos_center * sin_lat - sin_center * cos_lat * math.cos(lon_delta)
    return x, y


@dataclass
class MapProjection:
    mode: str
    center_lon: float
    center_lat: float
    min_x: float
    max_x: float
    min_y: float
    max_y: float
    width: float
    height: float

    def raw_point(self, lon: float, lat: float) -> tuple[float, float] | None:
        if self.mode == "orthographic":
            return orthographic_project(lon, lat, self.center_lon, self.center_lat)
        return mollweide_project(lon, lat, self.center_lon)

    def svg_point(self, lon: float, lat: float) -> tuple[float, float] | None:
        raw_point = self.raw_point(lon, lat)
        if raw_point is None:
            return None
        x_value, y_value = raw_point
        x_span = max(self.max_x - self.min_x, 1e-9)
        y_span = max(self.max_y - self.min_y, 1e-9)
        x = (x_value - self.min_x) / x_span * self.width
        y = (self.max_y - y_value) / y_span * self.height
        return x, y


def build_map_projection(
    records: list[ObsCodeRecord],
    boundaries: list[CountryBoundary],
    country_name: str,
    country_iso2: str,
    width: float,
    height: float,
    *,
    world: bool = False,
) -> MapProjection:
    center_lon = country_center_longitude(records, boundaries, country_name, country_iso2, world=world)
    center_lat = country_center_latitude(records, boundaries, country_name, country_iso2, world=world)
    mode = "mollweide" if world else "orthographic"

    def project_raw(lon: float, lat: float) -> tuple[float, float] | None:
        if mode == "orthographic":
            return orthographic_project(lon, lat, center_lon, center_lat)
        return mollweide_project(lon, lat, center_lon)

    projected_points: list[tuple[float, float]] = []
    if world:
        sample_lons = list(range(-180, 181, 10))
        sample_lats = list(range(-80, 81, 10))
        projected_points.extend(mollweide_project(float(lon), float(lat), center_lon) for lon in sample_lons for lat in sample_lats)
    else:
        for ring in selected_country_rings(boundaries, country_name, country_iso2):
            for lon, lat in ring:
                projected = project_raw(lon, lat)
                if projected is not None:
                    projected_points.append(projected)
    for record in records:
        point = record_geodetic_point(record)
        if point is not None:
            projected = project_raw(point[0], point[1])
            if projected is not None:
                projected_points.append(projected)
    if not projected_points:
        projected_points = [(-1.0, -0.65), (1.0, 0.65)] if mode == "orthographic" else [
            mollweide_project(-180.0, -60.0, center_lon),
            mollweide_project(180.0, 85.0, center_lon),
        ]

    min_x = min(x for x, _ in projected_points)
    max_x = max(x for x, _ in projected_points)
    min_y = min(y for _, y in projected_points)
    max_y = max(y for _, y in projected_points)
    if mode == "orthographic":
        if max_x - min_x < 0.08:
            center_x = (min_x + max_x) / 2.0
            min_x = center_x - 0.04
            max_x = center_x + 0.04
        if max_y - min_y < 0.08:
            center_y = (min_y + max_y) / 2.0
            min_y = center_y - 0.04
            max_y = center_y + 0.04
    x_span = max(max_x - min_x, 1e-6)
    y_span = max(max_y - min_y, 1e-6)
    x_padding = x_span * 0.07
    y_padding = y_span * 0.07
    min_x -= x_padding
    max_x += x_padding
    min_y -= y_padding
    max_y += y_padding

    projected_span_x = max_x - min_x
    projected_span_y = max_y - min_y
    viewport_ratio = width / height
    projected_ratio = projected_span_x / max(projected_span_y, 1e-9)
    if projected_ratio > viewport_ratio:
        target_span_y = projected_span_x / viewport_ratio
        extra = (target_span_y - projected_span_y) / 2.0
        min_y -= extra
        max_y += extra
    else:
        target_span_x = projected_span_y * viewport_ratio
        extra = (target_span_x - projected_span_x) / 2.0
        min_x -= extra
        max_x += extra

    return MapProjection(
        mode=mode,
        center_lon=center_lon,
        center_lat=center_lat,
        min_x=min_x,
        max_x=max_x,
        min_y=min_y,
        max_y=max_y,
        width=width,
        height=height,
    )


def country_map_extent(
    records: list[ObsCodeRecord],
    country_name: str,
    *,
    world: bool = False,
) -> tuple[float, float, float, float]:
    if world:
        return -180.0, 180.0, -60.0, 85.0

    points = [point for point in (record_geodetic_point(record) for record in records) if point is not None]
    lon_values = [point[0] for point in points]
    lat_values = [point[1] for point in points]
    if normalize_country_display_name(country_name) == "Ukraine":
        lon_values.extend(lon for lon, _ in UKRAINE_CRIMEA_DISPLAY_RING)
        lat_values.extend(lat for _, lat in UKRAINE_CRIMEA_DISPLAY_RING)
    if not lon_values or not lat_values:
        return -180.0, 180.0, -60.0, 85.0

    min_lon, max_lon = minimal_longitude_window(lon_values)
    min_lat = min(lat_values)
    max_lat = max(lat_values)
    lat_span = max(max_lat - min_lat, 2.0)
    lat_padding = max(1.0, min(8.0, lat_span * 0.18 + 0.7))
    return min_lon, max_lon, max(-90.0, min_lat - lat_padding), min(90.0, max_lat + lat_padding)


def ring_to_svg_path(
    ring: list[tuple[float, float]],
    *,
    center_lon: float,
    min_lon: float,
    max_lon: float,
    min_lat: float,
    max_lat: float,
    width: float,
    height: float,
    margin: float = 2.0,
) -> str:
    unwrapped_points = [(unwrap_longitude_to_center(lon, center_lon), lat) for lon, lat in ring]
    xs = [point[0] for point in unwrapped_points]
    ys = [point[1] for point in unwrapped_points]
    if not xs or not ys:
        return ""
    if max(xs) < min_lon - margin or min(xs) > max_lon + margin or max(ys) < min_lat - margin or min(ys) > max_lat + margin:
        return ""
    lon_span = max(max_lon - min_lon, 1e-9)
    lat_span = max(max_lat - min_lat, 1e-9)
    commands: list[str] = []
    for index, (lon, lat) in enumerate(unwrapped_points):
        x = (lon - min_lon) / lon_span * width
        y = (max_lat - lat) / lat_span * height
        commands.append(f"{'M' if index == 0 else 'L'} {x:.2f} {y:.2f}")
    commands.append("Z")
    return " ".join(commands)


def build_country_boundary_paths(
    boundaries: list[CountryBoundary],
    country_name: str,
    country_iso2: str,
    projection: MapProjection,
    *,
    world: bool = False,
) -> str:
    path_elements: list[str] = []
    for boundary in boundaries:
        selected = (not world) and country_boundary_matches(boundary, country_name, country_iso2)
        css_class = "selected-land" if selected else "land"
        for polygon in boundary.polygons:
            for ring_index, ring in enumerate(polygon):
                if ring_index > 0 or len(ring) < 2:
                    continue
                projected_points: list[tuple[float, float]] = []
                for lon, lat in ring:
                    projected = projection.svg_point(lon, lat)
                    if projected is None:
                        projected_points = []
                        break
                    projected_points.append(projected)
                if not projected_points:
                    continue
                xs = [x for x, _ in projected_points]
                ys = [y for _, y in projected_points]
                if max(xs) < -60.0 or min(xs) > projection.width + 60.0 or max(ys) < -60.0 or min(ys) > projection.height + 60.0:
                    continue
                path_data = " ".join(
                    f"{'M' if point_index == 0 else 'L'} {x:.2f} {y:.2f}"
                    for point_index, (x, y) in enumerate(projected_points)
                ) + " Z"
                if path_data:
                    path_elements.append(f'<path class="{css_class}" d="{path_data}" />')

    if normalize_country_display_name(country_name) == "Ukraine":
        projected_points = [point for lon, lat in UKRAINE_CRIMEA_DISPLAY_RING if (point := projection.svg_point(lon, lat)) is not None]
        path_data = " ".join(
            f"{'M' if point_index == 0 else 'L'} {x:.2f} {y:.2f}"
            for point_index, (x, y) in enumerate(projected_points)
        ) + " Z"
        if path_data:
            path_elements.append(f'<path class="selected-land manual-boundary" d="{path_data}" />')
    return "\n        ".join(path_elements)


def build_country_marker_elements(
    records: list[ObsCodeRecord],
    projection: MapProjection,
) -> str:
    max_observations = max((record_observation_count(record) for record in records), default=0)
    marker_elements: list[str] = []
    for record in records:
        point = record_geodetic_point(record)
        if point is None:
            continue
        lon, lat = point
        projected = projection.svg_point(lon, lat)
        if projected is None:
            continue
        x, y = projected
        if x < -30.0 or x > projection.width + 30.0 or y < -30.0 or y > projection.height + 30.0:
            continue
        radius = observation_marker_radius(record, max_observations)
        title = title_lines(
            f"Code: {record.obscode.strip()}",
            f"Name: {record.display_name}",
            f"Observations: {format_integer_with_dots(record_observation_count(record)) or 'unknown'}",
            f"Period: {record_observation_period_text(record)}",
        )
        marker_elements.append(
            f'<g class="station-marker" data-x="{x:.2f}" data-y="{y:.2f}" transform="translate({x:.2f} {y:.2f})">'
            f"<title>{html_escape(title)}</title>"
            f'<circle cx="0" cy="0" r="{radius:.2f}" />'
            f'<text x="{radius + 2.0:.2f}" y="3.00">{html_escape(record.obscode.strip())}</text>'
            "</g>"
        )
    if not marker_elements:
        marker_elements.append(
            f'<text x="{projection.width / 2:.2f}" y="{projection.height / 2:.2f}" text-anchor="middle" class="no-points">No station coordinates available</text>'
        )
    return "\n        ".join(marker_elements)


def write_interactive_country_map(
    title: str,
    country_name: str,
    country_iso2: str,
    records: list[ObsCodeRecord],
    boundaries: list[CountryBoundary],
    output_path: Path,
    generated_at: str,
    *,
    world: bool = False,
) -> None:
    width = 1200.0
    height = 760.0
    projection = build_map_projection(records, boundaries, country_name, country_iso2, width, height, world=world)
    boundary_paths = build_country_boundary_paths(
        boundaries,
        country_name,
        country_iso2,
        projection,
        world=world,
    )
    markers = build_country_marker_elements(records, projection)
    crimea_note = (
        " For Ukraine, Crimea is added as a manual display overlay so the mockup and generated country map reflect the requested political view."
        if normalize_country_display_name(country_name) == "Ukraine"
        else ""
    )
    html_text = f"""<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{html_escape(title)}</title>
  <style>
    body {{
      margin: 0;
      font-family: Arial, Helvetica, sans-serif;
      color: #132238;
      background: #f4f7fb;
      overflow: hidden;
    }}
    .page {{
      height: 100vh;
      display: flex;
      flex-direction: column;
      min-width: 0;
    }}
    header {{
      padding: 0.9rem 1.1rem;
      border-bottom: 1px solid #cfd8e3;
      background: #fff;
    }}
    h1 {{
      margin: 0;
      font-size: 1.25rem;
    }}
    .meta {{
      margin-top: 0.3rem;
      color: #4d5b6f;
      font-size: 0.92rem;
    }}
    .toolbar {{
      position: absolute;
      top: 0.75rem;
      right: 0.75rem;
      z-index: 2;
    }}
    .toolbar button {{
      font: inherit;
      padding: 0.28rem 0.55rem;
      border: 1px solid #9baabe;
      border-radius: 4px;
      background: rgba(255, 255, 255, 0.92);
      color: #132238;
      cursor: pointer;
    }}
    .map-shell {{
      position: relative;
      flex: 1 1 auto;
      min-height: 0;
      background: #d9e8f7;
      overflow: hidden;
    }}
    svg {{
      display: block;
      width: 100%;
      height: 100%;
      cursor: grab;
      touch-action: none;
    }}
    svg.dragging {{
      cursor: grabbing;
    }}
    .water {{
      fill: #d9e8f7;
    }}
    .land {{
      fill: #eef2ec;
      stroke: #9ba892;
      stroke-width: 0.85;
      opacity: 0.55;
      vector-effect: non-scaling-stroke;
    }}
    .selected-land {{
      fill: #dceadf;
      stroke: #1f6f48;
      stroke-width: 1.35;
      vector-effect: non-scaling-stroke;
    }}
    .manual-boundary {{
      fill: #dceadf;
      stroke: #174a73;
      stroke-dasharray: 4 3;
    }}
    .station-marker circle {{
      fill: rgba(193, 18, 31, 0.78);
      stroke: #7f0b13;
      stroke-width: 0.9;
      vector-effect: non-scaling-stroke;
    }}
    .station-marker:hover circle {{
      fill: #ffbe0b;
      stroke: #7f0b13;
    }}
    .station-marker text {{
      fill: #132238;
      font-size: 10px;
      font-weight: 700;
      paint-order: stroke;
      stroke: rgba(255, 255, 255, 0.9);
      stroke-width: 2.5px;
      vector-effect: non-scaling-stroke;
    }}
    .graticule {{
      stroke: rgba(80, 100, 120, 0.18);
      stroke-width: 0.7;
      vector-effect: non-scaling-stroke;
    }}
    .no-points {{
      fill: #536579;
      font-size: 24px;
    }}
    footer {{
      padding: 0.65rem 1.1rem;
      border-top: 1px solid #cfd8e3;
      background: #fff;
      color: #4d5b6f;
      font-size: 0.9rem;
    }}
    {site_layout_css()}
    @media (max-width: 900px) {{
      body {{
        overflow: auto;
      }}
      .page {{
        min-height: 100vh;
        height: auto;
      }}
      .map-shell {{
        min-height: 70vh;
      }}
    }}
  </style>
</head>
<body>
  {site_sidebar_html("", "../")}
  <div class="page">
    <header>
      <h1>{html_escape(title)}</h1>
      <div class="meta">Generated (UTC): {html_escape(generated_at)} | Stations: {format_integer_with_dots(len(records))} | Drag to pan, mouse wheel to zoom.</div>
    </header>
    <main class="map-shell">
      <div class="toolbar"><button type="button" id="map-reset">Reset</button></div>
      <svg id="map-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width:.0f} {height:.0f}" aria-label="{html_escape(title)}">
        <rect class="water" x="0" y="0" width="{width:.0f}" height="{height:.0f}" />
        <g class="boundaries">
        {boundary_paths}
        </g>
        <g class="markers">
        {markers}
        </g>
      </svg>
    </main>
    <footer>
      {html_escape("Mollweide" if projection.mode == "mollweide" else "Orthographic")} projection centered on {projection.center_lon:.2f} degrees longitude and {projection.center_lat:.2f} degrees latitude. Marker area is linearly normalized by observation count, with radius from {MAP_MARKER_MIN_RADIUS_PX:.0f} to {MAP_MARKER_MAX_RADIUS_PX:.0f} px. Country boundaries come from the cached geo-countries GeoJSON, which is Natural Earth-derived.{html_escape(crimea_note)}
    </footer>
  </div>
  <script>
    (function () {{
      const svg = document.getElementById("map-svg");
      const resetButton = document.getElementById("map-reset");
      const markers = Array.from(svg.querySelectorAll(".station-marker"));
      const initial = {{x: 0, y: 0, w: {width:.1f}, h: {height:.1f}}};
      let viewBox = {{x: initial.x, y: initial.y, w: initial.w, h: initial.h}};
      let drag = null;

      function updateMarkerScale() {{
        const markerScale = viewBox.w / initial.w;
        markers.forEach(marker => {{
          const x = marker.dataset.x || "0";
          const y = marker.dataset.y || "0";
          marker.setAttribute("transform", "translate(" + x + " " + y + ") scale(" + markerScale + ")");
        }});
      }}

      function setViewBox() {{
        svg.setAttribute("viewBox", viewBox.x + " " + viewBox.y + " " + viewBox.w + " " + viewBox.h);
        updateMarkerScale();
      }}

      function svgPoint(event) {{
        const rect = svg.getBoundingClientRect();
        return {{
          x: viewBox.x + ((event.clientX - rect.left) / rect.width) * viewBox.w,
          y: viewBox.y + ((event.clientY - rect.top) / rect.height) * viewBox.h
        }};
      }}

      svg.addEventListener("wheel", (event) => {{
        event.preventDefault();
        const point = svgPoint(event);
        const factor = event.deltaY < 0 ? 0.82 : 1.22;
        const nextW = Math.max(initial.w / 1000000, Math.min(initial.w * 20, viewBox.w * factor));
        const nextH = Math.max(initial.h / 1000000, Math.min(initial.h * 20, viewBox.h * factor));
        viewBox.x = point.x - ((point.x - viewBox.x) / viewBox.w) * nextW;
        viewBox.y = point.y - ((point.y - viewBox.y) / viewBox.h) * nextH;
        viewBox.w = nextW;
        viewBox.h = nextH;
        setViewBox();
      }}, {{passive: false}});

      svg.addEventListener("pointerdown", (event) => {{
        drag = {{point: svgPoint(event), x: viewBox.x, y: viewBox.y}};
        svg.classList.add("dragging");
        svg.setPointerCapture(event.pointerId);
      }});

      svg.addEventListener("pointermove", (event) => {{
        if (!drag) return;
        const point = svgPoint(event);
        viewBox.x = drag.x + drag.point.x - point.x;
        viewBox.y = drag.y + drag.point.y - point.y;
        setViewBox();
      }});

      function stopDrag() {{
        drag = null;
        svg.classList.remove("dragging");
      }}
      svg.addEventListener("pointerup", stopDrag);
      svg.addEventListener("pointercancel", stopDrag);
      resetButton.addEventListener("click", () => {{
        viewBox = {{x: initial.x, y: initial.y, w: initial.w, h: initial.h}};
        setViewBox();
      }});
      setViewBox();
    }})();
  </script>
</body>
</html>
"""
    output_path.parent.mkdir(parents=True, exist_ok=True)
    output_path.write_text(html_text, encoding="utf-8")
    log(f"Interaktive Karte geschrieben: {output_path} ({len(records)} Datensaetze)")


def write_map_image(
    title: str,
    country_name: str,
    country_iso2: str,
    records: list[ObsCodeRecord],
    boundaries: list[CountryBoundary],
    output_path: Path,
    *,
    world: bool = False,
) -> None:
    log(f"Erzeuge Karte: {output_path} ({len(records)} Datensaetze)")
    import matplotlib.pyplot as plt
    from matplotlib.patches import Polygon

    width = 1200.0
    height = 760.0
    projection = build_map_projection(records, boundaries, country_name, country_iso2, width, height, world=world)
    fig, ax = plt.subplots(figsize=(14, 8), dpi=150)
    ax.set_facecolor("#d9e8f7")

    for boundary in boundaries:
        selected = (not world) and country_boundary_matches(boundary, country_name, country_iso2)
        face_color = "#dceadf" if selected else "#eef2ec"
        edge_color = "#1f6f48" if selected else "#9ba892"
        line_width = 0.65 if selected else 0.35
        z_order = 2 if selected else 1
        for polygon in boundary.polygons:
            if not polygon or len(polygon[0]) < 2:
                continue
            projected_points: list[tuple[float, float]] = []
            for lon, lat in polygon[0]:
                projected = projection.svg_point(lon, lat)
                if projected is None:
                    projected_points = []
                    break
                projected_points.append(projected)
            if not projected_points:
                continue
            xs = [x for x, _ in projected_points]
            ys = [y for _, y in projected_points]
            if max(xs) < -60.0 or min(xs) > width + 60.0 or max(ys) < -60.0 or min(ys) > height + 60.0:
                continue
            ax.add_patch(
                Polygon(
                    projected_points,
                    closed=True,
                    facecolor=face_color,
                    edgecolor=edge_color,
                    linewidth=line_width,
                    alpha=0.95 if selected or world else 0.48,
                    zorder=z_order,
                )
            )

    if normalize_country_display_name(country_name) == "Ukraine":
        projected_crimea = [point for lon, lat in UKRAINE_CRIMEA_DISPLAY_RING if (point := projection.svg_point(lon, lat)) is not None]
        if projected_crimea:
            ax.add_patch(
                Polygon(
                    projected_crimea,
                    closed=True,
                    facecolor="#dceadf",
                    edgecolor="#174a73",
                    linewidth=0.8,
                    linestyle="--",
                    alpha=0.95,
                    zorder=3,
                )
            )

    point_count = 0
    max_observations = max((record_observation_count(record) for record in records), default=0)
    for record in records:
        point = record_geodetic_point(record)
        if point is None:
            continue
        lon, lat = point
        projected = projection.svg_point(lon, lat)
        if projected is None:
            continue
        x, y = projected
        if x < -30.0 or x > width + 30.0 or y < -30.0 or y > height + 30.0:
            continue
        radius = observation_marker_radius(record, max_observations)
        ax.scatter([x], [y], s=(radius * 2.0) ** 2, color="#c1121f", edgecolors="#7f0b13", linewidths=0.35, alpha=0.85, zorder=5)
        ax.text(
            x + radius + 2.0,
            y + 3.0,
            record.obscode.strip(),
            fontsize=4.8,
            color="#132238",
            weight="bold",
            zorder=6,
        )
        point_count += 1

    ax.set_xlim(0.0, width)
    ax.set_ylim(height, 0.0)
    ax.set_aspect("equal", adjustable="box")
    projection_label = "Mollweide" if projection.mode == "mollweide" else "Orthographic"
    ax.set_title(f"{title} - {projection_label} centered on {projection.center_lon:.2f} deg, {projection.center_lat:.2f} deg")
    ax.set_xticks([])
    ax.set_yticks([])
    fig.tight_layout()
    output_path.parent.mkdir(parents=True, exist_ok=True)
    fig.savefig(output_path, bbox_inches="tight")
    plt.close(fig)
    log(f"Karte geschrieben: {output_path} ({point_count} Punkte)")


def write_country_map_outputs(records: list[ObsCodeRecord], boundaries: list[CountryBoundary]) -> dict[tuple[str, str], str]:
    groups = collect_country_groups(records)
    map_paths: dict[tuple[str, str], str] = {}
    base_dir = COUNTRY_MAP_DIR
    generated_at = utc_now_text()

    log(f"Erzeuge/pruefe Laenderkarten: {len(groups)} Laendergruppen")
    for group_index, ((country_name, country_iso2), grouped_records) in enumerate(groups.items(), start=1):
        log_progress("Laenderkarten", group_index, len(groups))
        slug = country_map_slug(country_name)
        title = f"{country_name} station map"
        legacy_path = base_dir / f"{slug}.png"
        html_path = base_dir / f"{slug}.html"
        write_map_image(title, country_name, country_iso2, grouped_records, boundaries, legacy_path)
        write_interactive_country_map(title, country_name, country_iso2, grouped_records, boundaries, html_path, generated_at)
        map_paths[(country_name, country_iso2)] = html_path.as_posix()

    world_legacy_path = base_dir / "world.png"
    world_html_path = base_dir / "world.html"
    write_map_image("World station map", "World", "", records, boundaries, world_legacy_path, world=True)
    write_interactive_country_map("World station map", "World", "", records, boundaries, world_html_path, generated_at, world=True)
    map_paths[("World", "")] = world_html_path.as_posix()
    log("Laenderkarten fertig")
    return map_paths


def mockup_country_records(records: list[ObsCodeRecord], country_name: str) -> list[ObsCodeRecord]:
    normalized_target = normalize_country_display_name(country_name).lower()
    return [
        record
        for record in records
        if normalize_country_display_name(display_country_name(record.country_name or "")).lower() == normalized_target
    ]


def write_country_svg_mockup(
    country_name: str,
    country_iso2: str,
    records: list[ObsCodeRecord],
    boundaries: list[CountryBoundary],
    output_path: Path,
    variant: int,
    generated_at: str,
) -> None:
    width = 1200.0
    height = 760.0
    projection = build_map_projection(records, boundaries, country_name, country_iso2, width, height)
    boundary_paths = build_country_boundary_paths(
        boundaries,
        country_name,
        country_iso2,
        projection,
    )
    markers = build_country_marker_elements(records, projection)
    if variant == 1:
        water, land, selected, marker = "#d9e8f7", "#eef2ec", "#dceadf", "rgba(193, 18, 31, 0.78)"
    elif variant == 2:
        water, land, selected, marker = "#e9f4f8", "#f5efe3", "#e1edd1", "rgba(24, 119, 242, 0.78)"
    else:
        water, land, selected, marker = "#172231", "#253142", "#2f5e56", "rgba(255, 190, 11, 0.82)"
    svg_text = f"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width:.0f} {height:.0f}">
  <style>
    .land {{ fill: {land}; stroke: #8c99a6; stroke-width: 0.85; vector-effect: non-scaling-stroke; }}
    .selected-land {{ fill: {selected}; stroke: #1f6f48; stroke-width: 1.45; vector-effect: non-scaling-stroke; }}
    .manual-boundary {{ stroke: #174a73; stroke-dasharray: 4 3; }}
    .station-marker circle {{ fill: {marker}; stroke: #7f0b13; stroke-width: 0.9; vector-effect: non-scaling-stroke; }}
    .station-marker text {{ fill: {"#f7fafc" if variant == 3 else "#132238"}; font-family: Arial, Helvetica, sans-serif; font-size: 10px; font-weight: 700; paint-order: stroke; stroke: {"#111827" if variant == 3 else "#ffffff"}; stroke-width: 2.2px; vector-effect: non-scaling-stroke; }}
    .label {{ fill: {"#f7fafc" if variant == 3 else "#132238"}; font-family: Arial, Helvetica, sans-serif; font-size: 24px; font-weight: 700; }}
    .meta {{ fill: {"#cbd5e1" if variant == 3 else "#4d5b6f"}; font-family: Arial, Helvetica, sans-serif; font-size: 14px; }}
  </style>
  <rect width="100%" height="100%" fill="{water}" />
  <g>{boundary_paths}</g>
  <g>{markers}</g>
  <text x="24" y="38" class="label">{html_escape(country_name)} map mockup v{variant}</text>
  <text x="24" y="62" class="meta">Generated (UTC): {html_escape(generated_at)} | {html_escape("Mollweide" if projection.mode == "mollweide" else "Orthographic")} centered on {projection.center_lon:.2f}, {projection.center_lat:.2f} degrees | Real country station data</text>
</svg>
"""
    output_path.parent.mkdir(parents=True, exist_ok=True)
    output_path.write_text(svg_text, encoding="utf-8")
    log(f"Mockup-Karte geschrieben: {output_path}")


def record_last_observation_datetime(record: ObsCodeRecord) -> datetime | None:
    return parse_mpc_datetime(record.jost_last_date or record.last_date or record.updated_at)


def recency_color(record: ObsCodeRecord, reference_date: datetime | None, max_observations: int) -> str:
    last_observation = record_last_observation_datetime(record)
    if last_observation is None or reference_date is None:
        hue = 215.0
    else:
        days = max(0, (reference_date - last_observation).days)
        hue = max(0.0, 135.0 - min(days, 3650) / 3650.0 * 135.0)
    observation_count = record_observation_count(record)
    weight = 0.0 if observation_count <= 0 or max_observations <= 0 else math.log1p(observation_count) / math.log1p(max_observations)
    lightness = 88.0 - weight * 48.0
    saturation = 72.0 if last_observation is not None else 20.0
    return f"hsl({hue:.0f} {saturation:.0f}% {lightness:.0f}%)"


def build_obscode_grid_svg(records: list[ObsCodeRecord], variant: int, generated_at: str) -> str:
    records_by_code = {
        record.obscode.strip().upper(): record
        for record in records
        if re.fullmatch(r"[A-Z][0-9]{2}", record.obscode.strip().upper())
    }
    max_observations = max((record_observation_count(record) for record in records_by_code.values()), default=0)
    reference_dates = [value for value in (record_last_observation_datetime(record) for record in records_by_code.values()) if value is not None]
    reference_date = max(reference_dates) if reference_dates else None
    if variant == 1:
        cell_size, gap, background, text_color = 11, 2, "#f7fbff", "#132238"
    elif variant == 2:
        cell_size, gap, background, text_color = 8, 1, "#ffffff", "#132238"
    else:
        cell_size, gap, background, text_color = 13, 2, "#111827", "#e5e7eb"
    left = 42
    top = 42
    width = left + 100 * (cell_size + gap) + 18
    height = top + 26 * (cell_size + gap) + 48
    code_columns = [f"{number:02d}" for number in range(100)]
    row_letters = [chr(ord("A") + index) for index in range(26)]
    cells: list[str] = []
    for row_index, letter in enumerate(row_letters):
        y = top + row_index * (cell_size + gap)
        cells.append(f'<text x="24" y="{y + cell_size - 1}" class="axis">{letter}</text>')
        for col_index, number_text in enumerate(code_columns):
            code = f"{letter}{number_text}"
            x = left + col_index * (cell_size + gap)
            record = records_by_code.get(code)
            if record is None:
                title = f"Code: {code}\nNot assigned in the current MPC data."
                cells.append(
                    f'<rect class="cell unassigned" x="{x}" y="{y}" width="{cell_size}" height="{cell_size}">'
                    f"<title>{html_escape(title)}</title></rect>"
                )
                continue
            fill = recency_color(record, reference_date, max_observations)
            title = title_lines(
                f"Code: {code}",
                f"Name: {record.display_name}",
                f"Observations: {format_integer_with_dots(record_observation_count(record))}",
                f"Period: {record_observation_period_text(record)}",
            )
            cells.append(
                f'<rect class="cell assigned" x="{x}" y="{y}" width="{cell_size}" height="{cell_size}" fill="{fill}">'
                f"<title>{html_escape(title)}</title></rect>"
            )
    column_labels = []
    for number in range(0, 100, 10):
        x = left + number * (cell_size + gap)
        column_labels.append(f'<text x="{x}" y="27" class="axis">{number:02d}</text>')
    return f"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}">
  <style>
    @keyframes blink-unassigned {{ 0%, 100% {{ opacity: 1; }} 50% {{ opacity: 0.32; }} }}
    .axis, .title, .meta {{ font-family: Arial, Helvetica, sans-serif; fill: {text_color}; }}
    .title {{ font-size: 20px; font-weight: 700; }}
    .meta {{ font-size: 12px; }}
    .axis {{ font-size: 10px; font-weight: 700; }}
    .cell {{ stroke: {"#374151" if variant == 3 else "#ffffff"}; stroke-width: 0.5; }}
    .unassigned {{ fill: {"#4b5563" if variant == 3 else "#b8c0cc"}; animation: blink-unassigned 1.8s ease-in-out infinite; }}
  </style>
  <rect width="100%" height="100%" fill="{background}" />
  <text x="18" y="{height - 22}" class="title">ObsCode grid mockup v{variant}</text>
  <text x="18" y="{height - 8}" class="meta">Generated (UTC): {html_escape(generated_at)} | Fill intensity: observation count, color hue: age of last observation, grey blinking: unassigned code.</text>
  <g>{''.join(column_labels)}</g>
  <g>{''.join(cells)}</g>
</svg>
"""


def write_map_mockup_index(mockup_files: list[Path], generated_at: str) -> None:
    cards = "\n".join(
        f'<a class="card" href="{html_escape(path.name)}">{html_escape(path.name)}</a>'
        for path in sorted(mockup_files, key=lambda item: item.name.lower())
    )
    html_text = f"""<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Map Mockups</title>
  <style>
    body {{ margin: 0; font-family: Arial, Helvetica, sans-serif; color: #132238; background: #f6f8fb; }}
    .page {{ padding: 1.4rem; }}
    .grid {{ display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.6rem; }}
    .card {{ display: block; padding: 0.75rem; border: 1px solid #cfd8e3; border-radius: 6px; background: #fff; color: #174a73; overflow-wrap: anywhere; text-decoration: none; }}
    @media (max-width: 900px) {{ .grid {{ grid-template-columns: repeat(2, minmax(0, 1fr)); }} }}
    @media (max-width: 560px) {{ .grid {{ grid-template-columns: 1fr; }} }}
  </style>
</head>
<body>
  <main class="page">
    <h1>Map Mockups</h1>
    <p>Generated (UTC): {html_escape(generated_at)}</p>
    <div class="grid">{cards}</div>
  </main>
</body>
</html>
"""
    (MAP_MOCKUP_DIR / "index.html").write_text(html_text, encoding="utf-8")


def write_mockup_outputs(records: list[ObsCodeRecord], boundaries: list[CountryBoundary]) -> None:
    generated_at = utc_now_text()
    MAP_MOCKUP_DIR.mkdir(parents=True, exist_ok=True)
    written_files: list[Path] = []
    for country_name, country_iso2 in [
        ("United States of America", "us"),
        ("Ukraine", "ua"),
        ("Russia", "ru"),
    ]:
        grouped_records = mockup_country_records(records, country_name)
        for variant in (1, 2, 3):
            output_path = MAP_MOCKUP_DIR / f"{country_map_slug(country_name)}_v{variant}.svg"
            write_country_svg_mockup(country_name, country_iso2, grouped_records, boundaries, output_path, variant, generated_at)
            written_files.append(output_path)

    for variant in (1, 2, 3):
        output_path = MAP_MOCKUP_DIR / f"obscode_grid_v{variant}.svg"
        output_path.write_text(build_obscode_grid_svg(records, variant, generated_at), encoding="utf-8")
        log(f"ObsCode-Grid-Mockup geschrieben: {output_path}")
        written_files.append(output_path)
    write_map_mockup_index(written_files, generated_at)
    log(f"Mockup-Ausgaben fertig: {len(written_files)} Dateien")


def write_frequency_outputs(
    records: list[ObsCodeRecord],
    attribute_name: str,
    title: str,
    header_label: str,
    output_html_name: str,
    output_image_name: str,
) -> None:
    log(f"Erzeuge Statistikseite: {output_html_name}")
    generated_at = utc_now_text()
    rows = collect_frequency_rows(records, attribute_name)
    log(f"Statistikgruppen fuer {header_label}: {len(rows)}")
    html_rows = [(html_escape(label), count, percent) for label, count, percent in rows]
    html_text = build_frequency_html_page(title, header_label, html_rows, generated_at)
    Path(output_html_name).write_text(html_text, encoding="utf-8")
    log(f"HTML geschrieben: {output_html_name}")
    save_frequency_chart_outputs(Path(output_image_name), title, rows)


def build_country_frequency_html_page(
    title: str,
    rows: list[tuple[str, int, str]],
    generated_at: str,
    chart_html: str,
    extra_note_html: str = "",
) -> str:
    table_rows = "\n".join(
        f"        <tr><td class=\"label\">{label}</td><td class=\"count\">{count}</td><td class=\"percent\">{percent} %</td></tr>"
        for label, count, percent in rows
    )
    return f"""<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>{html_escape(title)}</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <style>
    body {{
      font-family: Arial, Helvetica, sans-serif;
      margin: 0;
      color: #111;
      background: linear-gradient(180deg, #f6f8fb 0%, #ffffff 100%);
    }}
    .page {{
      max-width: none;
      margin: 0;
      padding: 2rem;
    }}
    .country-layout {{
      display: grid;
      grid-template-columns: minmax(420px, max-content) minmax(340px, 520px);
      gap: 1.5rem;
      align-items: start;
    }}
    .panel {{
      background: #fff;
      border: 1px solid #d8dde6;
      border-radius: 12px;
      box-shadow: 0 8px 24px rgba(30, 42, 62, 0.08);
      padding: 1rem 1.1rem;
    }}
    table {{
      border-collapse: collapse;
      width: max-content;
      min-width: 100%;
      table-layout: auto;
    }}
    th, td {{
      border: 1px solid #ccc;
      padding: 0.35rem 0.5rem;
      text-align: left;
      vertical-align: top;
      white-space: normal;
    }}
    th {{
      background: #eee;
      text-align: left;
    }}
    td.label {{
      min-width: 14ch;
      text-align: left;
    }}
    td.count, td.percent {{
      text-align: right;
      font-variant-numeric: tabular-nums;
    }}
    .meta {{
      color: #555;
      font-size: 0.95rem;
      margin-top: 0.35rem;
    }}
    .source-note {{
      color: #555;
      font-size: 0.95rem;
      margin-top: 0.35rem;
    }}
    .legend {{
      margin-top: 1rem;
      padding-top: 0.6rem;
      border-top: 1px solid #d8dde6;
    }}
    .legend ul {{
      margin: 0.45rem 0 0;
      padding-left: 1.2rem;
    }}
    .flag {{
      height: 1em;
      width: auto;
      vertical-align: -0.15em;
    }}
    .flag-spacer {{
      display: inline-block;
      width: 1.45em;
      height: 1em;
      vertical-align: -0.15em;
    }}
    .country-pie svg {{
      display: block;
      width: 100%;
      max-width: 480px;
      height: auto;
      overflow: visible;
      margin: 0 auto;
    }}
    .pie-legend {{
      list-style: none;
      padding: 0;
      margin: 0.75rem 0 0;
      columns: 2;
      column-gap: 1rem;
      font-size: 0.9rem;
    }}
    .pie-legend li {{
      break-inside: avoid;
      margin: 0.2rem 0;
    }}
    .pie-swatch {{
      display: inline-block;
      width: 0.8rem;
      height: 0.8rem;
      margin-right: 0.35rem;
      vertical-align: -0.08rem;
    }}
    .pie-count {{
      color: #555;
      margin-left: 0.25rem;
    }}
    @media (max-width: 900px) {{
      .page {{
        padding: 1rem;
      }}
      .country-layout {{
        grid-template-columns: 1fr;
      }}
      .pie-legend {{
        columns: 1;
      }}
    }}
    {site_layout_css()}
  </style>
</head>
<body>
  {site_sidebar_html("mpc_code_stats_country.html")}
  <div class="page">
    <div class="panel">
      <h1>{html_escape(title)}</h1>
      <div class="meta">Generated (UTC): {html_escape(generated_at)}</div>
      {extra_note_html}
    </div>
    <div class="country-layout" style="margin-top: 1rem;">
      <div class="panel">
        {table_export_button("country-frequency-table")}
        <table id="country-frequency-table" data-sortable-table="1">
          <thead>
            <tr>
              <th data-type="text" title="The derived country value.">Country</th>
              <th data-type="number" title="How many rows contain this value.">Count</th>
              <th data-type="number" title="Share of all rows, shown with two decimal places.">Percent</th>
            </tr>
          </thead>
          <tbody>
{chr(10).join(table_rows.splitlines())}
          </tbody>
        </table>
        <div class="legend">
          <strong>Column notes</strong>
          <ul>
            <li><strong>Country</strong>: the derived country value. Click a country to open its generated map, except symbolic Space and Rover entries.</li>
            <li><strong>Count</strong>: the number of rows assigned to this country value.</li>
            <li><strong>Percent</strong>: the fraction of all rows represented by this group.</li>
          </ul>
        </div>
      </div>
      <div class="panel">
        <h2>Country distribution</h2>
        {chart_html}
      </div>
    </div>
  </div>
{table_export_script()}
</body>
</html>
"""


def write_country_outputs(
    records: list[ObsCodeRecord],
    boundaries: list[CountryBoundary],
    title: str,
    output_html_name: str,
    output_image_name: str,
) -> None:
    log(f"Erzeuge Laenderstatistik: {output_html_name}")
    generated_at = utc_now_text()
    rows = collect_country_frequency_rows(records)
    log(f"Laenderstatistikgruppen: {len(rows)}")
    map_paths = write_country_map_outputs(records, boundaries)
    html_rows: list[tuple[str, int, str]] = []
    for country_name, country_iso2, count, percent in rows:
        normalized_country_name = normalize_country_display_name(display_country_name(country_name))
        special_title = special_country_title(normalized_country_name, records)
        country_html = country_display_html(normalized_country_name, country_iso2)
        if special_title:
            label_html = f'<span title="{html_escape(special_title)}">{country_html}</span>'
        else:
            map_path = map_paths.get((normalized_country_name, country_iso2), "")
            label_html = (
                f'<a href="{html_escape(map_path)}">{country_html}</a>'
                if map_path
                else country_html
            )
        html_rows.append((label_html, count, percent))
    chart_rows = [(country_name, count, percent) for country_name, _, count, percent in rows]
    source_note_html = (
        f'<div class="source-note">Country boundaries: <a href="{COUNTRY_BOUNDARY_SOURCE_PAGE}">geo-countries GeoJSON</a> '
        f'cached locally from Natural Earth-derived data | '
        f'Flags: <a href="{FLAG_ICON_SOURCE_PAGE}">flag-icons SVG collection</a> cached locally. '
        f'Per-country maps can also be opened by clicking a country in this statistics table.</div>'
    )
    world_count = sum(count for _, _, count, _ in rows)
    world_percent = "100.00" if world_count else "0.00"
    world_link = map_paths.get(("World", ""), "")
    world_country_count = len(rows)
    world_text = f"World ({world_country_count} countries)"
    world_label = f'<a href="{html_escape(world_link)}">{html_escape(world_text)}</a>' if world_link else html_escape(world_text)
    html_rows.append((world_label, world_count, world_percent))
    chart_html = build_country_pie_html(rows)
    html_text = build_country_frequency_html_page(title, html_rows, generated_at, chart_html, source_note_html)
    Path(output_html_name).write_text(html_text, encoding="utf-8")
    log(f"HTML geschrieben: {output_html_name}")
    save_frequency_chart_outputs(Path(output_image_name), title, chart_rows, wide_layout=True)


def collect_no_flag_rows(records: list[ObsCodeRecord]) -> list[tuple[str, str, str]]:
    rows: list[tuple[str, str, str]] = []
    seen: set[tuple[str, str]] = set()

    for record in records:
        country_name = record.country_name.strip() or "Unknown"
        country_iso2 = (record.country_iso2.strip() or COUNTRY_NAME_TO_ISO2.get(country_name, "")).lower()
        if country_name == "Unknown":
            continue
        if country_iso2 and ensure_flag_icon(country_iso2) is not None:
            continue
        key = (country_name, country_iso2)
        if key in seen:
            continue
        seen.add(key)
        reason = "No matching SVG flag was available in the local flag cache."
        rows.append((country_name, country_iso2 or "(none)", reason))

    rows.sort(key=lambda item: (item[0].lower(), item[1].lower()))
    return rows


def write_country_text_output(records: list[ObsCodeRecord]) -> None:
    output_path = Path("mpc_obscode_country_list.txt")
    lines: list[str] = []
    for record in sorted(records, key=lambda item: item.obscode):
        longitude = parse_float_text(record.longitude)
        if longitude is not None:
            longitude = normalize_longitude(longitude)
        latitude = derive_geodetic_latitude(record)
        height_meters = derive_height_meters(record)
        lines.append(
            f"{record.obscode.strip()} "
            f"{format_compass_dms(longitude, 'E', 'W', 3)} "
            f"{format_compass_dms(latitude, 'N', 'S', 2)} "
            f"{format_country_list_height(height_meters)} "
            f"{display_country_name(record.country_name.strip() or 'Unknown')}"
        )
    output_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
    log(f"Textdatei geschrieben: {output_path} ({len(lines)} Zeilen)")


def collect_coordinate_double_groups(
    records: list[ObsCodeRecord],
    threshold_meters: float = 6.0,
) -> tuple[list[list[ObsCodeRecord]], dict[tuple[str, str], float]]:
    geo_records: list[tuple[ObsCodeRecord, float, float]] = []
    for record in records:
        point = record_geodetic_point(record)
        if point is None:
            continue
        lon, lat = point
        geo_records.append((record, lon, lat))

    parents = list(range(len(geo_records)))

    def find(index: int) -> int:
        while parents[index] != index:
            parents[index] = parents[parents[index]]
            index = parents[index]
        return index

    def union(index_a: int, index_b: int) -> None:
        root_a = find(index_a)
        root_b = find(index_b)
        if root_a != root_b:
            parents[root_b] = root_a

    pair_distances: dict[tuple[str, str], float] = {}
    latitude_threshold = threshold_meters / 111320.0
    for index_a, (record_a, lon_a, lat_a) in enumerate(geo_records):
        for index_b in range(index_a + 1, len(geo_records)):
            record_b, lon_b, lat_b = geo_records[index_b]
            if abs(lat_b - lat_a) > latitude_threshold * 1.2:
                continue
            mean_lat = math.radians((lat_a + lat_b) / 2.0)
            lon_threshold = latitude_threshold / max(0.08, abs(math.cos(mean_lat)))
            if abs(normalize_longitude(lon_b - lon_a)) > lon_threshold * 1.2:
                continue
            distance_meters = horizontal_distance_meters(lon_a, lat_a, lon_b, lat_b)
            if distance_meters <= threshold_meters:
                union(index_a, index_b)
                key = tuple(sorted((record_a.obscode.strip(), record_b.obscode.strip())))
                pair_distances[key] = distance_meters

    grouped_indexes: dict[int, list[int]] = {}
    for index in range(len(geo_records)):
        grouped_indexes.setdefault(find(index), []).append(index)

    groups: list[list[ObsCodeRecord]] = []
    for indexes in grouped_indexes.values():
        if len(indexes) < 2:
            continue
        group_records = [geo_records[index][0] for index in indexes]
        group_records.sort(key=lambda item: item.obscode)
        groups.append(group_records)
    groups.sort(key=lambda group: (group[0].obscode, len(group)))
    return groups, pair_distances


def is_coordinate_double_candidate(record: ObsCodeRecord) -> bool:
    country_name = display_country_name(record.country_name or "").strip().lower()
    observation_type = record.observations_type.strip().lower()
    if country_name in {"space", "rover"}:
        return False
    if observation_type == "occultation":
        return False
    return True


def distance_between_records(record_a: ObsCodeRecord, record_b: ObsCodeRecord) -> float | None:
    point_a = record_geodetic_point(record_a)
    point_b = record_geodetic_point(record_b)
    if point_a is None or point_b is None:
        return None
    return horizontal_distance_meters(point_a[0], point_a[1], point_b[0], point_b[1])


def coordinate_double_group_min_distance(group: list[ObsCodeRecord]) -> float | None:
    distances: list[float] = []
    for index, record_a in enumerate(group):
        for record_b in group[index + 1:]:
            distance = distance_between_records(record_a, record_b)
            if distance is not None:
                distances.append(distance)
    return min(distances) if distances else None


def coordinate_double_nearby_html(record: ObsCodeRecord, group: list[ObsCodeRecord], base_url: str) -> tuple[str, float | None]:
    nearby: list[tuple[float, ObsCodeRecord]] = []
    for other_record in group:
        if other_record is record:
            continue
        distance = distance_between_records(record, other_record)
        if distance is None:
            continue
        nearby.append((distance, other_record))
    nearby.sort(key=lambda item: (item[0], item[1].obscode))
    if not nearby:
        return "", None
    parts = [
        f'<a href="{html_escape(station_link(other_record.obscode, base_url))}">{html_escape(other_record.obscode)}</a> '
        f'({html_escape(format_distance_meters(distance))} m)'
        for distance, other_record in nearby
    ]
    return "<br>".join(parts), nearby[0][0]


def doubles_name_cell(record: ObsCodeRecord, group_distance: float, pair_distance: float) -> str:
    return (
        f'<td class="{station_name_css_class(record)}" '
        f'data-sort="{group_distance:012.3f} {pair_distance:012.3f} {html_escape(record.display_name)}" '
        f'title="{html_escape(station_geometry_title(record))}">'
        f'{station_name_link_html(record)}</td>'
    )


def write_coordinate_doubles_output(
    records: list[ObsCodeRecord],
    base_url: str,
    output_html_name: str = "mpc_obscode_doubles.html",
) -> None:
    log(f"Erzeuge Koordinaten-Dubletten: {output_html_name}")
    generated_at = utc_now_text()
    threshold_meters = 6.0
    candidate_records = [record for record in records if is_coordinate_double_candidate(record)]
    excluded_count = len(records) - len(candidate_records)
    groups, pair_distances = collect_coordinate_double_groups(candidate_records, threshold_meters=threshold_meters)
    records_by_code = {record.obscode.strip(): record for record in candidate_records}
    group_by_code: dict[str, int] = {}
    group_min_distance: dict[int, float] = {}
    for group_index, group in enumerate(groups, start=1):
        min_distance = coordinate_double_group_min_distance(group)
        if min_distance is not None:
            group_min_distance[group_index] = min_distance
        for record in group:
            group_by_code[record.obscode.strip()] = group_index

    pair_rows: list[str] = []
    sorted_pairs = sorted(
        pair_distances.items(),
        key=lambda item: (
            group_min_distance.get(group_by_code.get(item[0][0], 0), item[1]),
            group_by_code.get(item[0][0], 0),
            item[1],
            item[0][0],
            item[0][1],
        ),
    )
    previous_group_index = 0
    for (code_a, code_b), pair_distance in sorted_pairs:
        record_a = records_by_code.get(code_a)
        record_b = records_by_code.get(code_b)
        if record_a is None or record_b is None:
            continue
        group_index = group_by_code.get(code_a, 0)
        nearest_distance = group_min_distance.get(group_index, pair_distance)
        group_classes = ["group-even" if group_index % 2 == 0 else "group-odd"]
        if group_index != previous_group_index:
            group_classes.append("group-start")
            previous_group_index = group_index
        observations_sort_a = str(record_a.observations_count or "").zfill(12)
        observations_sort_b = str(record_b.observations_count or "").zfill(12)
        pair_rows.append(
            f'<tr class="{" ".join(group_classes)}" data-group="{group_index}">'
            f'<td class="num" data-sort="{pair_distance:012.3f}">{html_escape(format_distance_meters(pair_distance))}</td>'
            f'<td class="num" data-sort="{nearest_distance:012.3f}">{html_escape(format_distance_meters(nearest_distance))}</td>'
            f'<td class="num" data-sort="{group_index:04d}">{group_index}</td>'
            f'<td class="code" data-sort="{html_escape(code_a)}"><a href="{html_escape(station_link(code_a, base_url))}">{html_escape(code_a)}</a></td>'
            f'{doubles_name_cell(record_a, nearest_distance, pair_distance)}'
            f'<td class="code" data-sort="{html_escape(code_b)}"><a href="{html_escape(station_link(code_b, base_url))}">{html_escape(code_b)}</a></td>'
            f'{doubles_name_cell(record_b, nearest_distance, pair_distance)}'
            f'<td class="country" data-sort="{html_escape(country_filter_key(record_a.country_name or "Unknown"))}">{country_map_link_html(record_a.country_name, record_a.country_iso2)}</td>'
            f'<td class="country" data-sort="{html_escape(country_filter_key(record_b.country_name or "Unknown"))}">{country_map_link_html(record_b.country_name, record_b.country_iso2)}</td>'
            f'{table_cell(format_date_only(record_a.jost_first_date), sort_value=sort_key_datetime(record_a.jost_first_date), css_class="date")}'
            f'{table_cell(format_date_only(record_a.jost_last_date), sort_value=sort_key_datetime(record_a.jost_last_date), css_class="date")}'
            f'{table_cell(format_integer_with_dots(record_a.observations_count), sort_value=observations_sort_a, css_class="num")}'
            f'{table_cell(format_date_only(record_b.jost_first_date), sort_value=sort_key_datetime(record_b.jost_first_date), css_class="date")}'
            f'{table_cell(format_date_only(record_b.jost_last_date), sort_value=sort_key_datetime(record_b.jost_last_date), css_class="date")}'
            f'{table_cell(format_integer_with_dots(record_b.observations_count), sort_value=observations_sort_b, css_class="num")}'
            "</tr>"
        )

    duplicate_station_count = len({record.obscode.strip() for group in groups for record in group})
    if not pair_rows:
        body_html = '<tr><td colspan="15">No station pairs are within 6 meters of each other.</td></tr>'
    else:
        body_html = "\n".join(pair_rows)

    html_text = f"""<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>MPC Observatory Code Doubles</title>
  <style>
    body {{ font-family: Arial, Helvetica, sans-serif; margin: 0; color: #111; background: #f6f8fb; overflow-x: auto; }}
    .page {{ max-width: none; min-width: calc(100vw - 4rem); padding: 2rem; }}
    .panel {{ background: #fff; border: 1px solid #d8dde6; border-radius: 12px; box-shadow: 0 8px 24px rgba(30, 42, 62, 0.08); padding: 1rem 1.1rem; margin-bottom: 1rem; }}
    table {{ border-collapse: collapse; font-size: 0.92rem; width: max-content; min-width: 100%; table-layout: auto; }}
    th, td {{ border: 1px solid #ccc; padding: 0.35rem 0.45rem; text-align: left; vertical-align: top; white-space: normal; }}
    th {{ background: #eee; cursor: pointer; user-select: none; position: sticky; top: 0; z-index: 2; }}
    th.wrap-head {{ line-height: 1.08; white-space: normal; vertical-align: bottom; }}
    th.wrap-head .head-lines {{ display: inline-flex; flex-direction: column; gap: 0.04rem; }}
    th.num-head {{ text-align: right; }}
    th.measure-head {{ width: 4.8rem; min-width: 4.2rem; max-width: 5.4rem; }}
    th.code-head {{ width: 3.6rem; min-width: 3.2rem; max-width: 4.2rem; }}
    th.name-head {{ width: 4.8rem; min-width: 4.2rem; max-width: 5.6rem; }}
    th.country-head {{ width: 5.2rem; min-width: 4.4rem; max-width: 6rem; }}
    th.date-head {{ width: 5.3rem; min-width: 4.6rem; max-width: 6rem; }}
    th::after {{ content: " \\2195"; color: #777; font-weight: normal; }}
    th.sort-asc::after {{ content: " \\2191"; color: #111; }}
    th.sort-desc::after {{ content: " \\2193"; color: #111; }}
    td.num, td.date {{ text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }}
    td.code {{ white-space: nowrap; font-weight: 700; }}
    td.name {{ width: 16ch; min-width: 12ch; max-width: 20ch; overflow-wrap: normal; word-break: normal; }}
    td.country {{ min-width: 8rem; max-width: 13rem; overflow-wrap: normal; word-break: normal; }}
    tr.group-odd td {{ background: #fff; }}
    tr.group-even td {{ background: #f5f8fc; }}
    tr.group-start td {{ border-top: 3px solid #174a73; }}
    .name-elevation-alert, .name-elevation-alert a {{ color: #b00020; font-weight: 700; }}
    .flag {{ height: 1em; width: auto; vertical-align: -0.15em; }}
    .flag-spacer {{ display: inline-block; width: 1.45em; height: 1em; vertical-align: -0.15em; }}
    .country-cell {{ display: inline-flex; align-items: flex-start; gap: 0.45rem; flex-wrap: wrap; max-width: 100%; }}
    .country-cell span:last-child {{ min-width: 0; white-space: normal; overflow-wrap: normal; word-break: normal; }}
    .meta {{ color: #555; margin-bottom: 1rem; }}
    .legend {{ margin-top: 1rem; padding-top: 0.6rem; border-top: 1px solid #d8dde6; }}
    {site_layout_css()}
  </style>
</head>
<body>
  {site_sidebar_html("mpc_obscode_doubles.html")}
  <main class="page">
    <section class="panel">
      <h1>Doubles</h1>
      <div class="meta">Generated (UTC): {html_escape(generated_at)} | Threshold: {format_distance_meters(threshold_meters)} meters | Groups: {format_integer_with_dots(len(groups))} | Pairs: {format_integer_with_dots(len(pair_rows))} | Stations in groups: {format_integer_with_dots(duplicate_station_count)} | Excluded non-ground candidates: {format_integer_with_dots(excluded_count)}</div>
      <p>Each row compares one station code against one other station code within 6 meters. Space, Rover, and occultation entries are excluded. The default display is sorted by the nearest distance inside each group.</p>
    </section>
    <section class="panel">
      {table_export_button("doubles-table")}
      <table id="doubles-table">
        <thead>
          <tr>
            <th class="wrap-head num-head measure-head" data-type="number" title="Distance between the two station codes in this row, in meters.">{header_lines_html("Pair", "m")}</th>
            <th class="wrap-head num-head measure-head sort-asc" data-type="number" title="Nearest pair distance inside this doubles group, in meters. This is the default row order on page load.">{header_lines_html("Group", "nearest", "m")}</th>
            <th class="wrap-head num-head code-head" data-type="number" title="Doubles group number.">{header_lines_html("Group")}</th>
            <th class="wrap-head code-head" data-type="text" title="First MPC station code, linked to the usual station page.">{header_lines_html("Code", "A")}</th>
            <th class="wrap-head name-head" data-type="text" title="Station A name. Sorting this column sorts by group-nearest distance first, then by pair distance.">{header_lines_html("Name", "A")}</th>
            <th class="wrap-head code-head" data-type="text" title="Second MPC station code, linked to the usual station page.">{header_lines_html("Code", "B")}</th>
            <th class="wrap-head name-head" data-type="text" title="Station B name. Sorting this column sorts by group-nearest distance first, then by pair distance.">{header_lines_html("Name", "B")}</th>
            <th class="wrap-head country-head" data-type="text" title="Derived country for station A.">{header_lines_html("Country", "A")}</th>
            <th class="wrap-head country-head" data-type="text" title="Derived country for station B.">{header_lines_html("Country", "B")}</th>
            <th class="wrap-head date-head" data-type="date" title="First observation date for station A read from the Jost station index.">{header_lines_html("First", "obs", "A")}</th>
            <th class="wrap-head date-head" data-type="date" title="Last observation date for station A read from the Jost station index.">{header_lines_html("Last", "obs", "A")}</th>
            <th class="wrap-head num-head date-head" data-type="number" title="Observation count for station A read from the Jost station index.">{header_lines_html("Obs", "A")}</th>
            <th class="wrap-head date-head" data-type="date" title="First observation date for station B read from the Jost station index.">{header_lines_html("First", "obs", "B")}</th>
            <th class="wrap-head date-head" data-type="date" title="Last observation date for station B read from the Jost station index.">{header_lines_html("Last", "obs", "B")}</th>
            <th class="wrap-head num-head date-head" data-type="number" title="Observation count for station B read from the Jost station index.">{header_lines_html("Obs", "B")}</th>
          </tr>
        </thead>
        <tbody>
{body_html}
        </tbody>
      </table>
      <div class="legend">
        <strong>Distance method</strong>
        <p>Distances are calculated as horizontal great-circle distances from derived WGS84 geodetic longitude and latitude. The source MPC rho values are used internally to derive latitude and elevation, but coordinate columns are intentionally omitted here.</p>
      </div>
    </section>
  </main>
{table_export_script()}
  <script>
    (function () {{
      const table = document.getElementById("doubles-table");
      const tbody = table.querySelector("tbody");
      const headers = Array.from(table.querySelectorAll("th"));
      function compareCells(rowA, rowB, columnIndex, direction, type) {{
        const valueA = (rowA.children[columnIndex]?.dataset.sort || rowA.children[columnIndex]?.textContent || "").trim();
        const valueB = (rowB.children[columnIndex]?.dataset.sort || rowB.children[columnIndex]?.textContent || "").trim();
        let comparison = 0;
        if (type === "number") {{
          const numberA = Number(valueA.replace(/\\./g, "").replace(",", "."));
          const numberB = Number(valueB.replace(/\\./g, "").replace(",", "."));
          comparison = (Number.isFinite(numberA) ? numberA : -Infinity) - (Number.isFinite(numberB) ? numberB : -Infinity);
        }} else {{
          comparison = valueA.localeCompare(valueB, "de", {{ numeric: true, sensitivity: "base" }});
        }}
        return direction === "asc" ? comparison : -comparison;
      }}
      headers.forEach((header, columnIndex) => {{
        header.addEventListener("click", () => {{
          const direction = header.classList.contains("sort-asc") ? "desc" : "asc";
          headers.forEach(item => item.classList.remove("sort-asc", "sort-desc"));
          header.classList.add(direction === "asc" ? "sort-asc" : "sort-desc");
          const rows = Array.from(tbody.querySelectorAll("tr"));
          rows.sort((rowA, rowB) => compareCells(rowA, rowB, columnIndex, direction, header.dataset.type || "text"));
          tbody.replaceChildren(...rows);
        }});
      }});
    }})();
  </script>
</body>
</html>
"""
    Path(output_html_name).write_text(html_text, encoding="utf-8")
    log(f"HTML geschrieben: {output_html_name} ({len(groups)} Gruppen, {len(pair_rows)} Paare, {duplicate_station_count} Stationen)")


def collect_no_country_rows(records: list[ObsCodeRecord]) -> list[ObsCodeRecord]:
    return [record for record in records if record.country_name.strip() == "Unknown"]


def collect_daily_events(records: list[ObsCodeRecord]) -> list[dict[str, str]]:
    events: list[dict[str, str]] = []

    for record in records:
        created_day = format_datetime(record.created_at)[:10]
        updated_day = format_datetime(record.updated_at)[:10]
        if created_day:
            events.append(
                {
                    "day": created_day,
                    "kind": "created",
                    "code": record.obscode,
                    "name": record.display_name,
                    "name_cell": station_name_cell(record),
                    "country": record.country_name or "Unknown",
                    "country_iso2": record.country_iso2 or "",
                }
            )
        if updated_day and updated_day != created_day:
            events.append(
                {
                    "day": updated_day,
                    "kind": "updated",
                    "code": record.obscode,
                    "name": record.display_name,
                    "name_cell": station_name_cell(record),
                    "country": record.country_name or "Unknown",
                    "country_iso2": record.country_iso2 or "",
                }
            )

    events.sort(
        key=lambda item: (
            item["day"],
            0 if item["kind"] == "created" else -1,
            item["code"],
        ),
        reverse=True,
    )
    return events


def format_day_label(day_text: str) -> str:
    try:
        parsed = datetime.strptime(day_text, "%Y-%m-%d")
        return parsed.strftime("%Y %B %d")
    except Exception:
        return day_text


def build_daily_events_html(events: list[dict[str, str]], base_url: str, generated_at: str) -> str:
    rows_html_parts: list[str] = []
    last_day = ""
    for event in events:
        day_text = format_day_label(event["day"])
        shown_day = html_escape(day_text) if event["day"] != last_day else "&nbsp;"
        last_day = event["day"]
        code_link = station_link(event["code"], base_url)
        kind_class = " kind-created" if event["kind"] == "created" else ""
        rows_html_parts.append(
            "<tr>"
            f'<td class="day" data-sort="{html_escape(event["day"])}">{shown_day}</td>'
            f'<td class="kind{kind_class}" data-sort="{html_escape(event["kind"])}">{html_escape(event["kind"])}</td>'
            f'<td class="code"><a href="{html_escape(code_link)}" title="Open the station page for this code.">{html_escape(event["code"])}</a></td>'
            f'{event["name_cell"]}'
            f'<td class="country">{country_display_html(event["country"], event.get("country_iso2", ""))}</td>'
            "</tr>"
        )
    rows_html = "\n".join(rows_html_parts)
    return f"""<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>MPC Daily Changes</title>
  <style>
    body {{ font-family: Arial, Helvetica, sans-serif; margin: 2rem; color: #111; background: #f6f8fb; }}
    .panel {{ background: #fff; border: 1px solid #d8dde6; border-radius: 12px; box-shadow: 0 8px 24px rgba(30, 42, 62, 0.08); padding: 1rem 1.1rem; }}
    table {{ border-collapse: collapse; width: auto; table-layout: auto; display: inline-table; margin-top: 1rem; }}
    th, td {{ border: 1px solid #ccc; padding: 0.4rem 0.5rem; text-align: left; vertical-align: top; white-space: normal; }}
    th {{ background: #eee; text-align: left; position: sticky; top: 0; }}
    td.day, td.kind, td.code, td.name, td.country {{ text-align: left; }}
    td.code, td.country {{ min-width: 4ch; }}
    td.name {{ width: 20ch; min-width: 20ch; max-width: 20ch; overflow-wrap: anywhere; word-break: break-word; }}
    .name-elevation-alert, .name-elevation-alert a {{ color: #b00020; font-weight: 700; }}
    td.kind-created {{ color: #b00020; font-weight: 600; }}
    td.country {{ white-space: normal; }}
    .flag {{ height: 1em; width: auto; vertical-align: -0.15em; }}
    .flag-spacer {{ display: inline-block; width: 1.45em; height: 1em; vertical-align: -0.15em; }}
    .country-cell {{ display: inline-flex; align-items: center; gap: 0.45rem; }}
    .legend {{
      margin-top: 1rem;
      padding-top: 0.6rem;
      border-top: 1px solid #d8dde6;
    }}
    {site_layout_css()}
  </style>
</head>
<body>
  {site_sidebar_html("mpc_daily_changes.html")}
  <div class="page">
  <div class="panel">
    <h1>MPC daily changes</h1>
    <p>Generated (UTC): {html_escape(generated_at)}</p>
    <p>Reverse chronological list of created and updated entries. If a record was created and updated on the same day, only the created entry is listed.</p>
    {table_export_button("daily-changes-table")}
    <table id="daily-changes-table" data-sortable-table="1">
      <thead>
        <tr>
          <th data-type="date" title="The calendar day for this change entry.">Day</th>
          <th data-type="text" title="Whether the entry was created or updated.">Kind</th>
          <th data-type="text" title="The MPC observatory code, linked to the station page.">Code</th>
          <th data-type="text" title="The station name shown in the snapshot.">Name</th>
          <th data-type="text" title="The derived country, shown with a local flag when available.">Country</th>
        </tr>
      </thead>
      <tbody>
        {rows_html}
      </tbody>
    </table>
    <div class="legend">
      <strong>Column notes</strong>
      <ul>
        <li><strong>Day</strong>: the day of the creation or update event, shown once for each contiguous day block.</li>
        <li><strong>Kind</strong>: either <em>created</em> or <em>updated</em>; created entries are shown in red.</li>
        <li><strong>Code</strong>: the stable MPC observatory code and link to the matching station page.</li>
        <li><strong>Name</strong>: the human-readable station name from the snapshot, linked to a Google Maps satellite view.</li>
        <li><strong>Country</strong>: the derived country with a local flag icon when one exists.</li>
      </ul>
    </div>
  </div>
  </div>
{table_export_script()}
</body>
</html>
"""


def write_daily_events_output(records: list[ObsCodeRecord], base_url: str) -> None:
    log("Erzeuge Tagesaenderungen: mpc_daily_changes.html")
    events = collect_daily_events(records)
    Path("mpc_daily_changes.html").write_text(build_daily_events_html(events, base_url, utc_now_text()), encoding="utf-8")
    log(f"HTML geschrieben: mpc_daily_changes.html ({len(events)} Ereignisse)")


def escape_kml_text(value: str) -> str:
    return (
        value.replace("&", "&amp;")
        .replace("<", "&lt;")
        .replace(">", "&gt;")
        .replace('"', "&quot;")
    )


def kml_cdata(value: str) -> str:
    return value.replace("]]>", "]]]]><![CDATA[>")


def kmz_description_table(record: ObsCodeRecord, base_url: str) -> str:
    rows: list[str] = []

    def add_row(label: str, value: str, *, href: str = "") -> None:
        cleaned_value = value.strip()
        if not cleaned_value:
            return
        escaped_label = html_escape(label)
        escaped_value = html_escape(cleaned_value)
        if href:
            value_html = f'<a href="{html_escape(href)}">{escaped_value}</a>'
        else:
            value_html = escaped_value
        rows.append(f"<tr><th>{escaped_label}</th><td>{value_html}</td></tr>")

    station_url = station_link(record.obscode, base_url)
    web_url = normalize_station_web_url(record.web_link)
    add_row("Code", record.obscode, href=station_url)
    add_row("Name", record.display_name, href=station_satellite_url(record))
    add_row("Created", format_date_only(record.created_at))
    add_row("Updated", format_date_only(record.updated_at))
    add_row("First observation date", format_date_only(record.jost_first_date))
    add_row("Last observation date", format_date_only(record.jost_last_date))
    if record.observations_count is not None:
        add_row("Observations", format_integer_with_dots(record.observations_count))
    add_row("Type", record.observations_type)
    add_row("Status", record.status_text)
    if web_url and record.web_link_status is not None and record.web_link_status.reachable:
        add_row("Web", web_url, href=web_url)
    add_row("Country", display_country_name(record.country_name or "Unknown"))
    if not rows:
        return ""
    return (
        '<table border="1" cellpadding="4" cellspacing="0" style="border-collapse:collapse">'
        + "".join(rows)
        + "</table>"
    )


def write_kmz_output(records: list[ObsCodeRecord], base_url: str) -> None:
    log("Erzeuge KMZ-Datei: mpc_obscode_stations.kmz")
    placemarks: list[str] = []
    for record in records:
        lon = parse_float_text(record.longitude)
        lat = derive_geodetic_latitude(record)
        if lon is None or lat is None:
            continue
        lon = normalize_longitude(lon)
        description_html = kmz_description_table(record, base_url)
        placemarks.append(
            f"""
    <Placemark>
      <name>{escape_kml_text(record.obscode)}</name>
      <description><![CDATA[{kml_cdata(description_html)}]]></description>
      <Point><coordinates>{lon:.6f},{lat:.6f},0</coordinates></Point>
    </Placemark>"""
        )

    kml_text = f"""<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
  <Document>
    <name>MPC Observatory Codes</name>
    <description>Generated from the local MPC snapshot</description>
    {''.join(placemarks)}
  </Document>
</kml>
"""
    with ZipFile("mpc_obscode_stations.kmz", "w", compression=ZIP_DEFLATED) as kmz_file:
        kmz_file.writestr("doc.kml", kml_text)
    log(f"KMZ geschrieben: mpc_obscode_stations.kmz ({len(placemarks)} Placemarks)")


DIST_SITE_FILES = [
    "index.html",
    "introduction.html",
    "indexseite.html",
    "mpc_obscode_api.bas",
    "mpc_obscode_api_codex_anweisung.md",
    "mpc_obscode_api_documentation.md",
    "mpc_obscode_api.exe",
    "mpc_obscode_sync.log",
    "mpc_obscode_snapshot.tsv",
    "mpc_obscode_stats.html",
    "mpc_code_stats_type.html",
    "mpc_code_stats_type.png",
    "mpc_code_stats_type.gif",
    "mpc_code_stats_status.html",
    "mpc_code_stats_status.png",
    "mpc_code_stats_status.gif",
    "mpc_code_stats_country.html",
    "mpc_code_stats_country.png",
    "mpc_code_stats_country.gif",
    "mpc_web_link_status.html",
    "mpc_daily_changes.html",
    "mpc_obscode_doubles.html",
    "mpc_obscode_stations.kmz",
    "mpc_obscode_country_list.txt",
    "country_overrides.txt",
    "country_cache.json",
    "no_flag.html",
    "no_country.html",
    "no_country_map.png",
    "mpc_obscode_tsv_to_html_v2.py",
    "mpc_obscode_tsv_to_html_v2.exe",
    "web_link_status_cache.json",
    "jost_station_observation_counts.json",
    "jost_station_observation_counts_unmatched.txt",
]

DIST_SITE_DIRS = [
    "flags",
    "country_maps",
    "map_mockups",
]


def refresh_introduction_change_time(source_path: Path, generated_at: str) -> None:
    if not source_path.exists():
        return

    text = source_path.read_text(encoding="utf-8")
    refreshed = re.sub(
        r"(<strong>Current change time:</strong>\s*)[^<]+",
        rf"\g<1>{html_escape(generated_at)}",
        text,
        count=1,
    )
    refreshed = re.sub(
        r"(<strong>Aktueller Änderungszeitpunkt:</strong>\s*)[^<]+",
        rf"\g<1>{html_escape(generated_at)}",
        refreshed,
        count=1,
    )
    if refreshed != text:
        source_path.write_text(refreshed, encoding="utf-8")
        log(f"Aktualisiere Aenderungszeitpunkt: {source_path} -> {generated_at}")


def refresh_root_index_alias() -> None:
    introduction_path = Path("introduction.html")
    index_path = Path("index.html")
    if not introduction_path.exists():
        return
    shutil.copy2(introduction_path, index_path)
    log("Index-Alias aktualisiert: introduction.html -> index.html")


def sync_dist_site() -> None:
    log("Synchronisiere Website-Dateien nach dist")
    refresh_introduction_change_time(Path("introduction.html"), utc_now_text())
    refresh_root_index_alias()
    dist_dir = Path("dist")
    dist_dir.mkdir(parents=True, exist_ok=True)

    for file_name in DIST_SITE_FILES:
        source_path = Path(file_name)
        if not source_path.exists():
            log(f"Dist-Kopie uebersprungen, fehlt: {source_path}")
            continue
        target_path = dist_dir / source_path.name
        shutil.copy2(source_path, target_path)
        log(f"Dist-Kopie: {source_path} -> {target_path}")

    for dir_name in DIST_SITE_DIRS:
        source_dir = Path(dir_name)
        if not source_dir.exists():
            log(f"Dist-Ordner uebersprungen, fehlt: {source_dir}")
            continue
        shutil.copytree(source_dir, dist_dir / dir_name, dirs_exist_ok=True)
        log(f"Dist-Ordner kopiert: {source_dir} -> {dist_dir / dir_name}")
    log("Dist-Synchronisierung fertig")


def build_html(records: Iterable[ObsCodeRecord], base_url: str, title: str) -> str:
    record_list = sorted(records, key=lambda record: record.obscode)
    log(f"Baue Haupt-HTML im Speicher: {len(record_list)} Datensaetze")
    generated_at = utc_now_text()
    footer_generated_at = datetime.now()
    type_summary_title = build_type_summary_title(record_list)
    country_values = sorted({country_filter_key(record.country_name or "Unknown") for record in record_list}, key=str.lower)
    country_buttons_html = "\n".join(
        f'          <button type="button" data-country="{html_escape(country)}">{html_escape(country)}</button>'
        for country in country_values
    )
    total_count, url_count, no_url_count = collect_web_link_stats(record_list)
    url_percent = percentage_text(url_count, total_count)
    no_url_percent = percentage_text(no_url_count, total_count)
    observations_count = sum(1 for record in record_list if record.observations_count is not None)
    observations_total = sum(record.observations_count or 0 for record in record_list)

    created_at_count, created_at_min, created_at_max = collect_date_column_stats(
        record.created_at for record in record_list
    )
    updated_at_count, updated_at_min, updated_at_max = collect_date_column_stats(
        record.updated_at for record in record_list
    )
    first_date_count, first_date_min, first_date_max = collect_date_column_stats(
        record.jost_first_date for record in record_list
    )
    last_date_count, last_date_min, last_date_max = collect_date_column_stats(
        record.jost_last_date for record in record_list
    )

    def footer_cell(value: str) -> str:
        return f"<td>{html_escape(value)}</td>"

    def footer_blank_cell() -> str:
        return "<td></td>"

    def footer_count_cell(value: int) -> str:
        return f"<td>{value}</td>"

    def footer_date_text(value: str) -> str:
        return format_date_only(value)

    rows: list[str] = []
    for row_index, record in enumerate(record_list, start=1):
        code = html_escape(record.obscode)
        link = station_link(record.obscode, base_url)
        created_text = format_date_only(record.created_at)
        updated_text = format_date_only(record.updated_at)
        first_date_text = format_date_only(record.jost_first_date)
        last_date_text = format_date_only(record.jost_last_date)
        observations_text = format_integer_with_dots(record.observations_count)
        country_html = country_map_link_html(record.country_name, record.country_iso2)
        country_sort_value = country_filter_key(record.country_name or "Unknown")

        rows.append(
            f'      <tr data-country="{html_escape(country_sort_value)}">\n'
            f'        <td class="code" data-sort="{code}"><a href="{link}">{code}</a></td>\n'
            f"        {station_name_cell(record)}\n"
            f"        {date_table_cell(created_text, record.created_at)}\n"
            f"        {date_table_cell(updated_text, record.updated_at)}\n"
            f"        {table_cell(first_date_text, sort_value=sort_key_datetime(record.jost_first_date), css_class='date')}\n"
            f"        {table_cell(last_date_text, sort_value=sort_key_datetime(record.jost_last_date), css_class='date')}\n"
            f"        {table_cell(observations_text, sort_value=str(record.observations_count or '').zfill(12), css_class='num')}\n"
            f"        {table_cell(record.observations_type, css_class='type')}\n"
            f"        {table_cell(record.status_text, css_class='status')}\n"
            f"        {web_link_cell(record)}\n"
            f'        <td class="country" data-sort="{html_escape(country_sort_value)}">{country_html}</td>\n'
            "      </tr>"
        )
        if row_index % PROGRESS_INTERVAL == 0:
            log_progress("HTML-Zeilen vorbereitet", row_index, len(record_list))

    log("Haupt-HTML im Speicher fertig")
    return f"""<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>{html_escape(title)}</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
    <style>
    body {{
      font-family: Arial, Helvetica, sans-serif;
      margin: 0;
      color: #111;
      background: linear-gradient(180deg, #f6f8fb 0%, #ffffff 100%);
      overflow-x: auto;
    }}
    .page {{
      max-width: none;
      min-width: calc(100vw - 4rem);
      margin: 0;
      padding: 2rem;
    }}
    h1 {{
      margin-bottom: 0.25rem;
    }}
    .meta {{
      color: #555;
      margin-bottom: 1.5rem;
    }}
    .panel {{
      background: #fff;
      border: 1px solid #d8dde6;
      border-radius: 12px;
      box-shadow: 0 8px 24px rgba(30, 42, 62, 0.08);
      padding: 1rem 1.1rem;
      margin-bottom: 1rem;
    }}
    .table-wrap {{
      display: block;
      overflow: visible;
    }}
    table {{
      border-collapse: collapse;
      font-size: 0.92rem;
      table-layout: auto;
      width: max-content;
      min-width: 100%;
      display: inline-table;
    }}
    th, td {{
      border: 1px solid #ccc;
      padding: 0.35rem 0.45rem;
      vertical-align: top;
      white-space: normal;
    }}
    th {{
      text-align: left;
    }}
    td.name {{
      white-space: normal;
      width: 20ch;
      min-width: 20ch;
      max-width: 20ch;
      overflow-wrap: normal;
      word-break: normal;
    }}
    td.code, td.name, td.type, td.status, td.web {{
      text-align: left;
    }}
    td.country {{
      text-align: left;
      white-space: normal;
      min-width: 14rem;
      max-width: 20rem;
      overflow-wrap: normal;
      word-break: normal;
    }}
    td.web {{
      white-space: normal;
      max-width: 22rem;
      overflow-wrap: anywhere;
    }}
    td.web-unreachable {{
      color: #7a1c1c;
    }}
    .name-elevation-alert, .name-elevation-alert a {{
      color: #b00020;
      font-weight: 700;
    }}
    td.date, td.num {{
      text-align: right;
      font-variant-numeric: tabular-nums;
      white-space: nowrap;
    }}
    tfoot td {{
      background: #eef2f7;
      font-weight: bold;
    }}
    th {{
      background: #eee;
      position: sticky;
      top: 0;
      z-index: 1;
      cursor: pointer;
      user-select: none;
    }}
    th::after {{
      content: " \\2195";
      color: #777;
      font-weight: normal;
    }}
    th.sort-asc::after {{
      content: " \\2191";
      color: #111;
    }}
    th.sort-desc::after {{
      content: " \\2193";
      color: #111;
    }}
    tr:nth-child(even) {{
      background: #f8f8f8;
    }}
    a {{
      color: #0645ad;
      text-decoration: none;
    }}
    a:hover {{
      text-decoration: underline;
    }}
    .count {{
      font-weight: bold;
    }}
    .country-cell {{
      display: inline-flex;
      align-items: center;
      gap: 0.45rem;
    }}
    .flag {{
      width: 20px;
      height: auto;
      flex: 0 0 auto;
      border: 1px solid #c7cdd6;
    }}
    .sources {{
      margin-top: 0.45rem;
      color: #4d5b6f;
      font-size: 0.95rem;
    }}
    .legend {{
      margin-top: 1rem;
      padding-top: 0.6rem;
      border-top: 1px solid #d8dde6;
    }}
    .legend ul {{
      margin: 0.45rem 0 0;
      padding-left: 1.2rem;
    }}
    .legend li {{
      margin: 0.2rem 0;
    }}
    .country-filter {{
      display: flex;
      flex-wrap: wrap;
      align-items: center;
      gap: 0.25rem;
      margin-top: 0.9rem;
      font-size: 0.92rem;
    }}
    .country-filter button {{
      font: inherit;
      padding: 0.15rem 0.45rem;
      border: 1px solid #b9c3d3;
      border-radius: 4px;
      background: #f8fafc;
      color: #132238;
      cursor: pointer;
      line-height: 1.25;
    }}
    .country-filter button.active {{
      background: #174a73;
      border-color: #174a73;
      color: #fff;
    }}
    .filter-count {{
      margin-left: 0.35rem;
      color: #4d5b6f;
      white-space: nowrap;
    }}
    .filter-details summary {{
      cursor: pointer;
      font-weight: 700;
      list-style-position: outside;
    }}
    .filter-details summary::-webkit-details-marker {{
      color: #174a73;
    }}
    @media (max-width: 900px) {{
      .page {{
        padding: 1rem;
        min-width: calc(100vw - 2rem);
      }}
      td.name {{
        width: 20ch;
        min-width: 20ch;
        max-width: 20ch;
      }}
    }}
    {site_layout_css()}
  </style>
</head>
<body>
  {site_sidebar_html("mpc_obscode_stats.html")}
  <div class="page">
    <div class="panel">
      <h1>{html_escape(title)}</h1>
      <div class="meta">
        Generated (UTC): {html_escape(generated_at)} |
        Stations: <span class="count">{len(record_list)}</span> |
        Initial order: code ascending
      </div>
      <div class="sources">
        Country boundaries: <a href="{COUNTRY_BOUNDARY_SOURCE_PAGE}">geo-countries GeoJSON</a> cached locally from Natural Earth-derived data |
        Flags: <a href="{FLAG_ICON_SOURCE_PAGE}">flag-icons SVG collection</a> cached locally
      </div>
    </div>

    <div class="panel">
      <details class="filter-details" open>
        <summary>Country filter</summary>
        <div class="country-filter" id="country-filter">
          <button type="button" class="active" data-country="__all">All</button>
{country_buttons_html}
          <span class="filter-count" id="filter-count">{len(record_list)} visible</span>
        </div>
      </details>
    </div>

    <div class="panel">
      {table_export_button("obs-table")}
      <div class="table-wrap">
        <table id="obs-table">
          <thead>
            <tr>
              <th data-type="text" class="sort-asc" title="The MPC observatory code, used as the stable station identifier.">Code</th>
              <th data-type="text" title="The station name shown in the snapshot.">Name</th>
              <th data-type="date" title="Displayed as yyyy-mm-dd. The complete yyyy-mm-dd hh:mm:ss value is kept internally for sorting and appears when hovering over a cell.">Created</th>
              <th data-type="date" title="Displayed as yyyy-mm-dd. The complete yyyy-mm-dd hh:mm:ss value is kept internally for sorting and appears when hovering over a cell.">Updated</th>
              <th data-type="date" title="First observation date read from https://www.jostjahn.de/stations/index.html.">First date</th>
              <th data-type="date" title="Last observation date read from https://www.jostjahn.de/stations/index.html.">Last date</th>
              <th data-type="number" title="Observation count read from https://www.jostjahn.de/stations/index.html.">Obs</th>
              <th data-type="text" title="{html_escape(type_summary_title)}">Type</th>
              <th data-type="text" title="Local sync state from the MPC refresh: new, changed, same, or missing_from_list. It tells whether the station record was new, changed, unchanged, or no longer present in the current MPC list during the last sync.">Status</th>
              <th data-type="text" title="A checked web address linked as 'Link' when reachable; hover for the full URL and latest check result.">Web</th>
              <th data-type="text" title="The country is derived from the cached boundary data using geodetic latitude; unresolved cases may use a heuristic guess with a question mark or a symbolic value.">Country</th>
            </tr>
          </thead>
          <tbody>
{chr(10).join(rows)}
          </tbody>
          <tfoot>
            <tr>
              <td class="num">{len(record_list)}</td>
              {footer_blank_cell()}
              <td class="num">{created_at_count}</td>
              <td class="num">{updated_at_count}</td>
              <td class="num">{first_date_count}</td>
              <td class="num">{last_date_count}</td>
              <td class="num" title="{observations_count} stations with assigned observation counts">{format_integer_with_dots(observations_total)}</td>
              {footer_blank_cell()}
              {footer_blank_cell()}
              <td class="num">{total_count}</td>
              {footer_blank_cell()}
            </tr>
            <tr>
              <td>First</td>
              {footer_blank_cell()}
              <td class="date">{html_escape(footer_date_text(created_at_min))}</td>
              <td class="date">{html_escape(footer_date_text(updated_at_min))}</td>
              <td class="date">{html_escape(footer_date_text(first_date_min))}</td>
              <td class="date">{html_escape(footer_date_text(last_date_min))}</td>
              {footer_blank_cell()}
              {footer_blank_cell()}
              {footer_blank_cell()}
              <td class="num">URL: {url_count} ({url_percent} %)</td>
              {footer_blank_cell()}
            </tr>
            <tr>
              <td>Last</td>
              {footer_blank_cell()}
              <td class="date">{html_escape(footer_date_text(created_at_max))}</td>
              <td class="date">{html_escape(footer_date_text(updated_at_max))}</td>
              <td class="date">{html_escape(footer_date_text(first_date_max))}</td>
              <td class="date">{html_escape(footer_date_text(last_date_max))}</td>
              {footer_blank_cell()}
              {footer_blank_cell()}
              {footer_blank_cell()}
              <td class="num">no URL: {no_url_count} ({no_url_percent} %)</td>
              {footer_blank_cell()}
            </tr>
          </tfoot>
        </table>
      </div>
    </div>

    <div class="panel">
      <strong>Column notes</strong>
      <ul class="legend">
        <li><strong>Code</strong>: the stable MPC observatory code and the main station key.</li>
        <li><strong>Name</strong>: the human-readable station name that appears in the snapshot, linked to a Google Maps satellite view. Hover over the name for calculated longitude, latitude, and elevation. If the calculated elevation is below 0 m or above 8000 m, the name is red and the hover text notes the unrealistic elevation.</li>
        <li><strong>Created</strong>: when the record was first created in the snapshot. The visible value is yyyy-mm-dd; the complete timestamp is used for sorting and shown on hover.</li>
        <li><strong>Updated</strong>: when the record was last modified in the snapshot. The visible value is yyyy-mm-dd; the complete timestamp is used for sorting and shown on hover.</li>
        <li><strong>First date</strong>: first observation date read from <a href="{JOST_STATION_INDEX_URL}">{JOST_STATION_INDEX_URL}</a>.</li>
        <li><strong>Last date</strong>: last observation date read from <a href="{JOST_STATION_INDEX_URL}">{JOST_STATION_INDEX_URL}</a>.</li>
        <li><strong>Obs</strong>: observation count read from <a href="{JOST_STATION_INDEX_URL}">{JOST_STATION_INDEX_URL}</a>.</li>
        <li><strong>Type</strong>: the observation type text stored in the data source. Hover over the Type header for category counts.</li>
        <li><strong>Status</strong>: local sync state from the MPC refresh. <em>new</em> means first seen, <em>changed</em> means the MPC detail changed since the previous snapshot, <em>same</em> means unchanged, and <em>missing_from_list</em> means it was no longer present in the current MPC code list.</li>
        <li><strong>Web</strong>: the station web address shown as <em>Link</em>; hover for the full URL and check status. Reachable URLs are clickable, unreachable URLs keep the existing warning color and are not linked.</li>
        <li><strong>Country</strong>: the country derived from the cached boundary dataset, using geodetic latitude and a local flag SVG when available. Ground-country values link to the generated country map.</li>
      </ul>
    </div>

    <p>Created by Jost Jahn, mpc_stations@jostjahn.de, at {footer_generated_at.strftime(f"%Y {calendar.month_name[footer_generated_at.month]} %d")}</p>
  </div>

  {table_export_script()}
  <script>
    (function () {{
      const table = document.getElementById("obs-table");
      const headers = Array.from(table.querySelectorAll("th"));
      const tbody = table.querySelector("tbody");
      const filter = document.getElementById("country-filter");
      const filterCount = document.getElementById("filter-count");
      const selectedCountries = new Set();

      function compareCells(rowA, rowB, columnIndex, direction, type) {{
        const cellA = rowA.children[columnIndex];
        const cellB = rowB.children[columnIndex];
        const valueA = (cellA.dataset.sort || cellA.textContent || "").trim();
        const valueB = (cellB.dataset.sort || cellB.textContent || "").trim();

        let comparison = 0;

        if (type === "date") {{
          comparison = valueA.localeCompare(valueB);
        }} else if (type === "number") {{
          const numberA = Number(valueA.replace(/\\./g, "").replace(",", "."));
          const numberB = Number(valueB.replace(/\\./g, "").replace(",", "."));
          comparison = (Number.isFinite(numberA) ? numberA : -Infinity) - (Number.isFinite(numberB) ? numberB : -Infinity);
        }} else {{
          comparison = valueA.localeCompare(valueB, "de", {{
            numeric: true,
            sensitivity: "base"
          }});
        }}

        return direction === "asc" ? comparison : -comparison;
      }}

      headers.forEach((header, columnIndex) => {{
        header.addEventListener("click", () => {{
          const currentDirection = header.classList.contains("sort-asc")
            ? "asc"
            : header.classList.contains("sort-desc")
              ? "desc"
              : "";

          const nextDirection = currentDirection === "asc" ? "desc" : "asc";
          const type = header.dataset.type || "text";
          const rows = Array.from(tbody.querySelectorAll("tr"));

          headers.forEach((item) => {{
            item.classList.remove("sort-asc", "sort-desc");
          }});

          header.classList.add(nextDirection === "asc" ? "sort-asc" : "sort-desc");

          rows.sort((rowA, rowB) => compareCells(rowA, rowB, columnIndex, nextDirection, type));
          rows.forEach((row) => tbody.appendChild(row));
        }});
      }});

      function updateFilter() {{
        const rows = Array.from(tbody.querySelectorAll("tr"));
        let visible = 0;
        rows.forEach((row) => {{
          const country = row.dataset.country || "";
          const show = selectedCountries.size === 0 || selectedCountries.has(country);
          row.hidden = !show;
          if (show) {{
            visible += 1;
          }}
        }});
        Array.from(filter.querySelectorAll("button")).forEach((button) => {{
          const country = button.dataset.country || "";
          button.classList.toggle("active", country === "__all" ? selectedCountries.size === 0 : selectedCountries.has(country));
        }});
        filterCount.textContent = `${{visible}} visible`;
      }}

      filter.addEventListener("click", (event) => {{
        const button = event.target.closest("button[data-country]");
        if (!button) {{
          return;
        }}
        const country = button.dataset.country || "";
        if (country === "__all") {{
          selectedCountries.clear();
        }} else if (selectedCountries.has(country)) {{
          selectedCountries.delete(country);
        }} else {{
          selectedCountries.add(country);
        }}
        updateFilter();
      }});
    }})();
  </script>
</body>
</html>
"""


def parse_arguments() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Creates a sortable HTML statistics page from mpc_obscode_snapshot.tsv."
    )
    parser.add_argument(
        "--input",
        default="mpc_obscode_snapshot.tsv",
        help="Path to the TSV database. Default: mpc_obscode_snapshot.tsv",
    )
    parser.add_argument(
        "--output",
        default="mpc_obscode_stats.html",
        help="Target HTML file. Default: mpc_obscode_stats.html",
    )
    parser.add_argument(
        "--base-url",
        default="https://www.jostjahn.de/stations",
        help="Base URL for code links. Default: https://www.jostjahn.de/stations",
    )
    parser.add_argument(
        "--title",
        default="MPC Observatory Code Statistics",
        help="Title of the HTML page.",
    )
    parser.add_argument(
        "--diagnose",
        action="store_true",
        help="Print warnings for suspicious TSV rows.",
    )
    parser.add_argument(
        "--quiet",
        action="store_true",
        help="Only print errors and explicit diagnostic warnings.",
    )
    parser.add_argument(
        "--skip-web-link-check",
        action="store_true",
        help="Do not check station web links for reachability during this run.",
    )
    parser.add_argument(
        "--force-web-link-check",
        action="store_true",
        help="Check all station web links even if they were already checked today.",
    )
    parser.add_argument(
        "--skip-observation-counts",
        action="store_true",
        help="Do not read observation counts and First/Last dates from the public station index.",
    )
    parser.add_argument(
        "--skip-mpc-sync",
        action="store_true",
        help="Do not run mpc_obscode_api.exe --sync before generating outputs.",
    )
    parser.add_argument(
        "--mpc-sync-request-delay-ms",
        type=int,
        default=DEFAULT_MPC_SYNC_REQUEST_DELAY_MS,
        help=f"Delay passed to mpc_obscode_api.exe --sync. Default: {DEFAULT_MPC_SYNC_REQUEST_DELAY_MS}",
    )
    parser.add_argument(
        "--mpc-sync-max-age-days",
        type=int,
        default=DEFAULT_MPC_SYNC_MAX_AGE_DAYS,
        help=(
            "Maximum MPC record age passed to mpc_obscode_api.exe --sync. "
            f"Default: {DEFAULT_MPC_SYNC_MAX_AGE_DAYS} checks every current code before generation."
        ),
    )
    return parser.parse_args()


def main() -> int:
    global VERBOSE
    args = parse_arguments()
    VERBOSE = not args.quiet
    input_path = Path(args.input)
    output_path = Path(args.output)

    log("Starte mpc_obscode_tsv_to_html_v2")
    if getattr(sys, "frozen", False):
        log(f"EXE-Modus: {Path(sys.executable)}")
    else:
        log(f"Python-Modus: {Path(sys.argv[0])}")
    log(f"Arbeitsordner: {Path.cwd()}")
    log(f"Eingabe: {input_path}")
    log(f"Ausgabe: {output_path}")
    log(f"Basis-URL: {args.base_url}")
    log(f"MPC-Sync vor HTML-Erzeugung: {'aus' if args.skip_mpc_sync else 'an'}")
    log(f"MPC-Sync Maximalalter: {max(0, args.mpc_sync_max_age_days)} Tage")
    log(f"Web-Link-Pruefung: {'aus' if args.skip_web_link_check else 'an'}")
    log(f"Web-Link-Pruefung erzwingen: {'an' if args.force_web_link_check else 'aus'}")
    log(f"Jost-Indexdaten fuer First/Last/Observations: {'aus' if args.skip_observation_counts else 'an'}")

    if not run_mpc_sync_before_generation(
        enabled=not args.skip_mpc_sync,
        request_delay_ms=max(0, args.mpc_sync_request_delay_ms),
        max_age_days=max(0, args.mpc_sync_max_age_days),
    ):
        return 1

    if not input_path.exists():
        print(f"Fehler: Eingabedatei nicht gefunden: {input_path}")
        return 1

    records = read_snapshot(input_path, diagnose=args.diagnose)
    country_boundaries = load_country_boundaries()
    log("Lade Laender-Cache und manuelle Ueberschreibungen")
    country_cache = load_country_cache()
    country_overrides = load_country_overrides()
    log(f"Laender-Cache: {len(country_cache)} Eintraege")
    log(f"Manuelle Ueberschreibungen: {len(country_overrides)} Eintraege")
    enrich_records_with_country(records, country_boundaries, country_cache, country_overrides)
    station_index_data: dict[str, StationIndexEntry] = {}
    if not args.skip_observation_counts:
        station_index_data = load_station_index_data()
        assign_station_index_data(records, station_index_data)
        write_unmatched_observation_count_output(records, station_index_data)
    check_web_links(records, enabled=not args.skip_web_link_check, force=args.force_web_link_check)
    html_text = build_html(records, args.base_url, args.title)
    output_path.write_text(html_text, encoding="utf-8")
    log(f"HTML geschrieben: {output_path}")
    save_country_cache(records)
    write_web_link_status_output(records, args.base_url)

    write_frequency_outputs(
        records,
        "observations_type",
        "MPC Observatory Code Type Statistics",
        "Type",
        "mpc_code_stats_type.html",
        "mpc_code_stats_type.png",
    )
    write_frequency_outputs(
        records,
        "status_text",
        "MPC Observatory Code Status Statistics",
        "Status",
        "mpc_code_stats_status.html",
        "mpc_code_stats_status.png",
    )
    write_country_outputs(
        records,
        country_boundaries,
        "MPC Observatory Code Country Statistics",
        "mpc_code_stats_country.html",
        "mpc_code_stats_country.png",
    )
    write_country_text_output(records)
    write_coordinate_doubles_output(records, args.base_url)
    write_no_flag_output(records)
    write_no_country_output(records, country_boundaries, args.base_url)
    write_daily_events_output(records, args.base_url)
    write_mockup_outputs(records, country_boundaries)
    write_kmz_output(records, args.base_url)
    sync_dist_site()

    log(f"Fertig: {len(records)} Datensaetze verarbeitet")
    log(
        "Additional files written: "
        "mpc_code_stats_type.html, mpc_code_stats_type.png, mpc_code_stats_type.gif, "
        "mpc_code_stats_status.html, mpc_code_stats_status.png, mpc_code_stats_status.gif, "
        "mpc_code_stats_country.html, mpc_code_stats_country.png, mpc_code_stats_country.gif, "
        "mpc_web_link_status.html, mpc_obscode_doubles.html, no_flag.html, no_country.html, no_country_map.png, "
        "mpc_daily_changes.html, mpc_obscode_stations.kmz, "
        "mpc_obscode_country_list.txt, jost_station_observation_counts_unmatched.txt, "
        "country_maps/*.html, country_maps/*.png, map_mockups/*.svg, country_overrides.txt"
    )
    return 0


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