#!/usr/bin/env python3
"""Receive Clinic Pro backups uploaded by System.Net.WebClient.UploadFile.

The Windows program posts to this exact path and cannot be changed:

    POST /pos/upload.php
    Content-Type: multipart/form-data; boundary=...
    form field name: file

A raw application/octet-stream or binary/octet-stream body is also accepted.
"""

from __future__ import annotations

import hashlib
import hmac
import logging
import os
import re
import secrets
import shutil
import sys
import threading
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from html import escape
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qsl, quote, unquote, urlsplit

LOG = logging.getLogger("clinic-backup")

UPLOAD_PATH = "/pos/upload.php"
DASHBOARD_PATH = "/backup"
SESSION_COOKIE = "clinic_backup_session"
SESSION_TTL = 12 * 60 * 60
DISPOSITION_RE = re.compile(r"""([A-Za-z0-9_-]+)\s*=\s*(?:"([^"]*)"|([^;]+))""")
STAMP_RE = re.compile(r"^(\d{8}T\d{6}Z)_([0-9a-fA-F]{8})_(.+)$")
MONTHS = "Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split()
HTML_EXTRA = {
    "X-Frame-Options": "DENY",
    "Referrer-Policy": "no-referrer",
    "Cache-Control": "no-store",
    "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
}


class UploadError(Exception):
    def __init__(self, status: int, message: str) -> None:
        super().__init__(message)
        self.status = status
        self.message = message


@dataclass(frozen=True)
class Config:
    storage: Path
    max_bytes: int
    keep_days: int
    allow_ips: frozenset[str]
    listen: str
    port: int
    timeout_seconds: int
    trust_proxy: bool
    download_user: str
    download_password: str

    @property
    def files_dir(self) -> Path:
        return self.storage / "files"

    @property
    def log_path(self) -> Path:
        return self.storage / "uploads.log"


def load_config() -> Config:
    storage = Path(os.environ.get("CLINIC_STORAGE", "/var/backups/clinic-pro"))
    max_gb = os.environ.get("CLINIC_MAX_GB", "8")
    try:
        max_bytes = int(float(max_gb) * 1024 * 1024 * 1024)
    except ValueError as exc:
        raise SystemExit(f"CLINIC_MAX_GB is not a number: {max_gb}") from exc
    allow = os.environ.get("CLINIC_ALLOW_IPS", "").strip()
    allow_ips = frozenset(part.strip() for part in allow.split(",") if part.strip())
    return Config(
        storage=storage,
        max_bytes=max_bytes,
        keep_days=int(os.environ.get("CLINIC_KEEP_DAYS", "90")),
        allow_ips=allow_ips,
        listen=os.environ.get("CLINIC_LISTEN", "127.0.0.1"),
        port=int(os.environ.get("CLINIC_PORT", "8090")),
        timeout_seconds=int(os.environ.get("CLINIC_TIMEOUT", "21600")),
        trust_proxy=os.environ.get("CLINIC_TRUST_PROXY", "1") == "1",
        download_user=os.environ.get("CLINIC_DOWNLOAD_USER", ""),
        download_password=os.environ.get("CLINIC_DOWNLOAD_PASSWORD", ""),
    )


class BoundedReader:
    """Read at most `remaining` bytes from a socket stream."""

    def __init__(self, raw, remaining: int) -> None:
        self.raw = raw
        self.remaining = remaining

    def read(self, size: int) -> bytes:
        if self.remaining <= 0:
            return b""
        chunk = self.raw.read(min(size, self.remaining))
        if not chunk:
            return b""
        self.remaining -= len(chunk)
        return chunk


def boundary_from_content_type(content_type: str) -> bytes | None:
    if not content_type:
        return None
    parts = [part.strip() for part in content_type.split(";")]
    if not parts or not parts[0].lower().startswith("multipart/"):
        return None
    for part in parts[1:]:
        if not part.lower().startswith("boundary="):
            continue
        value = part.split("=", 1)[1].strip()
        if len(value) >= 2 and value[0] == value[-1] == '"':
            value = value[1:-1]
        if not value:
            return None
        try:
            return value.encode("ascii")
        except UnicodeEncodeError:
            return None
    return None


def is_raw_body(content_type: str) -> bool:
    media = content_type.split(";", 1)[0].strip().lower()
    return media in {
        "application/octet-stream",
        "binary/octet-stream",
        "application/binary",
    }


def sanitize_filename(name: str) -> str:
    name = name.replace("\\", "/").split("/")[-1].strip()
    name = re.sub(r"[\x00-\x1f<>:\"|?*]", "_", name)
    name = name.strip(" .")
    if not name or name in {".", ".."}:
        return "backup.bak"
    return name[:180]


def parse_disposition(value: str) -> dict[str, str]:
    found: dict[str, str] = {}
    for match in DISPOSITION_RE.finditer(value):
        raw = match.group(2) if match.group(2) is not None else match.group(3)
        found[match.group(1).lower()] = raw.strip()
    return found


def _write_limited(out, data: bytes, written: int, max_bytes: int) -> int:
    if not data:
        return written
    new_size = written + len(data)
    if new_size > max_bytes:
        raise UploadError(413, "Backup is larger than the configured limit")
    out.write(data)
    return new_size


def _read_until(reader: BoundedReader, pending: bytes, marker: bytes, limit: int) -> tuple[bytes, bytes]:
    buf = pending
    while marker not in buf:
        if len(buf) > limit:
            raise UploadError(400, "Upload headers are too large")
        chunk = reader.read(8192)
        if not chunk:
            raise UploadError(400, "Upload ended before the file header")
        buf += chunk
    index = buf.index(marker)
    return buf[:index], buf[index + len(marker) :]


def _stream_until(reader: BoundedReader, pending: bytes, marker: bytes, out, max_bytes: int) -> tuple[int, bytes]:
    buf = pending
    written = 0
    overlap = max(len(marker) - 1, 0)
    while True:
        index = buf.find(marker)
        if index != -1:
            written = _write_limited(out, buf[:index], written, max_bytes)
            return written, buf[index + len(marker) :]
        if len(buf) > overlap:
            emit = buf[:-overlap] if overlap else buf
            buf = buf[-overlap:] if overlap else b""
            written = _write_limited(out, emit, written, max_bytes)
        chunk = reader.read(1024 * 1024)
        if not chunk:
            raise UploadError(400, "Upload ended before the closing boundary")
        buf += chunk


def _looks_like_multipart(prefix: bytes) -> bool:
    head = prefix[:1024]
    return head.startswith(b"--") and b"Content-Disposition: form-data;" in head


def _destination(config: Config, original_name: str) -> Path:
    stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    token = secrets.token_hex(4)
    return config.files_dir / f"{stamp}_{token}_{sanitize_filename(original_name)}"


def save_multipart(
    reader: BoundedReader,
    pending: bytes,
    boundary: bytes,
    config: Config,
) -> tuple[Path, int, str]:
    header_blob, pending = _read_until(reader, pending, b"\r\n\r\n", 65536)
    lines = header_blob.split(b"\r\n")
    if not lines or lines[0] != b"--" + boundary:
        raise UploadError(400, "Multipart body does not match the Content-Type boundary")

    headers: dict[str, str] = {}
    for line in lines[1:]:
        if b":" not in line:
            continue
        key, value = line.split(b":", 1)
        headers[key.decode("utf-8", "replace").lower()] = value.decode("utf-8", "replace").strip()

    disposition = parse_disposition(headers.get("content-disposition", ""))
    original = disposition.get("filename") or disposition.get("filename*") or "backup.bak"
    if disposition.get("name", "file") not in {"file", ""} and "filename" not in disposition:
        raise UploadError(400, "Expected a form field named file")

    dest = _destination(config, original)
    tmp = dest.with_suffix(dest.suffix + ".partial")
    try:
        with tmp.open("wb") as out:
            size, _rest = _stream_until(reader, pending, b"\r\n--" + boundary, out, config.max_bytes)
        os.replace(tmp, dest)
    except Exception:
        tmp.unlink(missing_ok=True)
        raise
    finally:
        _drain(reader)
    if size <= 0:
        dest.unlink(missing_ok=True)
        raise UploadError(400, "Uploaded file is empty")
    os.chmod(dest, 0o640)
    return dest, size, sanitize_filename(original)


def save_raw(reader: BoundedReader, pending: bytes, config: Config, original_name: str) -> tuple[Path, int, str]:
    dest = _destination(config, original_name)
    tmp = dest.with_suffix(dest.suffix + ".partial")
    try:
        with tmp.open("wb") as out:
            written = _write_limited(out, pending, 0, config.max_bytes)
            while True:
                chunk = reader.read(1024 * 1024)
                if not chunk:
                    break
                written = _write_limited(out, chunk, written, config.max_bytes)
        if written <= 0:
            raise UploadError(400, "Uploaded file is empty")
        os.replace(tmp, dest)
    except Exception:
        tmp.unlink(missing_ok=True)
        raise
    os.chmod(dest, 0o640)
    return dest, written, sanitize_filename(original_name)


def _drain(reader: BoundedReader) -> None:
    while reader.read(1024 * 1024):
        pass


def receive_upload(content_type: str, reader: BoundedReader, content_length: int, config: Config) -> tuple[Path, int, str]:
    overhead = 1024 * 1024
    if content_length <= 0:
        raise UploadError(400, "Uploaded file is empty")
    if content_length > config.max_bytes + overhead:
        raise UploadError(413, "Backup is larger than the configured limit")

    boundary = boundary_from_content_type(content_type)
    if boundary is not None:
        return save_multipart(reader, b"", boundary, config)

    prefix = reader.read(min(1024, content_length))
    if _looks_like_multipart(prefix):
        first_line = prefix.split(b"\r\n", 1)[0]
        if first_line.startswith(b"--") and len(first_line) > 2:
            return save_multipart(reader, prefix, first_line[2:], config)

    if is_raw_body(content_type) or not content_type:
        return save_raw(reader, prefix, config, "backup.bak")

    raise UploadError(415, "Send the backup as multipart form field file")


def prune_old_files(config: Config) -> None:
    if config.keep_days <= 0:
        return
    cutoff = datetime.now(timezone.utc).timestamp() - (config.keep_days * 86400)
    for path in config.files_dir.glob("*"):
        if not path.is_file():
            continue
        try:
            if path.stat().st_mtime < cutoff:
                path.unlink()
                LOG.info("removed expired backup %s", path.name)
        except OSError as exc:
            LOG.warning("could not remove %s: %s", path.name, exc)


def append_log(config: Config, line: str) -> None:
    with config.log_path.open("a", encoding="utf-8") as handle:
        handle.write(line + "\n")


def ensure_storage(config: Config) -> None:
    config.files_dir.mkdir(parents=True, exist_ok=True)
    os.chmod(config.storage, 0o750)
    os.chmod(config.files_dir, 0o750)
    htaccess = config.files_dir / ".htaccess"
    if not htaccess.exists():
        htaccess.write_text("Require all denied\nDeny from all\n", encoding="ascii")


def check_disk(config: Config) -> None:
    usage = shutil.disk_usage(config.storage)
    if usage.free < 1024 * 1024 * 1024:
        raise UploadError(507, "Not enough free disk space for a backup")


def dashboard_enabled(config: Config) -> bool:
    return bool(config.download_user and config.download_password)


def format_bytes(size: int) -> str:
    value = float(size)
    for unit in ("B", "KB", "MB", "GB", "TB"):
        if value < 1024 or unit == "TB":
            if unit == "B":
                return f"{int(value)} B"
            text = f"{value:.1f}"
            if text.endswith(".0"):
                text = text[:-2]
            return f"{text} {unit}"
        value /= 1024
    return f"{size} B"


def format_when(moment: datetime) -> str:
    moment = moment.astimezone(timezone.utc)
    return f"{moment.day:02d} {MONTHS[moment.month - 1]} {moment.year} {moment.hour:02d}:{moment.minute:02d} UTC"


def list_backups(config: Config) -> list[dict]:
    items = []
    if not config.files_dir.exists():
        return items
    for path in config.files_dir.iterdir():
        if not path.is_file() or path.name.startswith(".") or path.name.endswith(".partial"):
            continue
        modified = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc)
        match = STAMP_RE.match(path.name)
        if match:
            received = datetime.strptime(match.group(1), "%Y%m%dT%H%M%SZ").replace(tzinfo=timezone.utc)
            original = match.group(3)
        else:
            received = modified
            original = path.name
        items.append(
            {
                "name": path.name,
                "original": original,
                "size": path.stat().st_size,
                "received": received,
            }
        )
    items.sort(key=lambda item: item["received"], reverse=True)
    return items


def recent_activity(config: Config, limit: int = 12) -> list[dict]:
    if not config.log_path.exists():
        return []
    data = config.log_path.read_bytes()
    if len(data) > 262144:
        data = data[-262144:]
    rows = []
    for line in data.decode("utf-8", "replace").splitlines()[-limit:]:
        parts = line.split("\t")
        if len(parts) < 5:
            continue
        try:
            size = int(parts[4])
        except ValueError:
            size = 0
        try:
            received = datetime.strptime(parts[0], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
        except ValueError:
            received = None
        rows.append(
            {
                "when": format_when(received) if received else parts[0],
                "ip": parts[1],
                "original": parts[2],
                "size": size,
            }
        )
    rows.reverse()
    return rows


def _password_tag(config: Config) -> str:
    return hashlib.sha256(config.download_password.encode("utf-8")).hexdigest()[:16]


def _secret_path(config: Config) -> Path:
    return config.storage / ".session_key"


def _secret(config: Config) -> bytes:
    path = _secret_path(config)
    if not path.exists():
        path.write_bytes(secrets.token_bytes(32))
        try:
            os.chmod(path, 0o600)
        except OSError:
            pass
    return path.read_bytes()


def rotate_secret(config: Config) -> None:
    path = _secret_path(config)
    path.write_bytes(secrets.token_bytes(32))
    try:
        os.chmod(path, 0o600)
    except OSError:
        pass


def make_session(config: Config, user: str) -> str:
    expiry = int(time.time()) + SESSION_TTL
    payload = f"{expiry}|{user}|{_password_tag(config)}".encode("utf-8")
    signature = hmac.new(_secret(config), payload, hashlib.sha256).hexdigest()
    return payload.hex() + "." + signature


def session_user(config: Config, cookie_header: str | None) -> str | None:
    if not cookie_header or not dashboard_enabled(config):
        return None
    try:
        jar = SimpleCookie()
        jar.load(cookie_header)
    except Exception:
        return None
    morsel = jar.get(SESSION_COOKIE)
    if morsel is None or "." not in morsel.value:
        return None
    payload_hex, signature = morsel.value.split(".", 1)
    try:
        payload = bytes.fromhex(payload_hex)
    except ValueError:
        return None
    expected = hmac.new(_secret(config), payload, hashlib.sha256).hexdigest()
    if not secrets.compare_digest(expected, signature):
        return None
    try:
        expiry_text, user, tag = payload.decode("utf-8").split("|", 2)
        expiry = int(expiry_text)
    except ValueError:
        return None
    if expiry < int(time.time()):
        return None
    if not secrets.compare_digest(user, config.download_user):
        return None
    if not secrets.compare_digest(tag, _password_tag(config)):
        return None
    return user


def session_cookie(config: Config, user: str, secure: bool) -> str:
    token = make_session(config, user)
    cookie = f"{SESSION_COOKIE}={token}; HttpOnly; Path=/; SameSite=Lax; Max-Age={SESSION_TTL}"
    if secure:
        cookie += "; Secure"
    return cookie


def cleared_cookie(secure: bool) -> str:
    cookie = f"{SESSION_COOKIE}=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0"
    if secure:
        cookie += "; Secure"
    return cookie


PAGE_STYLE = """
:root {
  --bg: #e7eef2;
  --ink: #142433;
  --muted: #5d6d7c;
  --line: #d5e0e8;
  --header: #10283a;
  --accent: #0c6e69;
  --soft: #e5f4f2;
  --warn: #9a3412;
  --shadow: 0 10px 30px rgba(16, 40, 58, 0.08);
}
* { box-sizing: border-box; }
body {
  margin: 0;
  font-family: "Segoe UI", "Myanmar Text", sans-serif;
  background: var(--bg);
  color: var(--ink);
}
.top {
  background: var(--header);
  color: white;
  padding: 22px 28px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 16px;
}
.brand h1 { margin: 0; font-size: 22px; font-weight: 650; letter-spacing: -0.02em; }
.brand p { margin: 4px 0 0; color: #b7c6d3; font-size: 14px; }
.who { display: flex; align-items: center; gap: 12px; color: #d5e2ec; font-size: 14px; }
.wrap { max-width: 1080px; margin: 0 auto; padding: 28px 20px 48px; }
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 18px; }
.stat, .panel, .card { background: white; border-radius: 14px; box-shadow: var(--shadow); }
.stat { padding: 16px 18px; }
.stat span { display: block; color: var(--muted); font-size: 13px; }
.stat strong { display: block; margin-top: 6px; font-size: 22px; letter-spacing: -0.02em; }
.stat strong.when { font-size: 18px; line-height: 1.3; }
.panel { overflow: hidden; margin-top: 16px; }
.panel-head {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  align-items: center;
  padding: 16px 18px;
  border-bottom: 1px solid var(--line);
}
.panel-head h2 { margin: 0; font-size: 16px; }
input[type="search"], input[type="text"], input[type="password"] {
  width: 100%;
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 10px 12px;
  font: inherit;
  background: white;
  color: var(--ink);
}
input[type="search"] { max-width: 280px; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 12px 16px; border-bottom: 1px solid var(--line); vertical-align: middle; }
th { font-size: 12px; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); font-weight: 650; }
tr:last-child td { border-bottom: 0; }
.name { font-weight: 650; }
.sub { color: var(--muted); font-size: 12px; margin-top: 3px; word-break: break-all; }
.num { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
.btn, button.solid {
  appearance: none;
  border: 0;
  background: var(--accent);
  color: white;
  border-radius: 999px;
  padding: 8px 14px;
  font: inherit;
  font-size: 14px;
  text-decoration: none;
  display: inline-block;
  cursor: pointer;
}
.btn:hover, button.solid:hover { background: #095651; }
button.ghost {
  appearance: none;
  background: transparent;
  color: white;
  border: 1px solid rgba(255,255,255,.4);
  border-radius: 999px;
  padding: 7px 12px;
  font: inherit;
  cursor: pointer;
}
.latest {
  background: var(--soft);
  color: var(--accent);
  border-radius: 999px;
  font-size: 12px;
  padding: 2px 8px;
  margin-left: 8px;
  font-weight: 650;
}
.empty { text-align: center; color: var(--muted); padding: 36px 16px; }
.bar { height: 8px; background: #e4ebf0; border-radius: 99px; margin-top: 10px; overflow: hidden; }
.bar i { display: block; height: 100%; background: var(--accent); }
.bar.warn i { background: var(--warn); }
.note { color: var(--muted); font-size: 13px; margin: 14px 2px 0; }
.login-wrap { min-height: 100vh; display: grid; place-items: center; padding: 24px; background: var(--bg); }
.card { width: min(420px, 100%); padding: 28px; }
.card h1 { margin: 0 0 6px; font-size: 26px; letter-spacing: -0.03em; }
.card .lede { margin: 0 0 18px; color: var(--muted); }
label { display: block; font-size: 13px; margin: 12px 0 6px; }
.error { background: #fde8e4; color: var(--warn); border-radius: 10px; padding: 10px 12px; }
.card button.solid { width: 100%; margin-top: 16px; padding: 11px 14px; }
@media (max-width: 800px) {
  .stats { grid-template-columns: 1fr 1fr; }
  .top { align-items: flex-start; flex-direction: column; }
  .panel-head { flex-direction: column; align-items: stretch; }
  input[type="search"] { max-width: none; }
  .scroll { overflow-x: auto; }
}
"""


def _page(title: str, body: str) -> str:
    return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{escape(title)}</title>
<style>{PAGE_STYLE}</style>
</head>
<body>
{body}
</body>
</html>
"""


def render_login(failed: bool) -> str:
    message = '<p class="error">Sign-in failed. Check the user name and password.</p>' if failed else ""
    return _page(
        "Sign in — Clinic Pro backups",
        f"""<div class="login-wrap"><form class="card" method="post" action="/backup/login">
<h1>Clinic Pro backups</h1>
<p class="lede">Sign in to see and download database backups.</p>
{message}
<label for="username">User name</label>
<input id="username" name="username" type="text" autocomplete="username" required>
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
<button class="solid" type="submit">Sign in</button>
</form></div>""",
    )


def render_disabled() -> str:
    return _page(
        "Clinic Pro backups",
        """<div class="login-wrap"><section class="card">
<h1>Clinic Pro backups</h1>
<p class="lede">Set CLINIC_DOWNLOAD_USER and CLINIC_DOWNLOAD_PASSWORD in /etc/clinic-backup.env, then restart the backup service.</p>
</section></div>""",
    )


def render_dashboard(config: Config, user: str) -> str:
    files = list_backups(config)
    total = sum(item["size"] for item in files)
    if files:
        received = files[0]["received"].astimezone(timezone.utc)
        latest_html = (
            f'<strong class="when">{escape(f"{received.day:02d} {MONTHS[received.month - 1]} {received.year}")}'
            f"<br>{received.hour:02d}:{received.minute:02d} UTC</strong>"
        )
    else:
        latest_html = "<strong>None yet</strong>"
    usage = shutil.disk_usage(config.storage)
    used_pct = int(usage.used * 100 / usage.total) if usage.total else 0
    warn = " warn" if used_pct >= 90 else ""
    rows = []
    for index, item in enumerate(files):
        badge = '<span class="latest">Latest</span>' if index == 0 else ""
        href = "/backup/file/" + quote(item["name"])
        search = escape(f"{item['original']} {item['name']}".lower())
        rows.append(
            "<tr data-search=\""
            + search
            + "\"><td>"
            + escape(format_when(item["received"]))
            + "</td><td><div class=\"name\">"
            + escape(item["original"])
            + badge
            + "</div><div class=\"sub\">"
            + escape(item["name"])
            + "</div></td><td class=\"num\">"
            + escape(format_bytes(item["size"]))
            + "</td><td class=\"num\"><a class=\"btn\" href=\""
            + escape(href)
            + "\">Download</a></td></tr>"
        )
    if not rows:
        body_rows = '<tr><td class="empty" colspan="4">No backups yet. Files uploaded from Clinic Pro will show up here.</td></tr>'
    else:
        body_rows = "".join(rows)
    activity_rows = []
    for entry in recent_activity(config):
        activity_rows.append(
            "<tr><td>"
            + escape(entry["when"])
            + "</td><td>"
            + escape(entry["original"])
            + "</td><td class=\"num\">"
            + escape(format_bytes(entry["size"]))
            + "</td><td>"
            + escape(entry["ip"])
            + "</td></tr>"
        )
    activity = ""
    if activity_rows:
        activity = (
            '<section class="panel"><div class="panel-head"><h2>Recent uploads</h2></div>'
            '<div class="scroll"><table><thead><tr><th>Received</th><th>File</th><th class="num">Size</th><th>From</th></tr></thead><tbody>'
            + "".join(activity_rows)
            + "</tbody></table></div></section>"
        )
    keep = "Backups are kept until the next upload removes files older than the retention window."
    if config.keep_days > 0:
        keep = f"Files older than {config.keep_days} days are removed when the next backup arrives."
    return _page(
        "Clinic Pro backups",
        f"""<header class="top">
<div class="brand"><h1>Clinic Pro backups</h1><p>Database files uploaded from the POS.</p></div>
<div class="who"><span>Signed in as {escape(user)}</span>
<form method="post" action="/backup/logout"><button class="ghost" type="submit">Log out</button></form>
</div>
</header>
<main class="wrap">
<section class="stats">
<article class="stat"><span>Backups</span><strong>{len(files)}</strong></article>
<article class="stat"><span>Stored</span><strong>{escape(format_bytes(total))}</strong></article>
<article class="stat"><span>Latest</span>{latest_html}</article>
<article class="stat"><span>Disk free</span><strong>{escape(format_bytes(usage.free))}</strong>
<div class="bar{warn}"><i style="width:{used_pct}%"></i></div></article>
</section>
<section class="panel">
<div class="panel-head"><h2>Backup files</h2>
<input id="q" type="search" placeholder="Search file name" aria-label="Search file name">
</div>
<div class="scroll"><table>
<thead><tr><th>Received</th><th>File</th><th class="num">Size</th><th class="num">Download</th></tr></thead>
<tbody id="files">{body_rows}</tbody>
</table></div>
</section>
{activity}
<p class="note">{escape(keep)}</p>
</main>
<script>
document.getElementById("q").addEventListener("input", function () {{
  var query = this.value.trim().toLowerCase();
  document.querySelectorAll("#files tr[data-search]").forEach(function (row) {{
    row.hidden = query.length > 0 && row.getAttribute("data-search").indexOf(query) === -1;
  }});
}});
</script>""",
    )


def _authorized(header: str | None, config: Config) -> bool:
    if not dashboard_enabled(config):
        return False
    if not header or not header.startswith("Basic "):
        return False
    import base64

    try:
        decoded = base64.b64decode(header[6:].strip(), validate=True).decode("utf-8")
    except (ValueError, UnicodeDecodeError):
        return False
    user, sep, password = decoded.partition(":")
    if not sep:
        return False
    return secrets.compare_digest(user, config.download_user) and secrets.compare_digest(
        password, config.download_password
    )


class BackupHandler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"
    server_version = "ClinicBackup/1.0"
    config: Config

    def setup(self) -> None:
        super().setup()
        self.connection.settimeout(self.config.timeout_seconds)

    def log_message(self, fmt: str, *args) -> None:
        LOG.info("%s - %s", self.address_string(), fmt % args)

    def _client_ip(self) -> str:
        peer = self.client_address[0]
        if self.config.trust_proxy and peer in {"127.0.0.1", "::1"}:
            forwarded = self.headers.get("X-Real-IP") or self.headers.get("X-Forwarded-For", "")
            if forwarded:
                return forwarded.split(",")[0].strip()
        return peer

    def _reject_ip(self) -> bool:
        if not self.config.allow_ips:
            return False
        if self._client_ip() in self.config.allow_ips:
            return False
        self._send(403, b"Forbidden\n")
        return True

    def _dashboard_enabled(self) -> bool:
        return dashboard_enabled(self.config)

    def _viewer(self) -> str | None:
        if _authorized(self.headers.get("Authorization"), self.config):
            return self.config.download_user
        return session_user(self.config, self.headers.get("Cookie"))

    def _is_https(self) -> bool:
        if not (self.config.trust_proxy and self.client_address[0] in {"127.0.0.1", "::1"}):
            return False
        proto = (self.headers.get("X-Forwarded-Proto") or "").split(",")[0].strip().lower()
        return proto == "https"

    def _form(self) -> dict[str, str]:
        try:
            length = int(self.headers.get("Content-Length", "0"))
        except ValueError:
            length = 0
        if length <= 0:
            return {}
        if length > 8192:
            length = 8192
        raw = self.rfile.read(length).decode("utf-8", "replace")
        return {key: value for key, value in parse_qsl(raw, keep_blank_values=True)}

    def _redirect(self, location: str, cookie: str | None = None, clear: bool = False) -> None:
        self.close_connection = True
        self.send_response(303)
        self.send_header("Location", location)
        self.send_header("Content-Length", "0")
        self.send_header("Connection", "close")
        self.send_header("Cache-Control", "no-store")
        if cookie:
            self.send_header("Set-Cookie", cookie)
        if clear:
            self.send_header("Set-Cookie", cleared_cookie(self._is_https()))
        self.end_headers()

    def _send_html(self, status: int, html: str, extra: dict | None = None) -> None:
        headers = dict(HTML_EXTRA)
        if extra:
            headers.update(extra)
        self._send(status, html.encode("utf-8"), "text/html; charset=utf-8", headers)

    def _send(self, status: int, body: bytes, content_type: str = "text/plain; charset=utf-8", extra: dict | None = None) -> None:
        self.close_connection = True
        self.send_response(status)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Connection", "close")
        self.send_header("X-Content-Type-Options", "nosniff")
        if extra:
            for key, value in extra.items():
                self.send_header(key, value)
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self) -> None:  # noqa: N802
        path = urlsplit(self.path).path
        if path == "/health":
            self._send(200, b"ok\n")
            return
        if path == UPLOAD_PATH:
            self._send(200, b"Clinic Pro backup receiver is ready. POST a file field named file.\n")
            return
        if path == "/":
            self._redirect(DASHBOARD_PATH)
            return
        if path in {DASHBOARD_PATH, DASHBOARD_PATH + "/"}:
            self._show_dashboard()
            return
        if path.startswith(DASHBOARD_PATH + "/file/"):
            self._serve_backup(unquote(path[len(DASHBOARD_PATH + "/file/") :]))
            return
        if path == "/download" or path.startswith("/download/"):
            accept = self.headers.get("Accept", "")
            if path.rstrip("/") == "/download" and "text/html" in accept:
                self._redirect(DASHBOARD_PATH)
                return
            self._download(path)
            return
        self._send(404, b"Not found\n")

    def do_POST(self) -> None:  # noqa: N802
        path = urlsplit(self.path).path
        if path == "/backup/login":
            self._login()
            return
        if path == "/backup/logout":
            self._logout()
            return
        if path != UPLOAD_PATH:
            self._send(404, b"Not found\n")
            return
        if self._reject_ip():
            return

        try:
            content_length = int(self.headers.get("Content-Length", "0"))
        except ValueError:
            self._send(400, b"Content-Length is required\n")
            return
        if content_length <= 0:
            self._send(411, b"Content-Length is required\n")
            return

        try:
            check_disk(self.config)
            saved, size, original = receive_upload(
                self.headers.get("Content-Type", ""),
                BoundedReader(self.rfile, content_length),
                content_length,
                self.config,
            )
            prune_old_files(self.config)
        except UploadError as exc:
            LOG.warning("rejected upload from %s: %s", self._client_ip(), exc.message)
            self._send(exc.status, (exc.message + "\n").encode("utf-8"))
            return
        except Exception:
            LOG.exception("upload failed")
            self._send(500, b"Could not store the backup\n")
            return

        when = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
        append_log(
            self.config,
            f"{when}\t{self._client_ip()}\t{original}\t{saved.name}\t{size}",
        )
        LOG.info("stored %s (%s bytes) from %s", saved.name, size, self._client_ip())
        self._send(200, f"OK {saved.name}\n".encode("utf-8"))

    def _show_dashboard(self) -> None:
        if not self._dashboard_enabled():
            self._send_html(200, render_disabled())
            return
        user = self._viewer()
        if not user:
            self._send_html(200, render_login(False))
            return
        self._send_html(200, render_dashboard(self.config, user))

    def _login(self) -> None:
        if not self._dashboard_enabled():
            self._form()
            self._send_html(200, render_disabled())
            return
        form = self._form()
        username = form.get("username", "")
        password = form.get("password", "")
        valid = (
            "|" not in username
            and secrets.compare_digest(username, self.config.download_user)
            and secrets.compare_digest(password, self.config.download_password)
        )
        if not valid:
            self._send_html(401, render_login(True))
            return
        self._redirect(DASHBOARD_PATH, cookie=session_cookie(self.config, username, self._is_https()))

    def _logout(self) -> None:
        self._form()
        if self._viewer():
            rotate_secret(self.config)
        self._redirect(DASHBOARD_PATH, clear=True)

    def _serve_backup(self, rel: str) -> None:
        if not self._dashboard_enabled():
            self._send(404, b"Not found\n")
            return
        if not self._viewer():
            self._send_html(401, render_login(False), {"WWW-Authenticate": 'Basic realm="Clinic backups"'})
            return
        self._send_named_file(rel)

    def _download(self, path: str) -> None:
        if not self._dashboard_enabled():
            self._send(404, b"Not found\n")
            return
        if not self._viewer():
            self._send(401, b"Authentication required\n", extra={"WWW-Authenticate": 'Basic realm="Clinic backups"'})
            return

        rel = unquote(path[len("/download") :]).lstrip("/")
        if not rel:
            lines = []
            for item in list_backups(self.config):
                lines.append(f"{item['size']}\t{item['name']}")
            body = ("\n".join(lines) + ("\n" if lines else "")).encode("utf-8")
            self._send(200, body)
            return
        self._send_named_file(rel)

    def _send_named_file(self, rel: str) -> None:
        rel = rel.strip()
        name = sanitize_filename(rel)
        if not rel or name != rel or "/" in rel or "\\" in rel or ".." in rel:
            self._send(400, b"Invalid file name\n")
            return
        target = (self.config.files_dir / name).resolve()
        if target.parent != self.config.files_dir.resolve() or not target.is_file() or target.name.endswith(".partial"):
            self._send(404, b"Not found\n")
            return
        self._send_file(target)

    def _send_file(self, target: Path) -> None:
        size = target.stat().st_size
        self.close_connection = True
        self.send_response(200)
        self.send_header("Content-Type", "application/octet-stream")
        self.send_header("Content-Length", str(size))
        self.send_header("Content-Disposition", f"attachment; filename*=UTF-8''{quote(target.name)}")
        self.send_header("Connection", "close")
        self.send_header("X-Content-Type-Options", "nosniff")
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        with target.open("rb") as handle:
            while True:
                chunk = handle.read(1024 * 1024)
                if not chunk:
                    break
                self.wfile.write(chunk)

    def do_PUT(self) -> None:  # noqa: N802
        self._send(405, b"Method not allowed\n", extra={"Allow": "GET, POST"})

    def do_DELETE(self) -> None:  # noqa: N802
        self._send(405, b"Method not allowed\n", extra={"Allow": "GET, POST"})


def serve(config: Config) -> None:
    ensure_storage(config)
    BackupHandler.config = config
    server = ThreadingHTTPServer((config.listen, config.port), BackupHandler)
    LOG.info("listening on http://%s:%s%s", config.listen, config.port, UPLOAD_PATH)
    LOG.info("storing backups in %s", config.files_dir)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        LOG.info("stopping")
    finally:
        server.server_close()


def _sample_body(boundary: str, filename: str, payload: bytes) -> bytes:
    head = (
        f"--{boundary}\r\n"
        f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'
        f"Content-Type: application/octet-stream\r\n"
        f"\r\n"
    ).encode("utf-8")
    tail = f"\r\n--{boundary}--\r\n".encode("ascii")
    return head + payload + tail


def self_test() -> None:
    import tempfile
    from urllib.request import Request, urlopen

    with tempfile.TemporaryDirectory() as tmp:
        storage = Path(tmp)
        config = Config(
            storage=storage,
            max_bytes=4 * 1024 * 1024,
            keep_days=90,
            allow_ips=frozenset(),
            listen="127.0.0.1",
            port=0,
            timeout_seconds=30,
            trust_proxy=False,
            download_user="backup",
            download_password="secret",
        )
        ensure_storage(config)
        BackupHandler.config = config
        server = ThreadingHTTPServer(("127.0.0.1", 0), BackupHandler)
        port = server.server_address[1]
        thread = threading.Thread(target=server.serve_forever, daemon=True)
        thread.start()
        base = f"http://127.0.0.1:{port}"
        try:
            boundary = "---------------------8d5e4c3b2a19f"
            payload = (b"TAPE" + b"\r\n--not-the-boundary" + os.urandom(200_000)) * 8
            body = _sample_body(boundary, r"..\clinic backup.bak", payload)
            request = Request(
                base + UPLOAD_PATH,
                data=body,
                method="POST",
                headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
            )
            with urlopen(request, timeout=10) as response:
                text = response.read().decode("utf-8")
                if response.status != 200 or not text.startswith("OK "):
                    raise SystemExit(f"multipart upload failed: {response.status} {text}")

            saved = list(config.files_dir.glob("*.bak"))
            if len(saved) != 1 or saved[0].read_bytes() != payload:
                raise SystemExit("saved backup does not match the uploaded bytes")
            if saved[0].name.startswith(".") or ".." in saved[0].name:
                raise SystemExit("unsafe file name was stored")

            raw = Request(
                base + UPLOAD_PATH,
                data=b"raw-backup-bytes",
                method="POST",
                headers={"Content-Type": "binary/octet-stream"},
            )
            with urlopen(raw, timeout=10) as response:
                if response.status != 200:
                    raise SystemExit("raw upload failed")

            mismatch = _sample_body(boundary, "mismatch.bak", b"abc123")
            mismatched = Request(
                base + UPLOAD_PATH,
                data=mismatch,
                method="POST",
                headers={"Content-Type": "binary/octet-stream"},
            )
            with urlopen(mismatched, timeout=10) as response:
                if response.status != 200:
                    raise SystemExit("header-mismatch upload failed")

            listing = Request(base + "/download", headers={"Authorization": _basic("backup", "secret")})
            with urlopen(listing, timeout=10) as response:
                names = response.read().decode("utf-8")
            if "clinic backup.bak" not in names or "mismatch.bak" not in names:
                raise SystemExit(f"download list missing files: {names}")

            _exercise_dashboard(port, config)

            from urllib.error import HTTPError

            too_big = Request(
                base + UPLOAD_PATH,
                data=b"x" * (config.max_bytes + 50),
                method="POST",
                headers={"Content-Type": "application/octet-stream"},
            )
            try:
                urlopen(too_big, timeout=30)
                raise SystemExit("oversized upload was accepted")
            except HTTPError as exc:
                if exc.code != 413:
                    raise SystemExit(f"expected 413, got {exc.code}") from exc
        finally:
            server.shutdown()
            server.server_close()
    print("self-test passed")


def _exercise_dashboard(port: int, config: Config) -> None:
    import http.client
    from urllib.parse import urlencode

    def exchange(method: str, target: str, body: bytes | None = None, headers: dict | None = None) -> tuple[int, str, bytes]:
        conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
        conn.request(method, target, body=body, headers=headers or {})
        resp = conn.getresponse()
        payload = resp.read()
        status = resp.status
        header = resp.getheader("Set-Cookie") or ""
        conn.close()
        return status, header, payload

    status, _header, payload = exchange("GET", "/backup")
    page = payload.decode("utf-8")
    if status != 200 or "Sign in" not in page or "clinic backup.bak" in page:
        raise SystemExit("login page leaked backups or did not render")

    stored = next(path.name for path in config.files_dir.iterdir() if path.name.endswith("_mismatch.bak"))
    status, _header, payload = exchange("GET", "/backup/file/" + quote(stored))
    if status != 401 or payload == b"abc123":
        raise SystemExit("backup download was allowed without signing in")

    bad = urlencode({"username": "backup", "password": "wrong"}).encode()
    status, _header, payload = exchange(
        "POST",
        "/backup/login",
        bad,
        {"Content-Type": "application/x-www-form-urlencoded", "Content-Length": str(len(bad))},
    )
    if status != 401 or "Sign-in failed" not in payload.decode("utf-8"):
        raise SystemExit("wrong password was accepted")

    good = urlencode({"username": "backup", "password": "secret"}).encode()
    status, set_cookie, _payload = exchange(
        "POST",
        "/backup/login",
        good,
        {"Content-Type": "application/x-www-form-urlencoded", "Content-Length": str(len(good))},
    )
    if status != 303 or SESSION_COOKIE not in set_cookie:
        raise SystemExit(f"sign-in did not start a session: {status}")
    cookie = set_cookie.split(";", 1)[0]

    status, _header, payload = exchange("GET", "/backup", headers={"Cookie": cookie})
    dashboard = payload.decode("utf-8")
    if status != 200 or "clinic backup.bak" not in dashboard or "mismatch.bak" not in dashboard:
        raise SystemExit("dashboard is missing backup files")
    if f'href="/backup/file/{quote(stored)}"' not in dashboard or "Latest" not in dashboard:
        raise SystemExit("dashboard is missing the download link")

    status, _header, payload = exchange("GET", "/backup/file/" + quote(stored), headers={"Cookie": cookie})
    if status != 200 or payload != b"abc123":
        raise SystemExit("dashboard download did not return the backup bytes")

    status, _header, payload = exchange("POST", "/backup/logout", headers={"Cookie": cookie, "Content-Length": "0"})
    if status != 303:
        raise SystemExit("logout did not return to the sign-in page")
    status, _header, payload = exchange("GET", "/backup", headers={"Cookie": cookie})
    after = payload.decode("utf-8")
    if "Sign in" not in after or "clinic backup.bak" in after:
        raise SystemExit("logout left the backup list visible")


def _basic(user: str, password: str) -> str:
    import base64

    token = base64.b64encode(f"{user}:{password}".encode("utf-8")).decode("ascii")
    return f"Basic {token}"


def main() -> None:
    logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
    if "--self-test" in sys.argv:
        self_test()
        return
    serve(load_config())


if __name__ == "__main__":
    main()
