"""Backup Panel — standalone browser page for managing local-first backups.

Serves a single-page app with:
- Provider selection (Google Drive / Dropbox / Box / Tresorit)
- Manual "Back Up Now" button
- Restore UI
- Schedule configuration (tier-gated)
- File category split view (system vs user files)
- Config snapshot save/restore per config file

Follows the pattern of feed.py but on a separate URL/port.
"""

from __future__ import annotations

import asyncio
import contextlib
import json
import logging
import signal
import sys
import threading
from concurrent.futures import ThreadPoolExecutor
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlparse

import yaml

from vastian.tasks.backup import BackupManager, create_backup_manager

logger = logging.getLogger(__name__)


# ── Backup Panel HTML ──────────────────────────────────────────────

BACKUP_PANEL_HTML = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Backup Panel — Vastian Network</title>
<style>
  :root {
    --bg: #0d1117; --card: #161b22; --border: #30363d;
    --text: #e6edf3; --muted: #8b949e; --accent: #58a6ff;
    --green: #3fb950; --red: #f85149; --yellow: #d29922;
  }
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body { font-family: 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); padding: 24px; }
  h1 { font-size: 1.5rem; margin-bottom: 16px; }
  h2 { font-size: 1.1rem; margin-bottom: 12px; color: var(--accent); }
  .card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 16px; margin-bottom: 16px; }
  button { background: var(--accent); color: #fff; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-size: .9rem; }
  button:hover { opacity: .85; }
  button.danger { background: var(--red); }
  select, input[type="number"] { background: var(--card); border: 1px solid var(--border); color: var(--text); padding: 6px 10px; border-radius: 4px; }
  .status-bar { display: flex; gap: 24px; flex-wrap: wrap; margin-bottom: 16px; }
  .status-item { display: flex; flex-direction: column; }
  .status-label { font-size: .75rem; color: var(--muted); text-transform: uppercase; }
  .status-value { font-size: 1.1rem; font-weight: 600; }
  .provider-select { display: flex; gap: 12px; align-items: center; }
  .tier-locked { opacity: .5; pointer-events: none; position: relative; }
  .tier-locked::after { content: "Upgrade tier to unlock"; position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); background: var(--yellow); color: #000; padding: 4px 8px; border-radius: 4px; font-size: .75rem; white-space: nowrap; }
  .file-split { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
  .file-split .card h3 { font-size: .95rem; margin-bottom: 8px; }
  #restore-section { margin-top: 8px; }
  .snapshot-list { font-size: .85rem; color: var(--muted); }
  .snapshot-list .snap-item { display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px solid var(--border); }
  .config-panel { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
</style>
</head>
<body>
<h1>Backup Panel</h1>

<div class="status-bar">
  <div class="status-item"><span class="status-label">Provider</span><span class="status-value" id="provider-display">__PROVIDER__</span></div>
  <div class="status-item"><span class="status-label">Last Backup</span><span class="status-value" id="last-backup-display">__LAST_BACKUP__</span></div>
  <div class="status-item"><span class="status-label">Total</span><span class="status-value" id="count-total">__FILE_COUNT_TOTAL__</span></div>
  <div class="status-item"><span class="status-label">System Files</span><span class="status-value" id="count-system">__FILE_COUNT_SYSTEM__</span></div>
  <div class="status-item"><span class="status-label">User Files</span><span class="status-value" id="count-user">__FILE_COUNT_USER__</span></div>
</div>

<div class="card">
  <h2>Provider</h2>
  <div class="provider-select">
    <select id="provider-selector">
      <option value="google_drive">Google Drive</option>
      <option value="dropbox">Dropbox</option>
      <option value="box">Box</option>
      <option value="tresorit">Tresorit (zero-knowledge encrypted)</option>
      <option value="none">None</option>
    </select>
    <button id="backup-trigger" onclick="triggerBackup()">Back Up Now</button>
    <button id="verify-provider" onclick="verifyProvider()" style="background:var(--muted);">Verify</button>
  </div>
  <p id="verify-result" style="font-size:.85rem;margin-top:8px;min-height:1.2em;"></p>
</div>

<div class="card">
  <h2>Backup Options</h2>
  <div style="margin-bottom:12px;">
    <label style="display:block;margin-bottom:4px;">Mode:</label>
    <select id="backup-mode-selector">
      <option value="archive">Archive (zip) — date-stamped .zip files</option>
      <option value="live">Live — per-file sync (no zip)</option>
      <option value="both">Both — zip + per-file</option>
    </select>
  </div>
  <label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
    <input type="checkbox" id="clean-up-old-toggle"> Clean up old backups (delete old zips)
  </label>
  <p style="font-size:.8rem;color:var(--muted);margin-top:8px;">When checked, deletes previous zip archives after a new backup. Unchecked = retain all zips; new backup replaces live files only.</p>
</div>

<div class="card __SCHEDULE_TIER_CLASS__" id="schedule-section">
  <h2>Schedule</h2>
  <label>
    <input type="checkbox" id="schedule-toggle"> Enable automatic backups
  </label>
  <div style="margin-top:8px;">
    Interval: <input type="number" id="schedule-interval" min="1" max="168" value="24" style="width:60px;"> hours
  </div>
</div>

<div class="card">
  <h2>File Categories</h2>
  <div class="file-split">
    <div class="card"><h3>System Files</h3><p>state/, config/local/, *.db</p><p id="sys-file-count">0 files</p></div>
    <div class="card"><h3>User Files</h3><p>docs/, workspace/, saved_prompts/</p><p id="usr-file-count">0 files</p></div>
  </div>
</div>

<div class="card" id="restore-section">
  <h2>Restore</h2>
  <p style="color:var(--muted);font-size:.9rem;">Select a backup to restore from.</p>
  <div id="backup-history" class="snapshot-list"></div>
</div>

<div class="card">
  <h2>Config Management</h2>
  <div class="config-panel">
    <select id="config-file-selector" onchange="activeConfigFile=this.value;loadSnapshots();">
      <option value="config/backup/settings.yaml">config/backup/settings.yaml</option>
      <option value="config/memory-subscriptions.yaml">config/memory-subscriptions.yaml</option>
    </select>
    <button id="config-save" onclick="saveConfig()">Save Snapshot</button>
    <select id="config-reset-dropdown">
      <option value="">Restore from snapshot...</option>
    </select>
    <button class="danger" onclick="resetConfig()">Reset to Default</button>
  </div>
  <div id="config-snapshot-list" class="snapshot-list" style="margin-top:8px;"></div>
</div>

<script>
/* ── Helpers ───────────────────────────────────────────────── */
const $  = id => document.getElementById(id);
const api = async (path, opts) => {
  const r = await fetch(path, opts);
  return r.json();
};
const postJson = (path, body) => api(path, {
  method:'POST',
  headers:{'Content-Type':'application/json'},
  body:JSON.stringify(body),
});

/* ── Provider selector ─────────────────────────────────────── */
$('provider-selector').addEventListener('change', async function() {
  const provider = this.value;
  await postJson('/api/backup/settings', {provider});
  $('provider-display').textContent = provider;
});

/* ── Backup options ────────────────────────────────────────── */
$('backup-mode-selector').addEventListener('change', async function() {
  const backup_mode = this.value;
  await postJson('/api/backup/settings', {backup_mode});
});
$('clean-up-old-toggle').addEventListener('change', async function() {
  const clean_up_old_backups = this.checked;
  await postJson('/api/backup/settings', {clean_up_old_backups});
});

/* ── Sync settings before backup (ensures dropdown state is persisted) ── */
async function syncSettingsBeforeBackup() {
  const provider = $('provider-selector').value;
  const backup_mode = $('backup-mode-selector').value;
  const clean_up_old_backups = $('clean-up-old-toggle').checked;
  await postJson('/api/backup/settings', { provider, backup_mode, clean_up_old_backups });
}

/* ── Backup trigger ────────────────────────────────────────── */
async function triggerBackup() {
  const btn = $('backup-trigger');
  btn.disabled = true; btn.textContent = 'Backing up…';
  try {
    await syncSettingsBeforeBackup();
    const d = await postJson('/api/backup/trigger', { force_full: true });
    if (d.status === 'completed') {
      const total = d.total_files != null ? d.total_files : (d.uploaded + d.skipped);
      let msg = `Backup: ${d.uploaded} uploaded, ${d.skipped} skipped.`;
      if (total === 0 || (d.uploaded === 0 && d.skipped === 0 && total === 0)) {
        msg = d.message || 'No files were backed up.';
        if (d.errors && d.errors.length) msg += ' ' + d.errors.map(e => e.error || e.file).join('; ');
        alert(msg);
      } else if (d.message || (d.errors && d.errors.length)) {
        msg = d.message || msg;
        if (d.errors && d.errors.length) msg += ' Errors: ' + d.errors.map(e => e.error || e.file).join('; ');
        btn.textContent = msg;
        if (d.errors && d.errors.length) alert(msg);
      } else {
        btn.textContent = msg;
      }
      setTimeout(() => location.reload(), d.uploaded > 0 ? 1500 : 2500);
    } else {
      btn.textContent = 'Backup failed';
      alert('Backup failed: ' + (d.message || JSON.stringify(d)));
    }
  } catch(e) {
    btn.textContent = 'Error';
    alert('Backup error: ' + e.message);
  } finally {
    setTimeout(() => { btn.disabled = false; btn.textContent = 'Back Up Now'; }, 3000);
  }
}

/* ── Verify provider ──────────────────────────────────────── */
async function verifyProvider() {
  const el = $('verify-result');
  el.textContent = 'Verifying…';
  try {
    await syncSettingsBeforeBackup();
    const d = await api('/api/backup/verify');
    if (d.ok) {
      el.textContent = 'Connected to ' + (d.provider || '?') + ': ' + (d.message || 'OK');
      el.style.color = 'var(--green)';
    } else {
      el.textContent = d.message || d.error || 'Verification failed';
      el.style.color = 'var(--red)';
    }
  } catch(e) {
    el.textContent = 'Error: ' + e.message;
    el.style.color = 'var(--red)';
  }
}

/* ── Schedule toggle + interval ────────────────────────────── */
$('schedule-toggle').addEventListener('change', async function() {
  const interval = parseInt($('schedule-interval').value) || 24;
  await postJson('/api/backup/settings', {schedule:{enabled:this.checked, interval_hours:interval}});
});
$('schedule-interval').addEventListener('change', async function() {
  if (!$('schedule-toggle').checked) return;
  await postJson('/api/backup/settings', {schedule:{enabled:true, interval_hours:parseInt(this.value)||24}});
});

/* ── Config snapshot management ────────────────────────────── */
const CONFIG_FILES = [
  'config/backup/settings.yaml',
  'config/memory-subscriptions.yaml',
];
let activeConfigFile = CONFIG_FILES[0];

async function loadSnapshots() {
  const d = await api(`/api/config/snapshots?file=${encodeURIComponent(activeConfigFile)}`);
  const snaps = d.snapshots || [];
  const dd = $('config-reset-dropdown');
  dd.innerHTML = '<option value="">Restore from snapshot…</option>';
  snaps.forEach(s => {
    const opt = document.createElement('option');
    opt.value = s.name;
    opt.textContent = `${s.timestamp} — ${s.name} (${s.size}B)`;
    dd.appendChild(opt);
  });
  const list = $('config-snapshot-list');
  list.innerHTML = snaps.length
    ? snaps.map(s => `<div class="snap-item"><span>${s.timestamp} — ${s.name}</span><span>${s.size}B</span></div>`).join('')
    : '<p style="color:var(--muted);">No snapshots yet.</p>';
}

async function saveConfig() {
  const d = await postJson('/api/config/save', {file:activeConfigFile, content:''});
  alert(d.status === 'saved' ? 'Snapshot saved!' : 'Error: ' + JSON.stringify(d));
  loadSnapshots();
}

async function resetConfig() {
  const snapName = $('config-reset-dropdown').value;
  if (snapName) {
    if (!confirm(`Restore "${activeConfigFile}" from snapshot "${snapName}"?`)) return;
    const d = await postJson('/api/config/restore', {file:activeConfigFile, snapshot_id:snapName});
    alert(d.status === 'restored' ? 'Restored from snapshot!' : 'Error: ' + JSON.stringify(d));
  } else {
    if (!confirm(`Reset "${activeConfigFile}" to factory default?`)) return;
    const d = await postJson('/api/config/reset', {file:activeConfigFile});
    alert(d.status === 'reset' ? 'Reset to default!' : 'Error: ' + JSON.stringify(d));
  }
  loadSnapshots();
}

/* ── Restore UI ────────────────────────────────────────────── */
async function loadRestoreUI() {
  const d = await api('/api/backup/history');
  const el = $('backup-history');
  const backups = d.backups || [];
  if (!backups.length) {
    el.innerHTML = '<p style="color:var(--muted);">No backups yet.</p>';
    return;
  }
  el.innerHTML = backups.map(b =>
    `<div class="snap-item">` +
      `<span>${b}</span>` +
      `<button onclick="restoreFile('${b.replace(/'/g,"\\'")}')" style="padding:2px 8px;font-size:.8rem;">Restore</button>` +
    `</div>`
  ).join('');
}
async function restoreFile(backupId) {
  if (!confirm(`Restore "${backupId}" from backup?`)) return;
  const d = await postJson('/api/backup/restore', {backup_id:backupId});
  alert(d.status === 'restored' ? `Restored ${d.restored} file(s)!` : 'Error: ' + JSON.stringify(d));
}

/* ── File category counts ──────────────────────────────────── */
async function loadFileCounts() {
  try {
    const d = await api('/api/backup/scan');
    const total = d.total ?? 0;
    const sys = d.system_count ?? 0;
    const usr = d.user_count ?? 0;
    $('count-total').textContent = String(total);
    $('count-system').textContent = String(sys);
    $('count-user').textContent = String(usr);
    $('sys-file-count').textContent = sys + ' files';
    $('usr-file-count').textContent = usr + ' files';
    if (d.error) console.warn('Backup scan:', d.error);
  } catch(e) {
    $('count-total').textContent = '—';
    $('count-system').textContent = '—';
    $('count-user').textContent = '—';
    $('sys-file-count').textContent = '—';
    $('usr-file-count').textContent = '—';
  }
}

/* ── Page load init ────────────────────────────────────────── */
(async function init() {
  // Status bar
  const status = await api('/api/backup/status');
  $('provider-display').textContent = status.provider;
  $('last-backup-display').textContent = status.last_backup || 'Never';
  $('provider-selector').value = status.provider;
  $('schedule-toggle').checked = status.schedule_enabled || false;
  $('schedule-interval').value = status.schedule_interval_hours || 24;
  $('backup-mode-selector').value = status.backup_mode || 'both';
  const cleanUpEl = $('clean-up-old-toggle');
  if (cleanUpEl) cleanUpEl.checked = status.clean_up_old_backups || status.replace_previous || false;

  // Restore UI
  loadRestoreUI();
  // Snapshot list
  loadSnapshots();
  // File counts (may be slow for large projects)
  loadFileCounts();
})();
</script>
</body>
</html>"""


class BackupPanelHandler(BaseHTTPRequestHandler):
    """HTTP request handler for the backup panel."""

    panel: BackupPanel  # set by BackupPanel.start()

    def log_message(self, format: str, *args: Any) -> None:
        logger.debug("BackupPanel: %s", format % args)

    def _write_body(self, body: bytes) -> None:
        """Write response body; suppress connection-aborted errors when client disconnects."""
        try:
            self.wfile.write(body)
        except (ConnectionAbortedError, BrokenPipeError, ConnectionResetError):
            logger.debug("Client disconnected before response finished")

    def _send_json(self, data: dict, status: int = 200) -> None:
        body = json.dumps(data, default=str).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Access-Control-Allow-Origin", "*")
        self.end_headers()
        self._write_body(body)

    def _send_html(self, html: str) -> None:
        body = html.encode("utf-8")
        self.send_response(200)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self._write_body(body)

    def _read_body(self) -> dict:
        length = int(self.headers.get("Content-Length", 0))
        raw = self.rfile.read(length) if length else b"{}"
        return json.loads(raw)

    def do_GET(self) -> None:
        parsed = urlparse(self.path)
        path = parsed.path

        if path == "/":
            self._send_html(self.panel.render_html())
        elif path == "/api/backup/status":
            self._send_json(self.panel.backup_manager.get_backup_status())
        elif path == "/api/backup/history":
            self._send_json({"backups": self.panel.backup_manager.list_backups()})
        elif path == "/api/config/snapshots":
            qs = parse_qs(parsed.query)
            cfg_file = qs.get("file", [""])[0]
            snaps = self.panel.backup_manager.list_config_snapshots(cfg_file)
            self._send_json({"snapshots": snaps})
        elif path == "/api/backup/scan":
            self._handle_scan()
        elif path == "/api/backup/verify":
            self._handle_verify()
        else:
            self.send_error(404)

    def do_POST(self) -> None:
        parsed = urlparse(self.path)
        path = parsed.path

        if path == "/api/backup/trigger":
            self._handle_trigger()
        elif path == "/api/backup/restore":
            self._handle_restore()
        elif path == "/api/backup/settings":
            self._handle_settings()
        elif path == "/api/config/save":
            self._handle_config_save()
        elif path == "/api/config/restore":
            self._handle_config_restore()
        elif path == "/api/config/reset":
            self._handle_config_reset()
        else:
            self.send_error(404)

    def _handle_verify(self) -> None:
        """Verify provider connection by listing remote files."""
        self.panel.backup_manager = create_backup_manager(self.panel.project_root)
        mgr = self.panel.backup_manager
        provider_name = mgr.config.provider
        if not mgr.provider:
            self._send_json(
                {
                    "ok": False,
                    "provider": provider_name,
                    "message": "No storage provider configured or failed to load.",
                }
            )
            return
        try:

            def _list() -> Any:
                loop = asyncio.new_event_loop()
                asyncio.set_event_loop(loop)
                try:
                    return loop.run_until_complete(mgr.provider.list_files(prefix=""))
                finally:
                    loop.close()

            with ThreadPoolExecutor(max_workers=1) as pool:
                listed = pool.submit(_list).result()
            count = len(listed) if isinstance(listed, list) else 0
            sample = listed[:3] if listed else []
            names = [f.get("remote_path", f.get("name", "?")) for f in sample]
            self._send_json(
                {
                    "ok": True,
                    "provider": provider_name,
                    "message": f"{count} backup(s) found. Sample: {', '.join(names) or 'none'}",
                }
            )
        except Exception as e:
            self._send_json(
                {
                    "ok": False,
                    "provider": provider_name,
                    "message": str(e),
                }
            )

    def _handle_trigger(self) -> None:
        body = self._read_body() if self.headers.get("Content-Length") else {}
        force_full = body.get(
            "force_full", True
        )  # Panel "Back Up Now" = full upload and overwrite by default
        # Recreate BackupManager so we use current provider from settings (defensive)
        self.panel.backup_manager = create_backup_manager(self.panel.project_root)
        self.panel.config = self.panel.backup_manager.config
        mgr = self.panel.backup_manager
        try:
            files = mgr.scan() if hasattr(mgr, "scan") else []

            # Run async upload in a dedicated thread (Windows Python 3.8: set_wakeup_fd only works in main thread; no-op in worker)
            def _run_upload() -> Any:
                orig_wakeup = getattr(signal, "set_wakeup_fd", None)
                if sys.platform == "win32" and orig_wakeup:
                    asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
                    signal.set_wakeup_fd = lambda fd: (
                        -1
                    )  # no-op in worker thread to avoid ValueError
                loop = asyncio.new_event_loop()
                asyncio.set_event_loop(loop)
                try:
                    return loop.run_until_complete(mgr.upload(files, force_full=force_full))
                finally:
                    with contextlib.suppress(ValueError):
                        loop.close()
                    if sys.platform == "win32" and orig_wakeup:
                        signal.set_wakeup_fd = orig_wakeup

            with ThreadPoolExecutor(max_workers=1) as pool:
                result = pool.submit(_run_upload).result()
            self._send_json({"status": "completed", **result})
        except Exception as e:
            self._send_json({"status": "error", "message": str(e)}, 500)

    def _handle_restore(self) -> None:
        body = self._read_body()
        backup_id = body.get("backup_id", "")
        mgr = self.panel.backup_manager

        def _run_restore() -> Any:
            orig_wakeup = getattr(signal, "set_wakeup_fd", None)
            if sys.platform == "win32" and orig_wakeup:
                asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
                signal.set_wakeup_fd = lambda fd: -1
            loop = asyncio.new_event_loop()
            asyncio.set_event_loop(loop)
            try:
                return loop.run_until_complete(mgr.restore([backup_id]))
            finally:
                with contextlib.suppress(ValueError):
                    loop.close()
                if sys.platform == "win32" and orig_wakeup:
                    signal.set_wakeup_fd = orig_wakeup

        with ThreadPoolExecutor(max_workers=1) as pool:
            result = pool.submit(_run_restore).result()
        self._send_json({"status": "restored", **result})

    def _handle_settings(self) -> None:
        body = self._read_body()
        # Provider can be updated at any tier; schedule requires elevated tier
        if "schedule" in body and not self.panel.config.is_feature_allowed(
            "scheduled", self.panel.user_tier
        ):
            self._send_json({"error": "Tier too low for schedule settings"}, 403)
            return
        from vastian.storage.data_root import get_config_dir

        settings_path = get_config_dir(self.panel.project_root) / "backup" / "settings.yaml"
        settings_path.parent.mkdir(parents=True, exist_ok=True)
        if settings_path.exists():
            current = yaml.safe_load(settings_path.read_text(encoding="utf-8")) or {}
        else:
            current = {}
        current.update(body)
        settings_path.write_text(yaml.dump(current, default_flow_style=False), encoding="utf-8")
        # Recreate BackupManager so it uses the new provider (fixes provider selection bug)
        if (
            "provider" in body
            or "backup_mode" in body
            or "clean_up_old_backups" in body
            or "replace_previous" in body
        ):
            self.panel.backup_manager = create_backup_manager(self.panel.project_root)
            self.panel.config = self.panel.backup_manager.config
        self._send_json({"status": "updated"})

    def _handle_config_save(self) -> None:
        body = self._read_body()
        cfg_file = body.get("file", "")
        content = body.get("content", "")
        mgr = self.panel.backup_manager
        # Snapshot before saving
        with contextlib.suppress(FileNotFoundError):
            mgr.save_config_snapshot(cfg_file)
        # Write new content if provided
        if content:
            from vastian.storage.data_root import get_config_dir

            cfg_dir = get_config_dir(self.panel.project_root)
            target = (
                cfg_dir / cfg_file.removeprefix("config/")
                if cfg_file.startswith("config/")
                else self.panel.project_root / cfg_file
            )
            target.parent.mkdir(parents=True, exist_ok=True)
            target.write_text(content, encoding="utf-8")
        self._send_json({"status": "saved"})

    def _handle_config_restore(self) -> None:
        body = self._read_body()
        cfg_file = body.get("file", "")
        snap_id = body.get("snapshot_id")
        mgr = self.panel.backup_manager
        try:
            mgr.restore_config_snapshot(cfg_file, snap_id)
            self._send_json({"status": "restored"})
        except FileNotFoundError as e:
            self._send_json({"error": str(e)}, 404)

    def _handle_config_reset(self) -> None:
        body = self._read_body()
        cfg_file = body.get("file", "")
        mgr = self.panel.backup_manager
        try:
            mgr.reset_config_to_default(cfg_file)
            self._send_json({"status": "reset"})
        except FileNotFoundError as e:
            self._send_json({"error": str(e)}, 404)

    def _handle_scan(self) -> None:
        """Scan for backupable files and return category counts."""
        try:
            system_count, user_count, total = self.panel.get_file_counts()
            self._send_json(
                {
                    "total": total,
                    "system_count": system_count,
                    "user_count": user_count,
                }
            )
        except Exception as e:
            self._send_json({"total": 0, "system_count": 0, "user_count": 0, "error": str(e)})


class BackupPanel:
    """Manages the backup panel HTTP server lifecycle."""

    def __init__(
        self,
        port: int = 4547,
        project_root: Path | None = None,
        backup_manager: BackupManager | None = None,
        user_tier: str = "open",
    ) -> None:
        self.port = port
        self.project_root = project_root or Path(__file__).resolve().parents[3]
        self.backup_manager = backup_manager or create_backup_manager(self.project_root)
        self.config = self.backup_manager.config
        self.user_tier = user_tier
        self._server: HTTPServer | None = None
        self._thread: threading.Thread | None = None

    def get_file_counts(self) -> tuple[int, int, int]:
        """Return (system_count, user_count, total) from one scan. System = state, config, *.db, agents."""
        mgr = self.backup_manager
        config = mgr.config
        all_files = mgr.scan()
        system_patterns = list(config.categories.get("system", {}).get("includes", []))
        agents_patterns = list(config.categories.get("agents", {}).get("includes", []))
        user_patterns = list(config.categories.get("user", {}).get("includes", []))

        def _matches(rel: str, patterns: list[str]) -> bool:
            for pat in patterns:
                if pat.endswith("/"):
                    prefix = pat.rstrip("/")
                    if rel == prefix or rel.startswith(prefix + "/"):
                        return True
                elif pat.startswith("*."):
                    ext = pat[1:].lower()
                    if rel.lower().endswith(ext):
                        return True
                elif rel == pat or rel.startswith(pat + "/"):
                    return True
            return False

        system_count = 0
        user_count = 0
        for f in all_files:
            rel = str(f.relative_to(mgr.root)).replace("\\", "/")
            if _matches(rel, system_patterns) or _matches(rel, agents_patterns):
                system_count += 1
            elif _matches(rel, user_patterns):
                user_count += 1
            else:
                user_count += 1
        return system_count, user_count, len(all_files)

    def render_html(self) -> str:
        """Render the backup panel HTML with live data substituted."""
        status = self.backup_manager.get_backup_status()
        html = BACKUP_PANEL_HTML
        html = html.replace("__PROVIDER__", status["provider"])
        html = html.replace("__LAST_BACKUP__", status["last_backup"] or "Never")
        try:
            system_count, user_count, total = self.get_file_counts()
            html = html.replace("__FILE_COUNT_TOTAL__", str(total))
            html = html.replace("__FILE_COUNT_SYSTEM__", str(system_count))
            html = html.replace("__FILE_COUNT_USER__", str(user_count))
        except Exception:
            html = html.replace("__FILE_COUNT_TOTAL__", "—")
            html = html.replace("__FILE_COUNT_SYSTEM__", "—")
            html = html.replace("__FILE_COUNT_USER__", "—")

        # Tier gating for schedule section
        if self.config.is_feature_allowed("scheduled", self.user_tier):
            html = html.replace("__SCHEDULE_TIER_CLASS__", "")
        else:
            html = html.replace("__SCHEDULE_TIER_CLASS__", "tier-locked")

        return html

    def start(self) -> str:
        """Start the backup panel server in a daemon thread."""
        handler = type(
            "BoundHandler",
            (BackupPanelHandler,),
            {"panel": self},
        )
        self._server = HTTPServer(("127.0.0.1", self.port), handler)
        # If port was 0, update to the actual port assigned by the OS
        self.port = self._server.server_address[1]
        self._thread = threading.Thread(
            target=self._server.serve_forever,
            daemon=True,
        )
        self._thread.start()
        url = f"http://127.0.0.1:{self.port}"
        logger.info("Backup panel started: %s", url)
        return url

    def stop(self) -> None:
        """Shut down the backup panel server."""
        if self._server:
            self._server.shutdown()
            self._server = None
            self._thread = None
            logger.info("Backup panel stopped.")


__all__ = ["BackupPanel", "BackupPanelHandler", "BACKUP_PANEL_HTML"]
