#!/usr/bin/env python3
"""Sentinel Password Vault — Program 113
Local-first encrypted password vault for SentinelOS / AEGIS.
"""
from __future__ import annotations

import argparse
import contextlib
import fcntl
import base64
import datetime as _dt
import getpass
import hashlib
import hmac
import json
import os
import re
import secrets
import shutil
import subprocess
import string
import struct
import sys
import tempfile
import time
import urllib.parse
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

try:
    from cryptography.hazmat.primitives.ciphers.aead import AESGCM
    from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
    from cryptography.hazmat.primitives import hashes
except Exception as exc:  # pragma: no cover
    print("ERROR: python3-cryptography is required for Sentinel Password Vault.", file=sys.stderr)
    print(f"Import failure: {exc}", file=sys.stderr)
    sys.exit(2)

VERSION = "1.0.3"
PROGRAM_ID = "113"
APP_NAME = "Sentinel Password Vault"
CLI_NAME = "sentinel-password-vault"
SCHEMA = "sentinel-password-vault.schema.v1.0.2"
VAULT_CONTAINER_SCHEMA = "sentinel-password-vault.container.v1"
VAULT_RECORD_SCHEMA = "sentinel-password-vault.records.v1"
KDF_ITERATIONS = 390_000
CLIPBOARD_CLEAR_SECONDS = 30
AUTO_LOCK_SECONDS = 10 * 60
TOTP_DEFAULT_DIGITS = 6
TOTP_DEFAULT_PERIOD = 30
TOTP_DEFAULT_ALGORITHM = "SHA1"

AEGIS_MANIFEST: Dict[str, Any] = {
    "schema": SCHEMA,
    "program_id": PROGRAM_ID,
    "name": APP_NAME,
    "version": VERSION,
    "command": CLI_NAME,
    "category": "SentinelOS Desktop Suite / Security",
    "local_first": True,
    "network_required": False,
    "telemetry": False,
    "cloud_sync": False,
    "root_policy": "Refuses vault note/secret actions as root. System install is /usr only; user data belongs to the normal desktop user.",
    "data_paths": {
        "vault": "~/.local/share/sentinel-password-vault/vault.spv",
        "audit_log": "~/.local/state/sentinel-password-vault/audit.log",
        "config": "~/.config/sentinel-password-vault/config.json",
        "backups": "~/.local/share/sentinel-password-vault/backups",
        "aegis_vault_backup_default": "~/SentinelOS/Vault/Sentinel_Exports/Sentinel_Password_Vault"
    },
    "security": {
        "encryption": "AES-256-GCM",
        "kdf": "PBKDF2-HMAC-SHA256",
        "iterations": KDF_ITERATIONS,
        "salt_bytes": 16,
        "nonce_bytes": 12,
        "master_password_stored": False,
        "plaintext_secret_audit_logging": False,
        "clipboard_auto_clear_seconds": CLIPBOARD_CLEAR_SECONDS,
        "gui_auto_lock_seconds": AUTO_LOCK_SECONDS,
        "encrypted_backup_only": True,
        "master_password_change": True,
        "gui_startup_crash_fix": True,
        "gui_non_modal_unlock_screen": True,
        "gui_startup_hang_mitigation": True,
        "gui_error_log": "~/.local/state/sentinel-password-vault/gui-error.log",
        "record_categories": True,
        "record_tags": True,
        "favorite_records": True,
        "record_health_indicators": True,
        "duplicate_password_detection": "local unlocked vault only",
        "backup_restore_workflow": True,
        "metadata_export": True,
        "decrypted_json_export_requires_unlock": True,
        "json_import_with_backup_before_import": True,
        "sentinel_theme_unified_widgets": True,
        "sentinel_themed_dialogs": True,
        "sentinel_themed_comboboxes": True,
        "sentinel_theme_completion_pass": True,
        "security_dashboard": True,
        "theme_self_report": True,
        "secret_view_button": True,
        "secret_view_requires_explicit_click": True,
        "random_password_generator_dialog": True,
        "compact_gui_layout": True,
        "grouped_action_buttons": True,
        "reduced_default_window_size": True,
        "wider_compact_window": True,
        "symmetrical_action_buttons": True,
        "append_safe_save_policy": True,
        "right_click_record_details": True,
        "random_password_generator_options": ["length", "lowercase", "uppercase", "digits", "symbols", "avoid_ambiguous"],
        "totp_authenticator_foundation": True,
        "totp_global_authenticator_panel": True,
        "totp_record_checkbox_registration": True,
        "totp_qr_canvas_renderer": True,
        "totp_qr_manual_render_only": True,
        "record_double_click_details_fix": True,
        "totp_non_technical_setup_wizard": True,
        "totp_paste_full_instructions_parser": True,
        "aegis_vault_backup_menu": True,
        "browser_toolbar_2fa_spec": True,
        "totp_setup_persistence_fix": True,
        "totp_qr_canvas_renderer": True,
        "totp_qr_manual_render_only": True,
        "record_list_wider_layout": True,
        "record_double_click_details_fix": True,
        "totp_non_technical_setup_wizard": True,
        "totp_paste_full_instructions_parser": True,
        "aegis_vault_backup_menu": True,
        "browser_toolbar_2fa_spec": True,
        "totp_qr_display": "optional QR image with otpauth URI fallback",
        "totp_setup_wizard": "non-technical setup key / otpauth paste flow with Save and Show Code",
        "top_menu_action_consolidation": True,
        "totp_scope": "standard authenticator-app TOTP only",
        "totp_supported_inputs": ["manual_secret", "otpauth_uri", "qr_payload_text"],
        "totp_supported_digits": [6, 8],
        "totp_supported_periods": [30, 60],
        "totp_supported_algorithms": ["SHA1", "SHA256", "SHA512"],
        "totp_plaintext_secret_audit_logging": False,
        "totp_code_requires_explicit_user_action": True,
        "main_gui_buttons_reduced": True,
        "aegis_vault_encrypted_backup": True,
        "aegis_vault_restore": True,
        "browser_bridge_foundation": True,
        "browser_toolbar_2fa_button_spec": True,
        "browser_2fa_quick_view_setup_spec": True,
        "browser_2fa_popup_provider": True,
        "browser_2fa_provider_cli": True,
        "browser_2fa_popup_cli": True,
        "browser_origin_matcher": True,
        "browser_user_approved_totp_code_endpoint": True,
        "browser_save_login_provider": True,
        "browser_save_candidate_preview": True,
        "browser_user_approved_save_endpoint": True,
        "browser_save_duplicate_match_provider": True,
        "browser_update_login_provider": True,
        "browser_password_fill_popup_provider": True,
        "browser_password_fill_one_shot_pipe": True,
        "browser_save_popup_provider": True,
        "browser_save_secret_transport_stdin_pipe": True,
        "browser_secret_values_never_in_command_line": True,
        "browser_save_provider_rc_hardening": True,
        "browser_save_one_action_commit": True,
        "browser_cross_process_file_lock": True,
        "browser_saved_record_live_refresh": True,
        "release_candidate_hardening": True,
        "vault_data_integrity_check": True,
        "backup_freshness_warning": True,
        "browser_provider_compatibility_report": True,
        "totp_health_report": True,
        "final_install_shadow_checks": True,
        "final_root_user_safety_checks": True,
        "browser_2fa_popup_receiver_cleanup": True,
        "browser_2fa_popup_setup_button": True,
        "browser_2fa_popup_open_vault_button": True,
        "browser_2fa_popup_visible_button_rows": True,
        "browser_2fa_popup_widened": True,
        "browser_2fa_popup_status_required_by_compatibility": True,
        "stable_lock_build": True,
        "v1_baseline_locked": True,
        "post_v1_policy": "focused bug fixes, browser integration refinements, security hardening, and compatibility patches only"
    },
    "permissions": {
        "read_vault_metadata": "requires local master password unlock",
        "read_secret_values": "requires local master password unlock and explicit user action such as View Secret, Copy Password, or CLI --reveal",
        "write_vault": "requires local master password unlock",
        "aegis_safe_actions": ["manifest", "status", "self_check", "review_status", "list_metadata", "search_metadata", "category_report", "password_health", "duplicate_report", "favorite_report", "backup_list", "restore_preview", "aegis_backup_status", "aegis_backup_list", "aegis_backup_create", "aegis_restore_preview", "browser_bridge_status", "browser_toolbar_spec", "browser_2fa_quick_view_spec", "browser_2fa_quick_view", "browser_2fa_popup_status", "browser_2fa_popup", "browser_save_preview", "browser_save_matches", "release_candidate_report", "stable_lock_report", "integrity_check", "backup_freshness", "browser_provider_compatibility", "totp_health", "generate_password", "audit_tail", "security_dashboard", "theme_status", "random_password_options", "totp_options", "totp_report", "totp_register_options"]
    },
    "accessibility": {"keyboard_shortcuts": ["Ctrl+F search", "Ctrl+N new record", "Ctrl+S save record", "Ctrl+L lock vault", "Ctrl+Q close"], "screen_reader_friendly_labels": True, "visible_status_text": True, "no_color_only_security_status": True, "tab_order_polish": True, "shortcut_hints_visible": True, "theme_consistent_controls": True, "top_menu_action_consolidation": True, "themed_buttons": True, "themed_checkboxes": True, "themed_dropdowns": True, "themed_popups": True, "themed_listboxes": True, "themed_dialog_buttons": True, "theme_status_action": True, "view_secret_button": True, "random_password_generator_dialog": True, "right_click_record_details": True, "append_safe_save_policy": True, "compact_gui_layout": True, "grouped_action_buttons": True, "symmetrical_action_buttons": True, "wider_compact_window": True, "top_menu_action_consolidation": True, "totp_global_authenticator_panel": True, "totp_record_checkbox_registration": True, "totp_non_technical_setup_wizard": True},
    "actions": [
        {"name": "manifest", "safe": True, "secrets": False},
        {"name": "status", "safe": True, "secrets": False},
        {"name": "self_check", "safe": True, "secrets": False},
        {"name": "review_status", "safe": True, "secrets": False},
        {"name": "category_report", "safe": True, "secrets": False, "requires_unlock": True},
        {"name": "password_health", "safe": True, "secrets": False, "requires_unlock": True},
        {"name": "duplicate_report", "safe": True, "secrets": False, "requires_unlock": True},
        {"name": "favorite_report", "safe": True, "secrets": False, "requires_unlock": True},
        {"name": "backup_list", "safe": True, "secrets": False},
        {"name": "restore_preview", "safe": True, "secrets": False},
        {"name": "aegis_backup_status", "safe": True, "secrets": False},
        {"name": "aegis_backup_list", "safe": True, "secrets": False},
        {"name": "aegis_backup_create", "safe": True, "secrets": False},
        {"name": "aegis_restore_preview", "safe": True, "secrets": False},
        {"name": "browser_bridge_status", "safe": True, "secrets": False},
        {"name": "browser_toolbar_spec", "safe": True, "secrets": False},
        {"name": "browser_2fa_quick_view_spec", "safe": True, "secrets": False},
        {"name": "browser_2fa_quick_view", "safe": True, "secrets": False, "requires_unlock": True},
        {"name": "browser_2fa_popup_status", "safe": True, "secrets": False},
        {"name": "browser_2fa_popup", "safe": False, "secrets": False, "requires_unlock": True, "restricted_popup": True},
        {"name": "browser_save_preview", "safe": True, "secrets": False},
        {"name": "browser_save_login", "safe": False, "secrets": True, "requires_unlock": True, "requires_user_approval": True},
        {"name": "browser_password_popup_status", "safe": True, "secrets": False},
        {"name": "browser_password_popup", "safe": False, "secrets": True, "requires_unlock": True, "requires_user_approval": True, "restricted_popup": True},
        {"name": "browser_save_popup_status", "safe": True, "secrets": False},
        {"name": "browser_save_popup", "safe": False, "secrets": True, "requires_unlock": True, "requires_user_approval": True, "restricted_popup": True},
        {"name": "browser_save_matches", "safe": True, "secrets": False, "requires_unlock": True},
        {"name": "browser_update_login", "safe": False, "secrets": True, "requires_unlock": True, "requires_user_approval": True},
        {"name": "browser_2fa_code", "safe": False, "secrets": True, "requires_unlock": True, "requires_user_approval": True},
        {"name": "list_metadata", "safe": True, "secrets": False, "requires_unlock": True},
        {"name": "search_metadata", "safe": True, "secrets": False, "requires_unlock": True},
        {"name": "generate_password", "safe": True, "secrets": False},
        {"name": "audit_tail", "safe": True, "secrets": False},
        {"name": "security_dashboard", "safe": True, "secrets": False, "requires_unlock": True},
        {"name": "theme_status", "safe": True, "secrets": False},
        {"name": "random_password_options", "safe": True, "secrets": False},
        {"name": "totp_options", "safe": True, "secrets": False},
        {"name": "totp_report", "safe": True, "secrets": False, "requires_unlock": True},
        {"name": "totp_register_options", "safe": True, "secrets": False},
        {"name": "integrity_check", "safe": True, "secrets": False, "requires_unlock": True},
        {"name": "backup_freshness", "safe": True, "secrets": False},
        {"name": "browser_provider_compatibility", "safe": True, "secrets": False},
        {"name": "totp_health", "safe": True, "secrets": False, "requires_unlock": True},
        {"name": "release_candidate_report", "safe": True, "secrets": False},
        {"name": "stable_lock_report", "safe": True, "secrets": False},
        {"name": "totp_code", "safe": False, "secrets": True, "requires_unlock": True, "requires_user_approval": True},
    ]
}


def now_iso() -> str:
    return _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds")


def b64e(data: bytes) -> str:
    return base64.b64encode(data).decode("ascii")


def b64d(text: str) -> bytes:
    return base64.b64decode(text.encode("ascii"))


def home() -> Path:
    return Path.home()


def data_dir() -> Path:
    return home() / ".local" / "share" / "sentinel-password-vault"


def backup_dir() -> Path:
    return data_dir() / "backups"

def aegis_vault_root() -> Path:
    raw = os.environ.get("AEGIS_VAULT_PATH") or os.environ.get("SENTINEL_VAULT_PATH")
    if raw:
        return Path(raw).expanduser()
    return home() / "SentinelOS" / "Vault"

def aegis_vault_backup_dir() -> Path:
    return aegis_vault_root() / "Sentinel_Exports" / "Sentinel_Password_Vault"


def state_dir() -> Path:
    return home() / ".local" / "state" / "sentinel-password-vault"


def config_dir() -> Path:
    return home() / ".config" / "sentinel-password-vault"


def vault_path() -> Path:
    return data_dir() / "vault.spv"


def vault_lock_path() -> Path:
    return data_dir() / ".vault.spv.lock"


def vault_file_stamp(path: Optional[Path] = None) -> Tuple[int, int]:
    p = path or vault_path()
    try:
        st = p.stat()
        return (int(st.st_mtime_ns), int(st.st_size))
    except FileNotFoundError:
        return (0, 0)


@contextlib.contextmanager
def vault_file_lock() -> Any:
    """Serialize read-modify-write operations across Vault and Browser processes."""
    ensure_dirs()
    lock_path = vault_lock_path()
    fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600)
    try:
        os.chmod(lock_path, 0o600)
        with os.fdopen(fd, "r+") as handle:
            fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
            try:
                yield
            finally:
                fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
    except Exception:
        try:
            os.close(fd)
        except OSError:
            pass
        raise


def audit_path() -> Path:
    return state_dir() / "audit.log"


def ensure_dirs() -> None:
    data_dir().mkdir(parents=True, exist_ok=True, mode=0o700)
    backup_dir().mkdir(parents=True, exist_ok=True, mode=0o700)
    state_dir().mkdir(parents=True, exist_ok=True, mode=0o700)
    config_dir().mkdir(parents=True, exist_ok=True, mode=0o700)


def audit(action: str, detail: str = "") -> None:
    try:
        ensure_dirs()
        safe_detail = detail.replace("\n", " ")[:500]
        with audit_path().open("a", encoding="utf-8") as f:
            f.write(json.dumps({"ts": now_iso(), "action": action, "detail": safe_detail}, sort_keys=True) + "\n")
    except Exception:
        pass


def is_root() -> bool:
    return hasattr(os, "geteuid") and os.geteuid() == 0


ROOT_ALLOWED_FLAGS = {
    "--version", "--manifest", "--status", "--self-check", "--theme-report", "--generate-password", "--write-manifest", "--json-action", "--aegis-action", "--totp-options", "--browser-bridge-status", "--browser-toolbar-spec", "--browser-2fa-popup-status", "--browser-password-popup-status", "--browser-save-popup-status", "--browser-2fa-quick-view", "--browser-save-preview", "--list-aegis-backups", "--backup-freshness", "--browser-provider-compatibility", "--release-candidate-report", "--stable-lock-report"
}


def enforce_not_root_for_vault_actions(argv: List[str]) -> None:
    if os.environ.get("SENTINEL_PASSWORD_VAULT_ALLOW_ROOT") == "1":
        return
    if not is_root():
        return
    # Allow harmless introspection. Refuse GUI or vault operations.
    if len(argv) == 0:
        print("ERROR: Do not run Sentinel Password Vault as root. Run it as your normal desktop user.", file=sys.stderr)
        print("System install uses sudo; vault data must not live under /root.", file=sys.stderr)
        sys.exit(13)
    if argv[0] in ROOT_ALLOWED_FLAGS:
        return
    print("ERROR: Refusing password-vault secret/data action as root.", file=sys.stderr)
    print("Run the app as your normal user, not with sudo.", file=sys.stderr)
    sys.exit(13)


def derive_key(master_password: str, salt: bytes, iterations: int = KDF_ITERATIONS) -> bytes:
    kdf = PBKDF2HMAC(
        algorithm=hashes.SHA256(),
        length=32,
        salt=salt,
        iterations=iterations,
    )
    return kdf.derive(master_password.encode("utf-8"))


def empty_vault() -> Dict[str, Any]:
    ts = now_iso()
    return {"schema": VAULT_RECORD_SCHEMA, "created_at": ts, "updated_at": ts, "records": []}


def encrypt_vault(plain: Dict[str, Any], master_password: str, existing: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
    salt = b64d(existing["salt"]) if existing and existing.get("salt") else secrets.token_bytes(16)
    iterations = int(existing.get("iterations", KDF_ITERATIONS)) if existing else KDF_ITERATIONS
    key = derive_key(master_password, salt, iterations)
    nonce = secrets.token_bytes(12)
    payload = json.dumps(plain, sort_keys=True, separators=(",", ":")).encode("utf-8")
    ciphertext = AESGCM(key).encrypt(nonce, payload, None)
    ts = now_iso()
    return {
        "schema": VAULT_CONTAINER_SCHEMA,
        "app": APP_NAME,
        "version": VERSION,
        "kdf": "PBKDF2-HMAC-SHA256",
        "cipher": "AES-256-GCM",
        "iterations": iterations,
        "salt": b64e(salt),
        "nonce": b64e(nonce),
        "ciphertext": b64e(ciphertext),
        "created_at": existing.get("created_at", ts) if existing else ts,
        "updated_at": ts,
    }


def decrypt_vault(container: Dict[str, Any], master_password: str) -> Dict[str, Any]:
    if container.get("schema") != VAULT_CONTAINER_SCHEMA:
        raise ValueError("Unsupported or invalid Sentinel Password Vault file schema")
    salt = b64d(container["salt"])
    nonce = b64d(container["nonce"])
    ciphertext = b64d(container["ciphertext"])
    iterations = int(container.get("iterations", KDF_ITERATIONS))
    key = derive_key(master_password, salt, iterations)
    payload = AESGCM(key).decrypt(nonce, ciphertext, None)
    plain = json.loads(payload.decode("utf-8"))
    if plain.get("schema") != VAULT_RECORD_SCHEMA:
        raise ValueError("Unsupported internal vault record schema")
    return plain


def load_container(path: Optional[Path] = None) -> Dict[str, Any]:
    p = path or vault_path()
    with p.open("r", encoding="utf-8") as f:
        return json.load(f)


def save_container(container: Dict[str, Any], path: Optional[Path] = None) -> None:
    ensure_dirs()
    p = path or vault_path()
    tmp = p.with_suffix(".tmp")
    with tmp.open("w", encoding="utf-8") as f:
        json.dump(container, f, indent=2, sort_keys=True)
        f.write("\n")
        f.flush()
        os.fsync(f.fileno())
    os.chmod(tmp, 0o600)
    tmp.replace(p)
    os.chmod(p, 0o600)
    try:
        dir_fd = os.open(str(p.parent), os.O_RDONLY)
        try:
            os.fsync(dir_fd)
        finally:
            os.close(dir_fd)
    except OSError:
        pass


def create_encrypted_backup(label: str = "manual") -> Path:
    ensure_dirs()
    src = vault_path()
    if not src.exists():
        raise FileNotFoundError("No Sentinel Password Vault exists yet.")
    safe_label = "".join(ch for ch in label.lower().replace(" ", "-") if ch.isalnum() or ch in "-_")[:40] or "manual"
    stamp = _dt.datetime.now().strftime("%Y%m%d-%H%M%S")
    dest = backup_dir() / f"vault-{stamp}-{safe_label}.spv"
    shutil.copy2(src, dest)
    os.chmod(dest, 0o600)
    audit("encrypted_backup", f"path={dest}")
    return dest


def audit_tail_lines(limit: int = 20) -> List[Dict[str, Any]]:
    limit = max(1, min(200, int(limit)))
    p = audit_path()
    if not p.exists():
        return []
    lines = p.read_text(encoding="utf-8", errors="replace").splitlines()[-limit:]
    out: List[Dict[str, Any]] = []
    for line in lines:
        try:
            out.append(json.loads(line))
        except Exception:
            out.append({"raw": line})
    return out



def list_encrypted_backups() -> List[Dict[str, Any]]:
    ensure_dirs()
    out: List[Dict[str, Any]] = []
    for p in sorted(backup_dir().glob("vault-*.spv"), key=lambda x: x.stat().st_mtime if x.exists() else 0, reverse=True):
        try:
            st = p.stat()
            out.append({
                "name": p.name,
                "path": str(p),
                "size_bytes": st.st_size,
                "modified_at": _dt.datetime.fromtimestamp(st.st_mtime, _dt.timezone.utc).isoformat(timespec="seconds"),
            })
        except Exception:
            continue
    return out


def resolve_backup_path(identifier: str) -> Path:
    ident = str(identifier or "").strip()
    if not ident:
        raise ValueError("Backup name/path is required")
    candidate = Path(ident).expanduser()
    if candidate.exists():
        return candidate
    candidate = backup_dir() / ident
    if candidate.exists():
        return candidate
    # Accept a partial unique filename match for CLI convenience.
    matches = [Path(row["path"]) for row in list_encrypted_backups() if ident.lower() in row["name"].lower()]
    if len(matches) == 1:
        return matches[0]
    if len(matches) > 1:
        raise ValueError("Multiple backups matched. Use the exact backup filename.")
    raise FileNotFoundError(f"Backup not found: {identifier}")


def restore_encrypted_backup(identifier: str, backup_current: bool = True) -> Dict[str, Any]:
    src = resolve_backup_path(identifier)
    if not src.exists():
        raise FileNotFoundError(str(src))
    created_backup = None
    if backup_current and vault_path().exists():
        created_backup = create_encrypted_backup("before-restore")
    ensure_dirs()
    shutil.copy2(src, vault_path())
    os.chmod(vault_path(), 0o600)
    audit("restore_backup", f"source={src} backup_current={created_backup}")
    return {"restored_from": str(src), "backup_before_restore": str(created_backup) if created_backup else None, "vault_path": str(vault_path())}



def create_aegis_vault_backup(label: str = "manual") -> Dict[str, Any]:
    """Copy the encrypted vault file into the AEGIS Vault export area without decrypting secrets."""
    if not vault_path().exists():
        raise FileNotFoundError(f"Vault not found: {vault_path()}")
    dest_dir = aegis_vault_backup_dir()
    dest_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
    stamp = _dt.datetime.now().strftime("%Y%m%d-%H%M%S")
    safe_label = re.sub(r"[^A-Za-z0-9_.-]+", "-", label or "manual").strip("-") or "manual"
    dest = dest_dir / f"sentinel-password-vault-{stamp}-{safe_label}.spv"
    shutil.copy2(vault_path(), dest)
    try:
        dest.chmod(0o600)
    except Exception:
        pass
    manifest = {
        "schema": "sentinel-password-vault.aegis-backup.v1",
        "program_id": PROGRAM_ID,
        "program": APP_NAME,
        "version": VERSION,
        "created_at": now_iso(),
        "encrypted_vault_file": dest.name,
        "contains_plaintext_secrets": False,
        "includes_password_records": True,
        "includes_totp_records": True,
        "restore_command": f"{CLI_NAME} --restore-aegis-backup {dest.name} --yes",
    }
    mpath = dest.with_suffix(dest.suffix + ".manifest.json")
    mpath.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    try:
        mpath.chmod(0o600)
    except Exception:
        pass
    audit("aegis_vault_backup", f"path={dest}")
    return {"ok": True, "backup": str(dest), "manifest": str(mpath), "aegis_vault_backup_dir": str(dest_dir), "encrypted_only": True}

def list_aegis_vault_backups() -> List[Dict[str, Any]]:
    d = aegis_vault_backup_dir()
    rows: List[Dict[str, Any]] = []
    if not d.exists():
        return rows
    for p in sorted(d.glob("sentinel-password-vault-*.spv"), key=lambda x: x.stat().st_mtime if x.exists() else 0, reverse=True):
        st = p.stat()
        manifest = p.with_suffix(p.suffix + ".manifest.json")
        rows.append({"name": p.name, "path": str(p), "size_bytes": st.st_size, "modified_at": _dt.datetime.fromtimestamp(st.st_mtime).isoformat(timespec="seconds"), "manifest": str(manifest) if manifest.exists() else None})
    return rows

def resolve_aegis_backup_path(identifier: str) -> Path:
    ident = (identifier or "").strip()
    if not ident:
        raise ValueError("Backup name/path is required")
    direct = Path(ident).expanduser()
    if direct.exists():
        return direct
    candidate = aegis_vault_backup_dir() / ident
    if candidate.exists():
        return candidate
    matches = [Path(row["path"]) for row in list_aegis_vault_backups() if ident.lower() in row["name"].lower()]
    if len(matches) == 1:
        return matches[0]
    if len(matches) > 1:
        raise ValueError("Multiple AEGIS Vault backups matched. Use the exact filename.")
    raise FileNotFoundError(f"No AEGIS Vault backup matched: {identifier}")

def restore_aegis_vault_backup(identifier: str, backup_current: bool = True) -> Dict[str, Any]:
    src = resolve_aegis_backup_path(identifier)
    if src.suffix != ".spv":
        raise ValueError("Only encrypted .spv vault backups can be restored")
    ensure_dirs()
    created_backup = None
    if backup_current and vault_path().exists():
        created_backup = create_encrypted_backup("before-aegis-restore")
    shutil.copy2(src, vault_path())
    try:
        vault_path().chmod(0o600)
    except Exception:
        pass
    audit("aegis_vault_restore", f"source={src} backup_current={created_backup}")
    return {"ok": True, "restored_from": str(src), "backup_before_restore": str(created_backup) if created_backup else None, "vault_path": str(vault_path())}

def aegis_backup_status() -> Dict[str, Any]:
    rows = list_aegis_vault_backups()
    return {"ok": True, "aegis_vault_root": str(aegis_vault_root()), "backup_dir": str(aegis_vault_backup_dir()), "backup_count": len(rows), "latest_backup": rows[0] if rows else None, "encrypted_only": True, "includes_totp_records": True}

def browser_bridge_status() -> Dict[str, Any]:
    return {
        "ok": True,
        "program_id": PROGRAM_ID,
        "provider": APP_NAME,
        "version": VERSION,
        "browser_program_id": "110",
        "browser_program": "Sentinel Browser",
        "bridge_stage": "provider_ready",
        "save_login_provider_ready": True,
        "save_login_match_provider_ready": True,
        "update_login_provider_ready": True,
        "browser_should_prompt_to_save_new_logins": True,
        "browser_should_offer_update_existing_logins": True,
        "network_required": False,
        "plaintext_secret_default_access": False,
        "requires_user_unlock": True,
        "requires_explicit_user_approval_for_secret_or_totp_code": True,
        "toolbar_button": {
            "label": "2FA",
            "icon": "sentinel-password-vault",
            "primary_action": "open_restricted_password_vault_2fa_popup",
            "secondary_action": "register_or_setup_2fa",
            "expected_ui": "small restricted Vault-owned 2FA popup from browser toolbar"
        },
        "browser_2fa_popup_receiver": True,
        "browser_2fa_popup_status_receiver": True,
        "browser_2fa_popup_command": "sentinel-password-vault-browser-2fa-popup --browser-origin $CURRENT_URL",
        "browser_2fa_popup_status_command": "sentinel-password-vault-browser-2fa-status",
        "supported_browser_actions": ["browser_toolbar_spec", "browser_2fa_quick_view_spec", "browser_2fa_popup_status", "browser_2fa_popup", "browser_2fa_quick_view", "browser_2fa_code", "browser_save_preview", "browser_save_login", "browser_save_matches", "browser_update_login", "browser_provider_compatibility", "totp_report", "totp_register_options"],
        "audit_policy": "metadata only; no plaintext password, TOTP secret, or TOTP code logging"
    }

def browser_toolbar_spec() -> Dict[str, Any]:
    d = browser_bridge_status()
    d.update({
        "toolbar_spec": {
            "button_text": "2FA",
            "tooltip": "Sentinel Password Vault 2FA",
            "popup_title": "Sentinel 2FA",
            "popup_sections": ["Current site matches", "All 2FA entries", "Setup new 2FA", "Open full vault"],
            "quick_view_rules": [
                "Do not show codes until vault is unlocked",
                "Do not return codes to browser without explicit click",
                "Warn if current site domain does not match selected issuer/account metadata",
                "Copy code must auto-clear clipboard"
            ],
            "save_login_rules": [
                "Browser detects username/password candidates",
                "Browser asks: Save to Sentinel Password Vault?",
                "Vault unlock and explicit approval required before save",
                "Before saving, browser may ask the vault for existing metadata matches",
                "If an existing record matches, browser should offer Update Existing or Save New",
                "Browser must discard plaintext password after save/update",
                "Password is not echoed in vault provider output"
            ]
        }
    })
    return d

def browser_2fa_quick_view_spec() -> Dict[str, Any]:
    return {
        "ok": True,
        "name": "Sentinel Browser 2FA toolbar quick view",
        "purpose": "Browser toolbar button opens a small 2FA popup and setup path backed by Sentinel Password Vault",
        "button": "2FA",
        "quick_view_fields": ["issuer", "account", "current_code_after_user_click", "countdown", "copy_code", "setup_new_2fa"],
        "setup_inputs": ["setup_key_from_website", "account_label", "website_or_issuer", "otpauth_uri_optional"],
        "security": {"vault_unlock_required": True, "user_click_required_for_code": True, "plaintext_secret_logged": False, "plaintext_code_logged": False, "metadata_only_aegis_default": True}
    }



def browser_2fa_popup_status() -> Dict[str, Any]:
    """Status contract consumed by Sentinel Browser before launching the mini popup."""
    return {
        "ok": True,
        "provider": APP_NAME,
        "program_id": PROGRAM_ID,
        "version": VERSION,
        "bridge_stage": "browser_2fa_popup_receiver_ready",
        "command_contract": 'sentinel-password-vault --browser-2fa-popup --browser-origin "$CURRENT_URL"',
        "wrapper_commands": {
            "popup": "sentinel-password-vault-browser-2fa-popup --browser-origin $CURRENT_URL",
            "status": "sentinel-password-vault-browser-2fa-status"
        },
        "popup_receiver": True,
        "restricted_popup": True,
        "opens_full_vault_by_default": False,
        "browser_origin_supported": True,
        "network_required": False,
        "vault_unlock_required": True,
        "user_click_required_for_code": True,
        "browser_receives_totp_secret": False,
        "browser_generates_totp_code": False,
        "plaintext_secret_logged": False,
        "plaintext_code_logged": False,
        "metadata_only_audit": True,
        "fallback_full_vault_launch": True,
        "ui": {
            "title": "Sentinel Vault 2FA",
            "surface": "small_popup_window",
            "button_layout": "two_visible_symmetrical_rows",
            "default_geometry": "680x430",
            "minimum_geometry": "620x390",
            "shows_full_vault": False,
            "sections": ["current_site", "matched_2fa_entries", "show_code", "copy_code", "setup_2fa", "open_full_vault", "refresh", "close"],
        },
    }


def _tk_colors() -> Dict[str, str]:
    return {"bg": "#0B0D10", "panel": "#11161C", "panel2": "#1C252E", "text": "#E8E6DF", "muted": "#9AA4AD", "gold": "#E19B00", "cyan": "#03C8FF", "red": "#FF5C5C", "green": "#7ED957"}


def browser_2fa_popup_gui(origin: str = "") -> int:
    """Open a restricted Vault-owned 2FA popup for Sentinel Browser.

    This popup belongs to Sentinel Password Vault. It never returns TOTP secrets
    to the browser and never opens the full Vault by default.
    """
    if os.environ.get("SENTINEL_PASSWORD_VAULT_BROWSER_2FA_POPUP_TEST") == "1":
        print(json.dumps({"ok": True, "test_mode": True, "origin": normalize_browser_origin(origin), **browser_2fa_popup_status()}, indent=2, sort_keys=True))
        return 0
    try:
        import tkinter as tk
        from tkinter import ttk, messagebox
    except Exception as exc:
        print(f"ERROR: Tkinter is required for the browser 2FA popup: {exc}", file=sys.stderr)
        return 2

    colors = _tk_colors()
    origin_info = normalize_browser_origin(origin)
    root = tk.Tk()
    root.title("Sentinel Vault 2FA")
    root.geometry("680x430")
    root.minsize(620, 390)
    try:
        root.configure(bg=colors["bg"])
    except Exception:
        pass

    state: Dict[str, Any] = {"plain": None, "records": [], "selected": "", "last_code": "", "clear_token": None}

    def styled_label(parent, text, **kw):
        opts = {"bg": colors["bg"], "fg": colors["text"], "anchor": "w", "justify": "left"}
        opts.update(kw)
        return tk.Label(parent, text=text, **opts)

    main = tk.Frame(root, bg=colors["bg"], padx=14, pady=12)
    main.pack(fill="both", expand=True)

    styled_label(main, "Sentinel Vault 2FA", font=("Sans", 13, "bold"), fg=colors["gold"]).pack(fill="x")
    styled_label(main, f"Site: {origin_info.get('host') or origin_info.get('raw') or 'current browser page'}", fg=colors["muted"]).pack(fill="x", pady=(2, 10))

    status_var = tk.StringVar(value="Unlock the Vault to view matching 2FA entries.")
    code_var = tk.StringVar(value="Code hidden")
    remain_var = tk.StringVar(value="")
    selected_var = tk.StringVar(value="")

    unlock_frame = tk.Frame(main, bg=colors["panel"], padx=10, pady=10)
    unlock_frame.pack(fill="x", pady=(0, 10))
    tk.Label(unlock_frame, text="Vault master password", bg=colors["panel"], fg=colors["text"]).pack(anchor="w")
    pw_entry = tk.Entry(unlock_frame, show="•", bg=colors["panel2"], fg=colors["text"], insertbackground=colors["cyan"], relief="flat")
    pw_entry.pack(fill="x", pady=(4, 6))

    list_frame = tk.Frame(main, bg=colors["bg"])
    list_frame.pack(fill="x", pady=(0, 8))
    tk.Label(list_frame, text="2FA entry", bg=colors["bg"], fg=colors["text"]).pack(anchor="w")
    entry_combo = ttk.Combobox(list_frame, textvariable=selected_var, state="readonly", values=[])
    entry_combo.pack(fill="x")

    code_label = tk.Label(main, textvariable=code_var, bg=colors["panel"], fg=colors["gold"], font=("Monospace", 22, "bold"), padx=8, pady=8)
    code_label.pack(fill="x", pady=(0, 4))
    tk.Label(main, textvariable=remain_var, bg=colors["bg"], fg=colors["muted"]).pack(fill="x")
    tk.Label(main, textvariable=status_var, bg=colors["bg"], fg=colors["muted"], wraplength=640, justify="left").pack(fill="x", pady=(4, 8))

    def display_name(meta: Dict[str, Any]) -> str:
        issuer = meta.get("issuer") or meta.get("title") or "2FA"
        account = meta.get("account") or meta.get("username") or ""
        score = meta.get("match_score", 0)
        suffix = f" [{score}]" if score else ""
        return (f"{issuer} — {account}" if account else str(issuer)) + suffix

    def selected_record_id() -> str:
        label = selected_var.get()
        for item in state.get("records", []):
            if item.get("display") == label:
                return item.get("id", "")
        return ""

    def schedule_clipboard_clear(value: str) -> None:
        token = secrets.token_hex(8)
        state["clear_token"] = token
        def clear() -> None:
            try:
                if state.get("clear_token") == token and root.clipboard_get() == value:
                    root.clipboard_clear(); root.update()
                    status_var.set("Clipboard cleared.")
                    audit("browser_2fa_popup_clipboard_clear", "cleared")
            except Exception:
                pass
        root.after(CLIPBOARD_CLEAR_SECONDS * 1000, clear)

    def refresh_countdown() -> None:
        rid = selected_record_id()
        plain = state.get("plain")
        if plain and rid and state.get("last_code"):
            try:
                rec = resolve_record(plain, rid)
                cfg = normalize_totp_config(rec)
                remain_var.set(f"Expires in {totp_remaining_seconds(cfg.get('period', TOTP_DEFAULT_PERIOD))}s")
            except Exception:
                remain_var.set("")
        root.after(1000, refresh_countdown)

    def unlock_vault() -> None:
        pw = pw_entry.get()
        if not pw:
            status_var.set("Enter the Vault master password.")
            return
        try:
            container = load_container(vault_path())
            plain = decrypt_vault(container, pw)
            state["plain"] = plain
            qv = browser_2fa_quick_view(plain, origin)
            rows = qv.get("matches") or qv.get("all_totp_entries") or []
            normalized = []
            for row in rows:
                row = dict(row)
                row["display"] = display_name(row)
                normalized.append(row)
            state["records"] = normalized
            entry_combo["values"] = [r["display"] for r in normalized]
            if normalized:
                selected_var.set(normalized[0]["display"])
                status_var.set(f"{qv.get('match_count', 0)} site match(es), {qv.get('entry_count', len(normalized))} total 2FA entrie(s). Click Show or Copy when ready.")
            else:
                selected_var.set("")
                status_var.set("No 2FA entries found in the Vault.")
            audit("browser_2fa_popup_unlock", f"origin={origin_info.get('host','')} matches={qv.get('match_count',0)}")
        except Exception as exc:
            audit("browser_2fa_popup_unlock_failed", type(exc).__name__)
            status_var.set("Vault unlock failed. Check the master password.")
            messagebox.showerror("Sentinel Vault 2FA", "Vault unlock failed. Check the master password.")

    def show_code(copy: bool = False) -> None:
        plain = state.get("plain")
        rid = selected_record_id()
        if not plain:
            status_var.set("Unlock the Vault first.")
            return
        if not rid:
            status_var.set("Select a 2FA entry first.")
            return
        try:
            out = browser_2fa_code_payload(plain, rid, origin)
            code = out.get("code", "")
            state["last_code"] = code
            code_var.set(code)
            remain_var.set(f"Expires in {out.get('remaining_seconds', '')}s")
            audit("browser_2fa_popup_show_code", f"id={out.get('record',{}).get('id','')} origin={origin_info.get('host','')}")
            if copy:
                root.clipboard_clear(); root.clipboard_append(code); root.update()
                schedule_clipboard_clear(code)
                status_var.set(f"2FA code copied. Clipboard clears in {CLIPBOARD_CLEAR_SECONDS}s.")
            else:
                status_var.set("2FA code shown after explicit user click.")
        except Exception as exc:
            status_var.set(f"Could not generate 2FA code: {exc}")

    def open_full_vault(setup_hint: bool = False) -> None:
        try:
            subprocess.Popen([sys.executable, str(Path(__file__).resolve()), "--gui"], close_fds=True)
            if setup_hint:
                status_var.set("Full Vault opened. In the Vault, use 2FA > Register New 2FA or select a record and enable 2FA.")
            else:
                status_var.set("Full Sentinel Password Vault opened.")
            audit("browser_2fa_popup_open_full_vault", f"origin={origin_info.get('host','')} setup_hint={setup_hint}")
        except Exception as exc:
            status_var.set(f"Could not open full Vault: {exc}")

    def setup_2fa_from_popup() -> None:
        open_full_vault(setup_hint=True)

    # Browser popup buttons are intentionally laid out in two visible, symmetrical rows.
    # v0.9.9.2 placed every action in one horizontal strip, which could overflow on
    # MATE/GTK scaling and make Setup 2FA / Open Vault appear missing.
    button_panel = tk.Frame(main, bg=colors["panel"], padx=8, pady=8)
    button_panel.pack(fill="x", pady=(6, 0))

    primary_row = tk.Frame(button_panel, bg=colors["panel"])
    primary_row.pack(fill="x", pady=(0, 6))
    secondary_row = tk.Frame(button_panel, bg=colors["panel"])
    secondary_row.pack(fill="x")

    def popup_button(parent, text, command, primary=False):
        btn = tk.Button(
            parent,
            text=text,
            command=command,
            bg=colors["gold"] if primary else colors["panel2"],
            fg="#111111" if primary else colors["text"],
            activebackground=colors["cyan"],
            activeforeground="#111111",
            relief="flat",
            width=15,
            padx=6,
            pady=5,
        )
        btn.pack(side="left", expand=True, fill="x", padx=3)
        return btn

    popup_button(primary_row, "Unlock Vault", unlock_vault, primary=True)
    popup_button(primary_row, "Show Code", lambda: show_code(False))
    popup_button(primary_row, "Copy Code", lambda: show_code(True))

    popup_button(secondary_row, "Setup 2FA", setup_2fa_from_popup)
    popup_button(secondary_row, "Open Vault", lambda: open_full_vault(False))
    popup_button(secondary_row, "Close", root.destroy)

    pw_entry.bind("<Return>", lambda _e: unlock_vault())
    pw_entry.focus_set()
    refresh_countdown()
    audit("browser_2fa_popup_open", f"origin={origin_info.get('host','')}")
    root.mainloop()
    return 0

def normalize_browser_origin(origin: str = "") -> Dict[str, str]:
    """Return a safe origin summary for browser bridge matching."""
    raw = (origin or "").strip()
    if raw and not re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", raw):
        raw_url = "https://" + raw
    else:
        raw_url = raw
    parsed = urllib.parse.urlparse(raw_url) if raw_url else urllib.parse.urlparse("")
    host = (parsed.hostname or raw).strip().lower()
    host = re.sub(r"^www\.", "", host)
    return {"raw": raw, "scheme": parsed.scheme or "", "host": host, "origin": f"{parsed.scheme}://{host}" if parsed.scheme and host else host}


def _browser_match_score(rec: Dict[str, Any], origin_info: Dict[str, str]) -> Tuple[int, List[str]]:
    host = origin_info.get("host", "").lower()
    if not host:
        return (0, [])
    cfg = normalize_totp_config(rec)
    fields = {
        "url": str(rec.get("url", "")).lower(),
        "title": str(rec.get("title", "")).lower(),
        "username": str(rec.get("username", "")).lower(),
        "issuer": str(cfg.get("issuer", "")).lower(),
        "account": str(cfg.get("account", "")).lower(),
        "notes": str(rec.get("notes", "")).lower(),
    }
    reasons: List[str] = []
    score = 0
    host_parts = [p for p in host.split(".") if p]
    root = host_parts[-2] if len(host_parts) >= 2 else host
    if host and host in fields["url"]:
        score += 100; reasons.append("url_host")
    if root and root in fields["url"]:
        score += 45; reasons.append("url_root")
    if root and root in fields["issuer"]:
        score += 35; reasons.append("issuer")
    if root and root in fields["title"]:
        score += 25; reasons.append("title")
    if host and host in fields["notes"]:
        score += 10; reasons.append("notes")
    return (score, reasons)


def browser_2fa_quick_view(plain: Dict[str, Any], origin: str = "") -> Dict[str, Any]:
    """Metadata-only 2FA quick-view payload for Sentinel Browser toolbar."""
    origin_info = normalize_browser_origin(origin)
    records = [normalize_record(r) for r in plain.get("records", []) if normalize_totp_config(r).get("enabled")]
    rows: List[Dict[str, Any]] = []
    for rec in records:
        cfg = normalize_totp_config(rec)
        score, reasons = _browser_match_score(rec, origin_info)
        meta = totp_metadata(rec)
        meta.update({
            "match_score": score,
            "match_reasons": reasons,
            "requires_click_for_code": True,
            "code_included": False,
            "secret_included": False,
        })
        rows.append(meta)
    rows.sort(key=lambda r: (r.get("match_score", 0), str(r.get("issuer", "")).lower(), str(r.get("account", "")).lower()), reverse=True)
    matches = [r for r in rows if r.get("match_score", 0) > 0]
    return {
        "ok": True,
        "provider": APP_NAME,
        "version": VERSION,
        "origin": origin_info,
        "matches": matches,
        "all_totp_entries": rows,
        "entry_count": len(rows),
        "match_count": len(matches),
        "browser_toolbar_popup_ready": True,
        "setup_supported": True,
        "security": {
            "vault_unlock_required": True,
            "user_click_required_for_code": True,
            "plaintext_totp_secret_included": False,
            "plaintext_totp_code_included": False,
            "audit_policy": "metadata only unless user explicitly requests a code",
        },
    }


def browser_2fa_code_payload(plain: Dict[str, Any], ident: str, origin: str = "") -> Dict[str, Any]:
    rec = resolve_record(plain, ident)
    out = totp_code_for_record(rec)
    score, reasons = _browser_match_score(rec, normalize_browser_origin(origin))
    return {
        "ok": True,
        "record": totp_metadata(rec),
        "origin": normalize_browser_origin(origin),
        "match_score": score,
        "match_reasons": reasons,
        "code": out["code"],
        "remaining_seconds": out["remaining_seconds"],
        "digits": out["digits"],
        "period": out["period"],
        "algorithm": out["algorithm"],
        "clipboard_auto_clear_seconds": CLIPBOARD_CLEAR_SECONDS,
        "secret_included": False,
        "requires_user_approval": True,
    }


def browser_save_candidate_preview_payload(origin: str = "", title: str = "", username: str = "", url: str = "", category: str = "Browser", notes: str = "") -> Dict[str, Any]:
    """Metadata-only preview for Sentinel Browser save-login prompt. Never returns password."""
    origin_info = normalize_browser_origin(origin or url)
    guessed_url = url or origin_info.get("origin") or origin_info.get("host")
    guessed_title = title or origin_info.get("host") or "Browser Login"
    return {
        "ok": True,
        "provider": "Sentinel Password Vault",
        "program_id": PROGRAM_ID,
        "version": VERSION,
        "origin": origin_info,
        "candidate": {
            "title": guessed_title,
            "username": username or "",
            "url": guessed_url or "",
            "category": category or "Browser",
            "notes_present": bool(notes),
            "password_present": False,
        },
        "prompt": "Save this login to Sentinel Password Vault?",
        "requires_user_approval": True,
        "requires_vault_unlock_to_save": True,
        "browser_should_not_store_password": True,
        "plaintext_password_returned": False,
        "secret_values_included": False,
        "match_endpoint": "sentinel-password-vault --browser-save-matches --browser-origin URL --browser-title TITLE --browser-username USER",
        "save_endpoint": "printf '%s' PASSWORD | sentinel-password-vault --browser-save-login --browser-origin URL --browser-title TITLE --browser-username USER --browser-password-stdin --yes",
        "update_endpoint": "printf '%s' PASSWORD | sentinel-password-vault --browser-update-login RECORD_ID --browser-origin URL --browser-password-stdin --yes",
    }


def _browser_save_match_score(rec: Dict[str, Any], origin_info: Dict[str, str], title: str = "", username: str = "", url: str = "") -> Tuple[int, List[str]]:
    """Rank existing vault records for browser save/update prompts without exposing secrets."""
    rec = normalize_record(rec)
    reasons: List[str] = []
    score = 0
    candidate_host = normalize_browser_origin(origin_info.get("origin") or origin_info.get("host") or url).get("host", "")
    rec_host = normalize_browser_origin(str(rec.get("url", ""))).get("host", "")
    cand_title = (title or "").strip().lower()
    cand_user = (username or "").strip().lower()
    rec_title = str(rec.get("title", "")).strip().lower()
    rec_user = str(rec.get("username", "")).strip().lower()
    rec_url = str(rec.get("url", "")).strip().lower()
    cand_url = (url or origin_info.get("origin") or origin_info.get("host") or "").strip().lower()
    host_score = _browser_hosts_related(candidate_host, rec_host)
    if host_score <= 0:
        return 0, []
    score += 70; reasons.append("same_host")
    if cand_user and rec_user and cand_user == rec_user:
        score += 55; reasons.append("same_username")
    if cand_title and rec_title and cand_title == rec_title:
        score += 35; reasons.append("same_title")
    elif cand_title and rec_title and (cand_title in rec_title or rec_title in cand_title):
        score += 15; reasons.append("similar_title")
    if cand_url and rec_url and cand_url == rec_url:
        score += 80; reasons.append("same_url")
    return score, reasons

def browser_save_existing_matches_payload(plain: Dict[str, Any], *, origin: str = "", title: str = "", username: str = "", url: str = "") -> Dict[str, Any]:
    """Metadata-only existing-login match report for Sentinel Browser save/update prompts."""
    origin_info = normalize_browser_origin(origin or url)
    rows: List[Dict[str, Any]] = []
    for rec in plain.get("records", []):
        score, reasons = _browser_save_match_score(rec, origin_info, title=title, username=username, url=url)
        if score > 0:
            meta = record_metadata(rec)
            meta.update({"match_score": score, "match_reasons": reasons, "secret_values_included": False})
            rows.append(meta)
    rows.sort(key=lambda r: (int(r.get("match_score", 0)), str(r.get("updated_at", ""))), reverse=True)
    best = rows[0] if rows else None
    return {
        "ok": True,
        "provider": APP_NAME,
        "version": VERSION,
        "origin": origin_info,
        "candidate": {"title": title or origin_info.get("host") or "Browser Login", "username": username or "", "url": url or origin_info.get("origin") or origin_info.get("host") or ""},
        "existing_matches": rows,
        "match_count": len(rows),
        "recommended_action": "update_existing_or_save_new" if rows else "save_new",
        "best_match": best,
        "plaintext_password_returned": False,
        "secret_values_included": False,
    }

def browser_update_login_payload(plain: Dict[str, Any], ident: str, *, origin: str = "", title: str = "", username: str = "", password: str = "", url: str = "", notes: str = "", category: str = "", tags: str = "") -> Dict[str, Any]:
    """Update an existing record from an approved browser prompt without echoing secrets."""
    if not password:
        raise ValueError("browser_update_login requires a password value from an approved browser prompt")
    rec = resolve_record(plain, ident)
    before_meta = record_metadata(rec)
    origin_info = normalize_browser_origin(origin or url or rec.get("url", ""))
    if title:
        rec["title"] = title
    if username:
        rec["username"] = username
    if url or origin_info.get("origin"):
        rec["url"] = url or origin_info.get("origin") or rec.get("url", "")
    rec["password"] = password
    if notes:
        existing_notes = str(rec.get("notes", ""))
        rec["notes"] = notes if not existing_notes else existing_notes + "\n" + notes
    if category:
        rec["category"] = category
    if tags:
        merged = list(dict.fromkeys(list(rec.get("tags", [])) + [t.strip() for t in str(tags).split(",") if t.strip()]))
        rec["tags"] = merged
    rec["source"] = rec.get("source") or "sentinel-browser-save-login"
    rec["browser_origin"] = origin_info.get("origin") or origin or rec.get("browser_origin", "")
    rec["updated_at"] = now_iso()
    plain["updated_at"] = rec["updated_at"]
    warnings = duplicate_warnings_for_candidate(plain, rec, existing_id=rec.get("id"))
    return {
        "ok": True,
        "updated": True,
        "record": record_metadata(rec),
        "previous_record": before_meta,
        "warnings": warnings,
        "origin": origin_info,
        "plaintext_password_returned": False,
        "secret_values_included": False,
        "browser_should_discard_password_after_update": True,
        "audit_plaintext_secret_logged": False,
    }

def browser_save_login_payload(plain: Dict[str, Any], *, origin: str = "", title: str = "", username: str = "", password: str = "", url: str = "", notes: str = "", category: str = "Browser", tags: str = "browser,saved-login") -> Dict[str, Any]:
    """Append a user-approved browser login candidate into the encrypted vault."""
    if not password:
        raise ValueError("browser_save_login requires a password value from an approved browser prompt")
    origin_info = normalize_browser_origin(origin or url)
    guessed_url = url or origin_info.get("origin") or origin_info.get("host") or ""
    guessed_title = title or origin_info.get("host") or "Browser Login"
    tag_list = [t.strip() for t in str(tags or "browser,saved-login").split(",") if t.strip()]
    ts = now_iso()
    rec = {
        "id": str(uuid.uuid4()),
        "title": guessed_title,
        "username": username or "",
        "url": guessed_url,
        "password": password,
        "notes": notes or f"Saved from Sentinel Browser origin: {origin_info.get('origin') or origin_info.get('host')}",
        "category": category or "Browser",
        "tags": tag_list,
        "favorite": False,
        "created_at": ts,
        "updated_at": ts,
        "source": "sentinel-browser-save-login",
        "browser_origin": origin_info.get("origin") or origin,
    }
    warnings = duplicate_warnings_for_candidate(plain, rec)
    plain.setdefault("records", []).append(rec)
    plain["updated_at"] = ts
    return {
        "ok": True,
        "saved": True,
        "record": record_metadata(rec),
        "warnings": warnings,
        "origin": origin_info,
        "plaintext_password_returned": False,
        "secret_values_included": False,
        "browser_should_discard_password_after_save": True,
        "audit_plaintext_secret_logged": False,
    }


def browser_commit_login_transaction(candidate: Dict[str, str], master_password: str, *, selected_record_id: str = "", prefer_update_same_username: bool = True) -> Dict[str, Any]:
    """Commit one browser-approved login against the latest encrypted vault state.

    The latest container is loaded while holding an exclusive local file lock, so a
    browser save cannot be lost when the full Vault GUI is open in another process.
    """
    effective_origin = candidate.get("origin") or candidate.get("url", "")
    with vault_file_lock():
        container = load_container(vault_path())
        plain = decrypt_vault(container, master_password)
        match_report = browser_save_existing_matches_payload(
            plain,
            origin=effective_origin,
            title=candidate.get("title", ""),
            username=candidate.get("username", ""),
            url=candidate.get("url", ""),
        )
        matches = list(match_report.get("existing_matches", []))
        update_id = str(selected_record_id or "")
        if update_id and update_id not in {str(row.get("id", "")) for row in matches}:
            raise ValueError("The selected Vault record no longer matches this site")
        if not update_id and prefer_update_same_username:
            wanted_user = str(candidate.get("username", "")).strip().casefold()
            same_user = [row for row in matches if wanted_user and str(row.get("username", "")).strip().casefold() == wanted_user]
            if len(same_user) == 1:
                update_id = str(same_user[0].get("id", ""))
        if update_id:
            out = browser_update_login_payload(
                plain, update_id, origin=effective_origin, title=candidate.get("title", ""),
                username=candidate.get("username", ""), password=candidate.get("password", ""),
                url=candidate.get("url", ""), notes=candidate.get("notes", ""),
                category=candidate.get("category", "Browser"), tags="browser,saved-login,updated-from-browser",
            )
            action = "updated"
        else:
            out = browser_save_login_payload(
                plain, origin=effective_origin, title=candidate.get("title", ""),
                username=candidate.get("username", ""), password=candidate.get("password", ""),
                url=candidate.get("url", ""), notes=candidate.get("notes", ""),
                category=candidate.get("category", "Browser"), tags=candidate.get("tags", "browser,saved-login"),
            )
            action = "saved"
        new_container = encrypt_vault(plain, master_password, container)
        save_container(new_container)
        verify_container = load_container(vault_path())
        verify_plain = decrypt_vault(verify_container, master_password)
        record_id = str(out.get("record", {}).get("id", ""))
        if not any(str(rec.get("id", "")) == record_id for rec in verify_plain.get("records", [])):
            raise RuntimeError("Vault write verification failed")
        return {
            "ok": True, "cancelled": False, "saved": action == "saved", "updated": action == "updated",
            "record": out.get("record", {}), "match_count": len(matches),
            "write_verified": True, "vault_stamp": list(vault_file_stamp()),
            "secret_values_included": False,
        }


def browser_password_popup_status() -> Dict[str, Any]:
    """Describe the restricted one-shot password-fill receiver used by Sentinel Browser."""
    return {
        "ok": True,
        "provider": APP_NAME,
        "program_id": PROGRAM_ID,
        "version": VERSION,
        "bridge_stage": "browser_password_fill_active",
        "restricted_popup": True,
        "opens_full_vault_by_default": False,
        "browser_origin_supported": True,
        "vault_unlock_required": True,
        "user_approval_required": True,
        "auto_submit": False,
        "domain_match_required": True,
        "secret_transport": "one_shot_stdout_pipe_after_user_approval",
        "secret_values_in_command_line": False,
        "browser_persistent_secret_storage": False,
    }


def browser_save_popup_status() -> Dict[str, Any]:
    """Describe the restricted browser save/update receiver."""
    return {
        "ok": True,
        "provider": APP_NAME,
        "program_id": PROGRAM_ID,
        "version": VERSION,
        "bridge_stage": "browser_save_update_active",
        "restricted_popup": True,
        "opens_full_vault_by_default": False,
        "browser_origin_supported": True,
        "vault_unlock_required": True,
        "user_approval_required": True,
        "supports_save_new": True,
        "supports_update_existing": True,
        "one_action_save_commit": True,
        "write_verification": True,
        "cross_process_locking": True,
        "candidate_transport": "stdin_json_pipe",
        "secret_values_in_command_line": False,
        "browser_persistent_secret_storage": False,
    }


def _browser_hosts_related(current_host: str, record_host: str) -> int:
    """Return an exact normalized-host match score for password release.

    ``normalize_browser_origin`` already removes a leading ``www.`` alias.  No
    parent/subdomain fallback is used here: without a Public Suffix List, that
    shortcut could release an apex-domain password to an untrusted tenant
    subdomain.  A user can save a separate record for an authentication host.
    """
    current = (current_host or "").strip().lower().strip(".")
    record = (record_host or "").strip().lower().strip(".")
    if not current or not record:
        return 0
    return 300 if current == record else 0


def browser_password_fill_matches(plain: Dict[str, Any], origin: str = "") -> List[Dict[str, Any]]:
    """Return internal matching records for a browser fill request.

    This helper is intentionally stricter than fuzzy metadata search: a password can
    only be offered when the normalized saved URL host exactly matches the
    current browser host (with a leading www alias normalized away).
    """
    origin_info = normalize_browser_origin(origin)
    current_host = origin_info.get("host", "")
    rows: List[Dict[str, Any]] = []
    for raw in plain.get("records", []):
        rec = normalize_record(raw)
        if not str(rec.get("password", "")):
            continue
        record_host = normalize_browser_origin(str(rec.get("url", ""))).get("host", "")
        score = _browser_hosts_related(current_host, record_host)
        if score <= 0:
            continue
        rows.append({"record": rec, "match_score": score, "record_host": record_host})
    rows.sort(key=lambda row: (int(row.get("match_score", 0)), str(row.get("record", {}).get("updated_at", ""))), reverse=True)
    return rows


def _read_browser_candidate_from_stdin(max_bytes: int = 65536) -> Dict[str, str]:
    raw = sys.stdin.buffer.read(max_bytes + 1)
    if len(raw) > max_bytes:
        raise ValueError("Browser credential candidate exceeds the local bridge size limit")
    try:
        payload = json.loads(raw.decode("utf-8"))
    except Exception as exc:
        raise ValueError("Browser credential candidate must be valid UTF-8 JSON") from exc
    if not isinstance(payload, dict):
        raise ValueError("Browser credential candidate must be a JSON object")
    limits = {"origin": 2048, "url": 2048, "title": 240, "username": 512, "password": 4096, "notes": 2000}
    out: Dict[str, str] = {}
    for key, limit in limits.items():
        value = payload.get(key, "")
        if value is None:
            value = ""
        if not isinstance(value, str):
            value = str(value)
        out[key] = value[:limit]
    out["category"] = str(payload.get("category", "Browser"))[:120] or "Browser"
    out["tags"] = str(payload.get("tags", "browser,saved-login"))[:500] or "browser,saved-login"
    if not out["password"]:
        raise ValueError("Browser credential candidate does not contain a password")
    return out


def browser_password_popup_gui(origin: str = "") -> Dict[str, Any]:
    """Restricted Vault-owned account chooser returning one approved credential over stdout."""
    import tkinter as tk
    from tkinter import messagebox, ttk

    origin_info = normalize_browser_origin(origin)
    result: Dict[str, Any] = {"ok": False, "cancelled": True, "secret_values_included": False}
    state: Dict[str, Any] = {"plain": None, "rows": []}
    root = tk.Tk()
    root.title("Sentinel Vault Login Fill")
    root.geometry("620x390")
    root.minsize(560, 350)
    colors = {"bg": "#0B0D10", "panel": "#11161C", "panel2": "#1C252E", "text": "#E8E6DF", "muted": "#9AA4AD", "gold": "#E19B00", "cyan": "#03C8FF"}
    root.configure(bg=colors["bg"])

    main = tk.Frame(root, bg=colors["bg"], padx=16, pady=14)
    main.pack(fill="both", expand=True)
    tk.Label(main, text="Fill Login from Sentinel Password Vault", bg=colors["bg"], fg=colors["gold"], font=("Sans", 13, "bold")).pack(fill="x")
    tk.Label(main, text=f"Site: {origin_info.get('host') or 'unknown'}", bg=colors["bg"], fg=colors["muted"], anchor="w").pack(fill="x", pady=(2, 10))
    tk.Label(main, text="The Vault will return one selected username and password to this browser window only. It will not submit the form.", bg=colors["bg"], fg=colors["text"], wraplength=580, justify="left", anchor="w").pack(fill="x", pady=(0, 10))

    unlock_panel = tk.Frame(main, bg=colors["panel"], padx=10, pady=10)
    unlock_panel.pack(fill="x")
    tk.Label(unlock_panel, text="Vault master password", bg=colors["panel"], fg=colors["text"], anchor="w").pack(fill="x")
    master_entry = tk.Entry(unlock_panel, show="•", bg=colors["panel2"], fg=colors["text"], insertbackground=colors["cyan"], relief="flat")
    master_entry.pack(fill="x", pady=(4, 8))

    selected = tk.StringVar(value="")
    status = tk.StringVar(value="Unlock the Vault to find logins saved for this site.")
    combo = ttk.Combobox(main, textvariable=selected, state="readonly", values=[])
    combo.pack(fill="x", pady=(12, 4))
    tk.Label(main, textvariable=status, bg=colors["bg"], fg=colors["muted"], wraplength=580, justify="left", anchor="w").pack(fill="x", pady=(2, 12))

    def display(row: Dict[str, Any]) -> str:
        rec = row["record"]
        title = rec.get("title") or row.get("record_host") or "Login"
        username = rec.get("username") or "(no username)"
        return f"{title} — {username}"

    def selected_row() -> Optional[Dict[str, Any]]:
        label = selected.get()
        for row in state.get("rows", []):
            if row.get("display") == label:
                return row
        return None

    def unlock_receiver() -> None:
        password = master_entry.get()
        if not password:
            status.set("Enter the Vault master password.")
            return
        try:
            container = load_container(vault_path())
            plain = decrypt_vault(container, password)
            rows = browser_password_fill_matches(plain, origin)
            for row in rows:
                row["display"] = display(row)
            state["plain"] = plain
            state["rows"] = rows
            combo["values"] = [row["display"] for row in rows]
            if rows:
                selected.set(rows[0]["display"])
                status.set(f"{len(rows)} matching login(s). Select one, then click Fill Selected Login.")
            else:
                selected.set("")
                status.set("No Vault login has a matching saved URL for this site.")
            audit("browser_password_popup_unlock", f"host={origin_info.get('host','')} matches={len(rows)}")
        except Exception as exc:
            state["plain"] = None
            state["rows"] = []
            combo["values"] = []
            selected.set("")
            audit("browser_password_popup_unlock_failed", type(exc).__name__)
            messagebox.showerror("Sentinel Vault Login Fill", "Vault unlock failed. Check the master password.")
            status.set("Vault unlock failed.")

    def approve_fill() -> None:
        row = selected_row()
        if not row:
            status.set("Unlock the Vault and select a matching login first.")
            return
        rec = row["record"]
        result.clear()
        result.update({
            "ok": True,
            "cancelled": False,
            "approved": True,
            "origin": origin_info,
            "record_id": rec.get("id", ""),
            "username": str(rec.get("username", "")),
            "password": str(rec.get("password", "")),
            "auto_submit": False,
            "secret_values_included": True,
            "secret_transport": "one_shot_stdout_pipe",
            "browser_should_discard_after_fill": True,
        })
        audit("browser_password_popup_fill_approved", f"id={rec.get('id','')} host={origin_info.get('host','')}")
        root.destroy()

    def cancel() -> None:
        result.clear()
        result.update({"ok": False, "cancelled": True, "secret_values_included": False})
        root.destroy()

    buttons = tk.Frame(main, bg=colors["panel"], padx=8, pady=8)
    buttons.pack(fill="x", side="bottom")
    tk.Button(buttons, text="Unlock Vault", command=unlock_receiver, bg=colors["gold"], fg="#111111", relief="flat", padx=10, pady=6).pack(side="left", expand=True, fill="x", padx=3)
    tk.Button(buttons, text="Fill Selected Login", command=approve_fill, bg=colors["panel2"], fg=colors["text"], relief="flat", padx=10, pady=6).pack(side="left", expand=True, fill="x", padx=3)
    tk.Button(buttons, text="Cancel", command=cancel, bg=colors["panel2"], fg=colors["text"], relief="flat", padx=10, pady=6).pack(side="left", expand=True, fill="x", padx=3)
    root.protocol("WM_DELETE_WINDOW", cancel)
    master_entry.bind("<Return>", lambda _e: unlock_receiver())
    master_entry.focus_set()
    audit("browser_password_popup_open", f"host={origin_info.get('host','')}")
    root.mainloop()
    try:
        master_entry.delete(0, "end")
    except Exception:
        pass
    state.clear()
    return result


def browser_save_popup_gui(candidate: Dict[str, str], origin: str = "") -> Dict[str, Any]:
    """Restricted Firefox-style save/update dialog with one clear commit action."""
    import tkinter as tk
    from tkinter import messagebox, ttk

    effective_origin = candidate.get("origin") or origin or candidate.get("url", "")
    origin_info = normalize_browser_origin(effective_origin)
    result: Dict[str, Any] = {"ok": False, "cancelled": True, "secret_values_included": False}
    state: Dict[str, Any] = {"matches": [], "unlocked": False}
    root = tk.Tk()
    root.title("Save Login to Sentinel Password Vault")
    root.geometry("680x490")
    root.minsize(610, 430)
    colors = {"bg": "#0B0D10", "panel": "#11161C", "panel2": "#1C252E", "text": "#E8E6DF", "muted": "#9AA4AD", "gold": "#E19B00", "cyan": "#03C8FF"}
    root.configure(bg=colors["bg"])
    main = tk.Frame(root, bg=colors["bg"], padx=16, pady=14)
    main.pack(fill="both", expand=True)
    tk.Label(main, text="Save Login to Sentinel Password Vault", bg=colors["bg"], fg=colors["gold"], font=("Sans", 13, "bold")).pack(fill="x")
    tk.Label(main, text=f"Site: {origin_info.get('host') or candidate.get('url') or 'unknown'}", bg=colors["bg"], fg=colors["muted"], anchor="w").pack(fill="x", pady=(2, 8))

    summary = tk.Frame(main, bg=colors["panel"], padx=10, pady=10)
    summary.pack(fill="x")
    for label, value in (("Account", candidate.get("username") or "(no username)"), ("Password", "•" * min(16, max(8, len(candidate.get("password", ""))))), ("Page", candidate.get("url") or effective_origin)):
        row = tk.Frame(summary, bg=colors["panel"]); row.pack(fill="x", pady=2)
        tk.Label(row, text=label + ":", width=11, bg=colors["panel"], fg=colors["muted"], anchor="w").pack(side="left")
        tk.Label(row, text=value, bg=colors["panel"], fg=colors["text"], anchor="w").pack(side="left", fill="x", expand=True)

    unlock_panel = tk.Frame(main, bg=colors["bg"], pady=10); unlock_panel.pack(fill="x")
    tk.Label(unlock_panel, text="Vault master password", bg=colors["bg"], fg=colors["text"], anchor="w").pack(fill="x")
    master_entry = tk.Entry(unlock_panel, show="•", bg=colors["panel2"], fg=colors["text"], insertbackground=colors["cyan"], relief="flat")
    master_entry.pack(fill="x", pady=(4, 6))

    selected = tk.StringVar(value="")
    status = tk.StringVar(value="Enter the master password, then choose Save Login. Existing matching accounts are updated; new accounts are added.")
    combo = ttk.Combobox(main, textvariable=selected, state="readonly", values=[])
    combo.pack(fill="x", pady=(2, 4))
    tk.Label(main, textvariable=status, bg=colors["bg"], fg=colors["muted"], wraplength=640, justify="left", anchor="w").pack(fill="x", pady=(2, 10))

    def display(meta: Dict[str, Any]) -> str:
        return f"{meta.get('title') or 'Login'} — {meta.get('username') or '(no username)'}"

    def selected_match() -> Optional[Dict[str, Any]]:
        label = selected.get()
        return next((row for row in state.get("matches", []) if row.get("display") == label), None)

    def review_matches() -> bool:
        password = master_entry.get()
        if not password:
            status.set("Enter the Vault master password first.")
            return False
        try:
            with vault_file_lock():
                container = load_container(vault_path())
                plain = decrypt_vault(container, password)
                report = browser_save_existing_matches_payload(plain, origin=effective_origin, title=candidate.get("title", ""), username=candidate.get("username", ""), url=candidate.get("url", ""))
            rows = []
            for meta in report.get("existing_matches", []):
                row = dict(meta); row["display"] = display(row); rows.append(row)
            state.update({"matches": rows, "unlocked": True})
            combo["values"] = [row["display"] for row in rows]
            if rows:
                selected.set(rows[0]["display"])
                status.set(f"Found {len(rows)} login(s) for this site. Save Login updates the same username automatically; Update Selected uses the chosen record.")
            else:
                selected.set("")
                status.set("No matching login exists. Save Login will add this account to the encrypted Vault.")
            audit("browser_save_popup_unlock", f"host={origin_info.get('host','')} matches={len(rows)}")
            return True
        except Exception as exc:
            state.update({"matches": [], "unlocked": False}); combo["values"] = []; selected.set("")
            audit("browser_save_popup_unlock_failed", type(exc).__name__)
            messagebox.showerror("Save Login to Sentinel Vault", "Vault unlock failed. Check the master password.")
            status.set("Vault unlock failed.")
            return False

    def commit(selected_record_id: str = "", prefer_update_same_username: bool = True) -> None:
        password = master_entry.get()
        if not password:
            status.set("Enter the Vault master password first.")
            return
        try:
            out = browser_commit_login_transaction(candidate, password, selected_record_id=selected_record_id, prefer_update_same_username=prefer_update_same_username)
            result.clear(); result.update(out)
            action = "updated" if out.get("updated") else "saved"
            audit(f"browser_save_popup_{action}", f"id={out.get('record',{}).get('id','')} host={origin_info.get('host','')} verified={out.get('write_verified')}")
            root.destroy()
        except Exception as exc:
            audit("browser_save_popup_commit_failed", type(exc).__name__)
            messagebox.showerror("Save Login to Sentinel Vault", f"Could not save this login: {exc}")
            status.set("The login was not saved. Review the message and try again.")

    def save_login() -> None:
        commit("", True)

    def update_selected() -> None:
        if not state.get("unlocked") and not review_matches():
            return
        row = selected_match()
        if not row:
            status.set("Select an existing login, or choose Save Login to add/update automatically.")
            return
        commit(str(row.get("id", "")), False)

    def cancel() -> None:
        result.clear(); result.update({"ok": False, "cancelled": True, "secret_values_included": False}); root.destroy()

    buttons = tk.Frame(main, bg=colors["panel"], padx=8, pady=8); buttons.pack(fill="x", side="bottom")
    save_button = tk.Button(buttons, text="Save Login", command=save_login, bg=colors["gold"], fg="#111111", relief="flat", padx=8, pady=7)
    save_button.pack(side="left", expand=True, fill="x", padx=3)
    tk.Button(buttons, text="Review Matches", command=review_matches, bg=colors["panel2"], fg=colors["text"], relief="flat", padx=8, pady=7).pack(side="left", expand=True, fill="x", padx=3)
    tk.Button(buttons, text="Update Selected", command=update_selected, bg=colors["panel2"], fg=colors["text"], relief="flat", padx=8, pady=7).pack(side="left", expand=True, fill="x", padx=3)
    tk.Button(buttons, text="Cancel", command=cancel, bg=colors["panel2"], fg=colors["text"], relief="flat", padx=8, pady=7).pack(side="left", expand=True, fill="x", padx=3)
    root.protocol("WM_DELETE_WINDOW", cancel)
    master_entry.bind("<Return>", lambda _e: save_login())
    master_entry.focus_set()
    audit("browser_save_popup_open", f"host={origin_info.get('host','')}")
    root.mainloop()
    try: master_entry.delete(0, "end")
    except Exception: pass
    candidate["password"] = ""; state.clear()
    return result

def duplicate_report(plain: Dict[str, Any]) -> Dict[str, Any]:
    records = [normalize_record(r) for r in plain.get("records", [])]
    def bucket(key_fn):
        buckets: Dict[str, List[Dict[str, Any]]] = {}
        for rec in records:
            key = key_fn(rec).strip().lower()
            if key:
                buckets.setdefault(key, []).append(record_metadata(rec))
        return [v for v in buckets.values() if len(v) > 1]
    same_title_username = bucket(lambda r: f"{r.get('title','')}|{r.get('username','')}")
    same_url_username = bucket(lambda r: f"{r.get('url','')}|{r.get('username','')}")
    # Reused secrets are reported without exposing the password value.
    pw_buckets: Dict[str, List[Dict[str, Any]]] = {}
    for rec in records:
        pw = rec.get("password") or ""
        if pw:
            pw_buckets.setdefault(pw, []).append(record_metadata(rec))
    reused_passwords = [v for v in pw_buckets.values() if len(v) > 1]
    return {
        "record_count": len(records),
        "same_title_username": same_title_username,
        "same_url_username": same_url_username,
        "reused_password_groups": reused_passwords,
        "issue_count": len(same_title_username) + len(same_url_username) + len(reused_passwords),
    }


def favorite_report(plain: Dict[str, Any]) -> Dict[str, Any]:
    favorites = [record_metadata(r) for r in plain.get("records", []) if normalize_record(r).get("favorite")]
    return {"count": len(favorites), "records": favorites}


def theme_status() -> Dict[str, Any]:
    """Report SentinelOS theme coverage for AEGIS/review tooling.

    This intentionally avoids inspecting live Tk internals from CLI mode; it is a
    static contract that documents which GUI surfaces are routed through the
    Sentinel theme helpers rather than native unthemed widgets/dialogs.
    """
    surfaces = {
        "window": True,
        "header": True,
        "buttons": True,
        "checkboxes": True,
        "dropdowns": True,
        "popups": True,
        "confirmations": True,
        "restore_backup_selection": True,
        "text_entries": True,
        "treeview_records": True,
        "listboxes": True,
        "focus_indicators": True,
        "status_messages": True,
        "desktop_menu_icon": True,
        "secret_view_dialog": True,
        "random_password_generator_dialog": True,
        "compact_window_layout": True,
        "grouped_action_buttons": True,
        "reduced_default_window_size": True,
        "wider_compact_window": True,
        "symmetrical_action_buttons": True,
        "top_menu_action_consolidation": True,
        "reduced_main_action_buttons": True,
        "totp_setup_dialog": True,
        "totp_code_dialog": True,
        "totp_global_authenticator_panel": True,
        "totp_qr_display_or_uri_fallback": True,
        "totp_qr_canvas_renderer": True,
        "totp_qr_manual_render_only": True,
        "record_list_wider_layout": True,
        "record_double_click_details_fix": True,
        "totp_non_technical_setup_wizard": True,
        "totp_paste_full_instructions_parser": True,
        "aegis_vault_backup_menu": True,
        "browser_toolbar_2fa_spec": True,
        "browser_save_login_provider": True,
        "browser_2fa_popup_setup_button": True,
        "browser_2fa_popup_open_vault_button": True,
        "browser_2fa_popup_visible_button_rows": True,
        "browser_2fa_popup_widened": True,
        "browser_2fa_popup_status_required_by_compatibility": True,
        "totp_record_checkbox_registration": True,
    }
    return {
        "ok": all(surfaces.values()),
        "theme": "SentinelOS dark/gold/cyan",
        "version": VERSION,
        "program_id": PROGRAM_ID,
        "surfaces": surfaces,
        "native_messagebox_used_for_gui": False,
        "native_simpledialog_used_for_unlock": False,
        "security_state_color_only": False,
        "default_geometry": "1120x640",
        "minimum_geometry": "1020x560",
        "totp_theme_surfaces": True,
    }


def security_dashboard(plain: Dict[str, Any]) -> Dict[str, Any]:
    """Metadata-only unlocked-vault dashboard. No plaintext secrets are returned."""
    records = [normalize_record(r) for r in plain.get("records", [])]
    health = vault_health_report(plain)
    dupes = duplicate_report(plain)
    cats = category_report(plain)
    favs = favorite_report(plain)
    missing_url = sum(1 for r in records if not (r.get("url") or "").strip())
    missing_username = sum(1 for r in records if not (r.get("username") or "").strip())
    weak = sum(1 for r in records if password_health(r, records).get("strength") in {"weak", "fair"})
    return {
        "ok": True,
        "record_count": len(records),
        "favorite_count": favs.get("count", 0),
        "totp_record_count": totp_report(plain).get("totp_enabled_count", 0),
        "category_count": len(cats.get("categories", {})),
        "duplicate_issue_count": dupes.get("issue_count", 0),
        "weak_or_fair_password_count": weak,
        "missing_url_count": missing_url,
        "missing_username_count": missing_username,
        "backups_available": len(list_encrypted_backups()),
        "health": health,
        "categories": cats,
        "duplicates": dupes,
        "favorites": favs,
        "totp": totp_report(plain),
        "theme_status": theme_status(),
        "secret_values_included": False,
    }


def export_metadata_report(plain: Dict[str, Any], dest: Path) -> Path:
    dest = dest.expanduser()
    dest.parent.mkdir(parents=True, exist_ok=True)
    payload = {
        "schema": "sentinel-password-vault.metadata-export.v1",
        "exported_at": now_iso(),
        "program": APP_NAME,
        "version": VERSION,
        "secret_values_included": False,
        "records": [record_metadata(r) for r in plain.get("records", [])],
        "category_report": category_report(plain),
        "password_health": vault_health_report(plain),
        "duplicate_report": duplicate_report(plain),
    }
    dest.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    os.chmod(dest, 0o600)
    audit("export_metadata_report", f"path={dest}")
    return dest


def export_decrypted_json(plain: Dict[str, Any], dest: Path) -> Path:
    dest = dest.expanduser()
    dest.parent.mkdir(parents=True, exist_ok=True)
    payload = dict(plain)
    payload.setdefault("schema", VAULT_RECORD_SCHEMA)
    dest.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    os.chmod(dest, 0o600)
    audit("export_decrypted_json", f"path={dest}")
    return dest


def load_plain_import(path: Path) -> Dict[str, Any]:
    payload = json.loads(path.expanduser().read_text(encoding="utf-8"))
    if payload.get("schema") == VAULT_RECORD_SCHEMA and isinstance(payload.get("records"), list):
        plain = payload
    elif isinstance(payload.get("records"), list):
        plain = {"schema": VAULT_RECORD_SCHEMA, "created_at": payload.get("created_at", now_iso()), "updated_at": now_iso(), "records": payload.get("records", [])}
    else:
        raise ValueError("Import JSON must contain a records list")
    for rec in plain.get("records", []):
        normalize_record(rec)
    plain["updated_at"] = now_iso()
    return plain


def duplicate_warnings_for_candidate(plain: Dict[str, Any], candidate: Dict[str, Any], existing_id: Optional[str] = None) -> List[str]:
    warnings: List[str] = []
    title_user = (candidate.get("title", "").strip().lower(), candidate.get("username", "").strip().lower())
    url_user = (candidate.get("url", "").strip().lower(), candidate.get("username", "").strip().lower())
    pw = candidate.get("password") or ""
    for rec in plain.get("records", []):
        rec = normalize_record(rec)
        if existing_id and rec.get("id") == existing_id:
            continue
        if title_user[0] and title_user[1] and title_user == (rec.get("title", "").strip().lower(), rec.get("username", "").strip().lower()):
            warnings.append("same title + username already exists")
        if url_user[0] and url_user[1] and url_user == (rec.get("url", "").strip().lower(), rec.get("username", "").strip().lower()):
            warnings.append("same URL + username already exists")
        if pw and rec.get("password") == pw:
            warnings.append("password appears reused in another record")
    # Preserve order while de-duplicating messages.
    return list(dict.fromkeys(warnings))

def password_strength_label(password: str) -> Tuple[str, int]:
    length = len(password or "")
    classes = sum(bool(chars and any(c in chars for c in password)) for chars in [string.ascii_lowercase, string.ascii_uppercase, string.digits, "!@#$%^&*()-_=+[]{};:,.?/\\|`~'\""])
    score = 0
    if length >= 12: score += 1
    if length >= 16: score += 1
    if length >= 24: score += 1
    if classes >= 3: score += 1
    if classes >= 4: score += 1
    if length < 10:
        return "Weak — use at least 12–16 characters", 1
    if score <= 2:
        return "Fair — longer/more mixed is better", 2
    if score <= 4:
        return "Strong", 3
    return "Very strong", 4




def normalize_record(rec: Dict[str, Any]) -> Dict[str, Any]:
    """Backfill v0.3 record organization fields without changing secrets."""
    rec.setdefault("id", str(uuid.uuid4()))
    rec.setdefault("title", "")
    rec.setdefault("username", "")
    rec.setdefault("url", "")
    rec.setdefault("password", "")
    rec.setdefault("notes", "")
    rec.setdefault("tags", [])
    rec.setdefault("category", "Other")
    rec.setdefault("favorite", False)
    rec["totp"] = normalize_totp_config(rec)
    rec.setdefault("created_at", now_iso())
    rec.setdefault("updated_at", rec.get("created_at", now_iso()))
    if isinstance(rec.get("tags"), str):
        rec["tags"] = [t.strip() for t in rec.get("tags", "").split(",") if t.strip()]
    return rec


def password_health(rec: Dict[str, Any], all_records: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
    password = str(rec.get("password", ""))
    title = str(rec.get("title", ""))
    label, score = password_strength_label(password)
    issues: List[str] = []
    if not password:
        issues.append("missing_password")
    if not rec.get("username"):
        issues.append("missing_username")
    if len(password) < 12:
        issues.append("weak_length")
    if score <= 2:
        issues.append("weak_password")
    if all_records:
        duplicate_count = sum(1 for r in all_records if r is not rec and password and r.get("password") == password)
        if duplicate_count:
            issues.append("reused_password")
    updated = rec.get("updated_at") or ""
    if updated:
        try:
            ts = _dt.datetime.fromisoformat(str(updated).replace("Z", "+00:00"))
            if ts.tzinfo is None:
                ts = ts.replace(tzinfo=_dt.timezone.utc)
            age_days = (_dt.datetime.now(_dt.timezone.utc) - ts).days
            if age_days >= 365:
                issues.append("old_password")
        except Exception:
            pass
    return {
        "title": title,
        "score": score,
        "label": label,
        "issues": issues,
        "ok": not issues,
    }


def category_report(plain: Dict[str, Any]) -> Dict[str, Any]:
    counts: Dict[str, int] = {}
    for rec in plain.get("records", []):
        normalize_record(rec)
        cat = rec.get("category") or "Other"
        counts[cat] = counts.get(cat, 0) + 1
    return {"categories": sorted(counts.items(), key=lambda x: x[0].lower()), "count": len(counts)}


def vault_health_report(plain: Dict[str, Any]) -> Dict[str, Any]:
    records = [normalize_record(r) for r in plain.get("records", [])]
    health = []
    issue_counts: Dict[str, int] = {}
    for rec in records:
        h = password_health(rec, records)
        h["id"] = rec.get("id", "")
        h["category"] = rec.get("category", "Other")
        h["favorite"] = bool(rec.get("favorite"))
        health.append(h)
        for issue in h["issues"]:
            issue_counts[issue] = issue_counts.get(issue, 0) + 1
    return {"record_count": len(records), "issue_counts": issue_counts, "records": health}

def prompt_master(confirm: bool = False) -> str:
    pw = getpass.getpass("Master password: ")
    if confirm:
        pw2 = getpass.getpass("Confirm master password: ")
        if pw != pw2:
            raise SystemExit("ERROR: Master passwords did not match.")
        if len(pw) < 10:
            raise SystemExit("ERROR: Master password must be at least 10 characters for this foundation build.")
    return pw


def unlock(path: Optional[Path] = None, password: Optional[str] = None) -> Tuple[Dict[str, Any], Dict[str, Any], str]:
    p = path or vault_path()
    container = load_container(p)
    master = password if password is not None else prompt_master(False)
    try:
        plain = decrypt_vault(container, master)
    except Exception as exc:
        audit("unlock_failed", type(exc).__name__)
        raise SystemExit("ERROR: Vault unlock failed. Check the master password or vault file.")
    return container, plain, master


def record_metadata(rec: Dict[str, Any]) -> Dict[str, Any]:
    rec = normalize_record(rec)
    return {
        "id": rec.get("id", ""),
        "title": rec.get("title", ""),
        "username": rec.get("username", ""),
        "url": rec.get("url", ""),
        "category": rec.get("category", "Other"),
        "tags": rec.get("tags", []),
        "favorite": bool(rec.get("favorite", False)),
        "created_at": rec.get("created_at", ""),
        "updated_at": rec.get("updated_at", ""),
        "totp_enabled": bool(normalize_totp_config(rec).get("enabled")),
        "totp_issuer": normalize_totp_config(rec).get("issuer", ""),
        "totp_account": normalize_totp_config(rec).get("account", ""),
        "totp_digits": normalize_totp_config(rec).get("digits", TOTP_DEFAULT_DIGITS),
        "totp_period": normalize_totp_config(rec).get("period", TOTP_DEFAULT_PERIOD),
        "totp_algorithm": normalize_totp_config(rec).get("algorithm", TOTP_DEFAULT_ALGORITHM),
    }

def find_records(plain: Dict[str, Any], query: str) -> List[Dict[str, Any]]:
    q = query.lower().strip()
    out = []
    for rec in plain.get("records", []):
        rec = normalize_record(rec)
        hay = " ".join([
            str(rec.get("id", "")), str(rec.get("title", "")), str(rec.get("username", "")),
            str(rec.get("url", "")), str(rec.get("category", "")), " ".join(rec.get("tags", [])),
            str(rec.get("notes", "")), str(normalize_totp_config(rec).get("issuer", "")), str(normalize_totp_config(rec).get("account", ""))
        ]).lower()
        if q in hay:
            out.append(rec)
    return out

def resolve_record(plain: Dict[str, Any], ident: str) -> Dict[str, Any]:
    ident_l = ident.lower().strip()
    exact = [r for r in plain.get("records", []) if r.get("id", "").lower() == ident_l]
    if len(exact) == 1:
        return exact[0]
    title_exact = [r for r in plain.get("records", []) if r.get("title", "").lower() == ident_l]
    if len(title_exact) == 1:
        return title_exact[0]
    contains = find_records(plain, ident)
    if len(contains) == 1:
        return contains[0]
    if not contains and not exact and not title_exact:
        raise SystemExit("ERROR: No matching record found.")
    raise SystemExit("ERROR: Multiple records matched. Use the exact record ID.")


def password_generator_options() -> Dict[str, Any]:
    return {
        "min_length": 12,
        "max_length": 128,
        "default_length": 20,
        "character_classes": ["lowercase", "uppercase", "digits", "symbols"],
        "avoid_ambiguous_supported": True,
        "secrets_logged": False,
        "network_required": False,
    }


def _strip_ambiguous(chars: str) -> str:
    ambiguous = set("O0oIl1|`'\"{}[]()<>;:,.")
    return "".join(c for c in chars if c not in ambiguous)


def generate_password(length: int = 20, lowercase: bool = True, uppercase: bool = True, digits: bool = True, symbols: bool = True, avoid_ambiguous: bool = False) -> str:
    length = max(12, min(128, int(length)))
    classes: List[str] = []
    if lowercase:
        classes.append(string.ascii_lowercase)
    if uppercase:
        classes.append(string.ascii_uppercase)
    if digits:
        classes.append(string.digits)
    if symbols:
        classes.append("!@#$%^&*()-_=+[]{};:,.?")
    if avoid_ambiguous:
        classes = [_strip_ambiguous(c) for c in classes]
    classes = [c for c in classes if c]
    if not classes:
        raise ValueError("At least one password character class must be enabled.")
    required = [secrets.choice(c) for c in classes]
    alphabet = "".join(classes)
    rest = [secrets.choice(alphabet) for _ in range(max(0, length - len(required)))]
    chars = required + rest
    secrets.SystemRandom().shuffle(chars)
    return "".join(chars[:length])




def b32_no_padding(raw: bytes) -> str:
    return base64.b32encode(raw).decode("ascii").rstrip("=")


def normalize_base32_secret(secret: str) -> str:
    cleaned = "".join(ch for ch in str(secret or "").replace(" ", "").replace("-", "") if ch.strip()).upper()
    if not cleaned:
        return ""
    # Validate with padding; accept common manual setup keys without padding.
    padded = cleaned + ("=" * ((8 - len(cleaned) % 8) % 8))
    try:
        base64.b32decode(padded, casefold=True)
    except Exception as exc:
        raise ValueError("Invalid TOTP base32 secret/manual setup key") from exc
    return cleaned


def extract_totp_setup_input(text: str) -> Dict[str, str]:
    """Extract a TOTP setup URI or manual key from user-pasted setup text.

    This exists for non-technical setup screens like Google Authenticator where
    the user may paste either the bare key, an otpauth:// URI, or the whole
    instruction block. The returned secret/URI is still validated before use.
    """
    raw = str(text or "").strip()
    if not raw:
        return {"mode": "", "value": ""}
    m = re.search(r"otpauth://totp/[^\s]+", raw, flags=re.IGNORECASE)
    if m:
        return {"mode": "uri", "value": m.group(0).strip().rstrip('.,;')}

    # Prefer grouped manual keys such as: ABCD EFGH IJKL MNOP 2345 6789
    grouped = re.findall(r"(?:[A-Z2-7a-z2-7]{3,8}[ \t-]+){2,}[A-Z2-7a-z2-7]{3,8}", raw)
    candidates = []
    for item in grouped:
        cleaned = ''.join(ch for ch in item if ch.upper() in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567')
        if len(cleaned) >= 16:
            candidates.append(cleaned)

    # Also support a single continuous manual key.
    for token in re.findall(r"[A-Z2-7a-z2-7]{16,}", raw):
        candidates.append(token)

    for cand in sorted(set(candidates), key=len, reverse=True):
        try:
            return {"mode": "secret", "value": normalize_base32_secret(cand)}
        except Exception:
            pass
    # Last try: maybe the whole input was the key with spaces/newlines.
    try:
        return {"mode": "secret", "value": normalize_base32_secret(raw)}
    except Exception:
        pass
    raise ValueError("Could not find a valid authenticator setup key. Paste the setup key from the website, or paste an otpauth:// URI.")


def normalize_totp_algorithm(value: str) -> str:
    alg = str(value or TOTP_DEFAULT_ALGORITHM).upper().replace("-", "")
    if alg not in {"SHA1", "SHA256", "SHA512"}:
        raise ValueError("TOTP algorithm must be SHA1, SHA256, or SHA512")
    return alg


def normalize_totp_config(rec_or_cfg: Dict[str, Any]) -> Dict[str, Any]:
    raw = rec_or_cfg.get("totp") if isinstance(rec_or_cfg.get("totp"), dict) else rec_or_cfg
    raw = raw or {}
    enabled = bool(raw.get("enabled", False) and str(raw.get("secret", "")).strip())
    digits = int(raw.get("digits", TOTP_DEFAULT_DIGITS) or TOTP_DEFAULT_DIGITS)
    if digits not in {6, 8}:
        digits = TOTP_DEFAULT_DIGITS
    period = int(raw.get("period", TOTP_DEFAULT_PERIOD) or TOTP_DEFAULT_PERIOD)
    if period not in {30, 60}:
        period = TOTP_DEFAULT_PERIOD
    try:
        alg = normalize_totp_algorithm(raw.get("algorithm", TOTP_DEFAULT_ALGORITHM))
    except Exception:
        alg = TOTP_DEFAULT_ALGORITHM
    secret = str(raw.get("secret", "") or "")
    try:
        secret = normalize_base32_secret(secret) if secret else ""
    except Exception:
        # Preserve the raw secret for existing records but mark disabled until corrected.
        enabled = False
    return {
        "enabled": bool(enabled),
        "secret": secret,
        "issuer": str(raw.get("issuer", "") or ""),
        "account": str(raw.get("account", "") or ""),
        "digits": digits,
        "period": period,
        "algorithm": alg,
    }


def parse_otpauth_uri(uri: str) -> Dict[str, Any]:
    text = str(uri or "").strip()
    if not text.lower().startswith("otpauth://totp/"):
        raise ValueError("Only otpauth://totp/ URIs are supported")
    parsed = urllib.parse.urlparse(text)
    label_text = urllib.parse.unquote(parsed.path.lstrip("/"))
    qs = urllib.parse.parse_qs(parsed.query)
    secret = normalize_base32_secret((qs.get("secret") or [""])[0])
    issuer = (qs.get("issuer") or [""])[0]
    account = label_text
    if ":" in label_text:
        possible_issuer, possible_account = label_text.split(":", 1)
        issuer = issuer or possible_issuer
        account = possible_account
    digits = int((qs.get("digits") or [TOTP_DEFAULT_DIGITS])[0])
    period = int((qs.get("period") or [TOTP_DEFAULT_PERIOD])[0])
    algorithm = normalize_totp_algorithm((qs.get("algorithm") or [TOTP_DEFAULT_ALGORITHM])[0])
    if digits not in {6, 8}:
        digits = TOTP_DEFAULT_DIGITS
    if period not in {30, 60}:
        period = TOTP_DEFAULT_PERIOD
    return {"enabled": True, "secret": secret, "issuer": issuer, "account": account, "digits": digits, "period": period, "algorithm": algorithm}


def _totp_digestmod(algorithm: str):
    alg = normalize_totp_algorithm(algorithm)
    return {"SHA1": hashlib.sha1, "SHA256": hashlib.sha256, "SHA512": hashlib.sha512}[alg]


def generate_totp_code(secret: str, for_time: Optional[int] = None, digits: int = TOTP_DEFAULT_DIGITS, period: int = TOTP_DEFAULT_PERIOD, algorithm: str = TOTP_DEFAULT_ALGORITHM) -> str:
    secret = normalize_base32_secret(secret)
    digits = int(digits)
    if digits not in {6, 8}:
        raise ValueError("TOTP digits must be 6 or 8")
    period = int(period)
    if period not in {30, 60}:
        raise ValueError("TOTP period must be 30 or 60 seconds")
    padded = secret + ("=" * ((8 - len(secret) % 8) % 8))
    key = base64.b32decode(padded, casefold=True)
    counter = int((time.time() if for_time is None else for_time) // period)
    msg = struct.pack(">Q", counter)
    digest = hmac.new(key, msg, _totp_digestmod(algorithm)).digest()
    offset = digest[-1] & 0x0F
    code_int = struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7fffffff
    return str(code_int % (10 ** digits)).zfill(digits)


def totp_remaining_seconds(period: int = TOTP_DEFAULT_PERIOD, for_time: Optional[int] = None) -> int:
    period = int(period)
    now = int(time.time() if for_time is None else for_time)
    return period - (now % period)


def totp_options() -> Dict[str, Any]:
    return {
        "ok": True,
        "scope": "standard authenticator-app TOTP only",
        "inputs": ["manual_secret", "otpauth_uri"],
        "digits": [6, 8],
        "periods": [30, 60],
        "algorithms": ["SHA1", "SHA256", "SHA512"],
        "default_digits": TOTP_DEFAULT_DIGITS,
        "default_period": TOTP_DEFAULT_PERIOD,
        "default_algorithm": TOTP_DEFAULT_ALGORITHM,
        "network_required": False,
        "secret_values_logged": False,
        "sms_email_push_passkeys_supported": False,
    }


def totp_metadata(rec: Dict[str, Any]) -> Dict[str, Any]:
    rec = normalize_record(rec)
    cfg = normalize_totp_config(rec)
    return {
        "id": rec.get("id", ""),
        "title": rec.get("title", ""),
        "username": rec.get("username", ""),
        "url": rec.get("url", ""),
        "category": rec.get("category", "Other"),
        "totp_enabled": bool(cfg.get("enabled")),
        "issuer": cfg.get("issuer", ""),
        "account": cfg.get("account", ""),
        "digits": cfg.get("digits", TOTP_DEFAULT_DIGITS),
        "period": cfg.get("period", TOTP_DEFAULT_PERIOD),
        "algorithm": cfg.get("algorithm", TOTP_DEFAULT_ALGORITHM),
        "secret_included": False,
    }


def totp_report(plain: Dict[str, Any]) -> Dict[str, Any]:
    records = [normalize_record(r) for r in plain.get("records", [])]
    enabled = [totp_metadata(r) for r in records if normalize_totp_config(r).get("enabled")]
    return {"record_count": len(records), "totp_enabled_count": len(enabled), "records": enabled, "secret_values_included": False}


def set_totp_config_on_record(rec: Dict[str, Any], *, secret: str = "", uri: str = "", issuer: str = "", account: str = "", digits: int = TOTP_DEFAULT_DIGITS, period: int = TOTP_DEFAULT_PERIOD, algorithm: str = TOTP_DEFAULT_ALGORITHM, enabled: bool = True) -> Dict[str, Any]:
    if uri:
        cfg = parse_otpauth_uri(uri)
        if issuer:
            cfg["issuer"] = issuer
        if account:
            cfg["account"] = account
    else:
        cfg = {
            "enabled": bool(enabled),
            "secret": normalize_base32_secret(secret),
            "issuer": issuer,
            "account": account,
            "digits": int(digits),
            "period": int(period),
            "algorithm": normalize_totp_algorithm(algorithm),
        }
        cfg = normalize_totp_config(cfg)
        cfg["enabled"] = bool(enabled and cfg.get("secret"))
    rec["totp"] = cfg
    rec["updated_at"] = now_iso()
    return rec


def totp_code_for_record(rec: Dict[str, Any]) -> Dict[str, Any]:
    rec = normalize_record(rec)
    cfg = normalize_totp_config(rec)
    if not cfg.get("enabled") or not cfg.get("secret"):
        raise ValueError("Selected record has no enabled TOTP/2FA secret")
    code = generate_totp_code(cfg["secret"], digits=cfg["digits"], period=cfg["period"], algorithm=cfg["algorithm"])
    return {
        "code": code,
        "digits": cfg["digits"],
        "period": cfg["period"],
        "algorithm": cfg["algorithm"],
        "remaining_seconds": totp_remaining_seconds(cfg["period"]),
        "issuer": cfg.get("issuer", ""),
        "account": cfg.get("account", ""),
    }



def build_otpauth_uri_for_record(rec: Dict[str, Any]) -> str:
    """Build an otpauth:// URI for QR display/export. Contains the TOTP secret; never log this."""
    rec = normalize_record(rec)
    cfg = normalize_totp_config(rec)
    if not cfg.get("enabled") or not cfg.get("secret"):
        raise ValueError("Selected record has no enabled TOTP/2FA secret")
    issuer = cfg.get("issuer") or rec.get("title") or "Sentinel Password Vault"
    account = cfg.get("account") or rec.get("username") or rec.get("title") or "account"
    label_value = f"{issuer}:{account}" if issuer else account
    query = {
        "secret": cfg["secret"],
        "issuer": issuer,
        "digits": str(cfg.get("digits", TOTP_DEFAULT_DIGITS)),
        "period": str(cfg.get("period", TOTP_DEFAULT_PERIOD)),
        "algorithm": cfg.get("algorithm", TOTP_DEFAULT_ALGORITHM),
    }
    return "otpauth://totp/" + urllib.parse.quote(label_value) + "?" + urllib.parse.urlencode(query)

def status_dict() -> Dict[str, Any]:
    p = vault_path()
    return {
        "program": APP_NAME,
        "program_id": PROGRAM_ID,
        "version": VERSION,
        "vault_exists": p.exists(),
        "vault_path": str(p),
        "data_dir": str(data_dir()),
        "audit_log": str(audit_path()),
        "backup_dir": str(backup_dir()),
        "aegis_vault_backup_dir": str(aegis_vault_backup_dir()),
        "root": is_root(),
        "network_required": False,
        "telemetry": False,
        "aegis_ready": True,
        "accessibility_ready": True,
        "theme_ready": theme_status().get("ok", False),
        "totp_ready": True,
    }


def self_check() -> Dict[str, Any]:
    checks: List[Dict[str, Any]] = []
    def add(name: str, ok: bool, detail: str = "") -> None:
        checks.append({"name": name, "ok": bool(ok), "detail": detail})
    add("cryptography_import", True, "cryptography loaded")
    add("not_root_runtime", not is_root(), "root is refused for vault actions" if not is_root() else "running as root; introspection only")
    add("data_paths_defined", True, f"vault={vault_path()} backup_dir={backup_dir()}")
    add("clipboard_auto_clear_policy", CLIPBOARD_CLEAR_SECONDS > 0, f"{CLIPBOARD_CLEAR_SECONDS}s")
    add("auto_lock_policy", AUTO_LOCK_SECONDS >= 60, f"{AUTO_LOCK_SECONDS}s")
    add("accessibility_policy", True, "keyboard shortcuts, visible status text, screen-reader labels documented")
    ts = theme_status()
    add("theme_completion", bool(ts.get("ok")), "buttons/checkbuttons/dropdowns/top-menu/popups/listboxes/focus/status/secret-view/generator/right-click-details/TOTP surfaces declared Sentinel-themed")
    add("compact_gui_layout", ts.get("surfaces", {}).get("compact_window_layout", False), "default window 1120x640, wider record list, compact main actions, top-menu utility actions, reduced notes height, compact tree/list sizing")
    add("top_menu_action_consolidation", ts.get("surfaces", {}).get("top_menu_action_consolidation", False), "Vault/Records/Security/2FA/Tools/Help menu replaces unnecessary main buttons")
    add("secret_view_policy", True, "GUI View Secret requires explicit user click and audit event without plaintext")
    add("record_save_policy", True, "new-record saves append by default; selected records update only while explicitly editing")
    add("right_click_details", True, "record list supports right-click details without plaintext secret auto-reveal")
    add("totp_setup_persistence", True, "GUI 2FA setup writes to the original encrypted record; checkbox prompts registration when needed")
    add("totp_authenticator_panel", True, "2FA Authenticator panel is available without first selecting a password record")
    add("totp_non_technical_setup_wizard", True, "2FA setup uses clear website/account/setup-key steps and hides advanced options by default")
    add("totp_paste_full_instructions_parser", True, "2FA setup accepts bare keys, otpauth URIs, or pasted instruction text")
    add("totp_qr_canvas_renderer", True, "2FA QR renderer uses a local Tk canvas renderer when qrcode is available; otpauth URI fallback is shown otherwise")
    add("totp_qr_manual_render_only", True, "2FA QR is rendered only after explicit Show Setup QR click; countdown refresh no longer redraws QR")
    add("browser_2fa_provider_cli", True, "Sentinel Browser can request metadata-only 2FA quick-view data from Password Vault CLI")
    add("browser_2fa_popup_receiver", browser_2fa_popup_status().get("ok", False), "restricted Vault-owned browser 2FA popup receiver exposes --browser-2fa-popup")
    add("browser_2fa_popup_cleanup_buttons", True, "browser mini popup declares Setup 2FA and Open Vault buttons for browser toolbar use")
    add("browser_2fa_popup_visible_button_rows", True, "browser mini popup uses two visible symmetrical button rows so actions do not overflow off-window")
    add("browser_2fa_popup_status_required", "browser_2fa_popup_status" in browser_provider_compatibility_report().get("required_actions", []), "browser provider compatibility now requires popup status receiver")
    add("browser_origin_matcher", True, "Browser-origin host matching ranks local 2FA entries without exposing codes")
    add("browser_user_approved_totp_code_endpoint", True, "Browser code endpoint requires explicit --yes user approval")
    fill_status = browser_password_popup_status()
    save_status = browser_save_popup_status()
    add("browser_password_fill_popup", fill_status.get("ok") is True and fill_status.get("restricted_popup") is True and fill_status.get("secret_values_in_command_line") is False and fill_status.get("auto_submit") is False, "restricted user-approved fill popup uses a one-shot pipe and never auto-submits")
    add("browser_save_update_popup", save_status.get("ok") is True and save_status.get("restricted_popup") is True and save_status.get("secret_values_in_command_line") is False and save_status.get("candidate_transport") == "stdin_json_pipe", "restricted save/update popup receives the candidate through stdin")
    add("browser_save_login_provider", True, "Browser can send user-approved login save requests to Password Vault instead of storing passwords itself")
    add("browser_save_candidate_preview", True, "Browser save preview returns metadata/warnings without echoing plaintext password")
    add("browser_save_duplicate_match_provider", True, "Browser can ask the unlocked vault for metadata-only existing login matches before saving")
    add("browser_update_login_provider", True, "Browser can update an existing login record after explicit user approval instead of creating duplicates")
    add("browser_user_approved_save_endpoint", True, "Browser save/update endpoints require explicit --yes/approved user confirmation")
    add("record_list_wider_layout", True, "record list pane widened and form/input pane kept compact for fewer horizontal scroll needs")
    add("record_double_click_details", True, "record list double-click opens the selected record details dialog")
    add("random_password_generator", len(generate_password(24, avoid_ambiguous=True)) == 24, "configurable local random generator available")
    try:
        test_secret = b32_no_padding(b"12345678901234567890")
        # RFC 6238 SHA1 vector at Unix time 59 is 94287082 for 8 digits.
        add("totp_rfc6238_sha1", generate_totp_code(test_secret, for_time=59, digits=8, period=30, algorithm="SHA1") == "94287082", "standard TOTP/HOTP implementation passes RFC 6238 SHA1 vector")
    except Exception as exc:
        add("totp_rfc6238_sha1", False, type(exc).__name__)
    add("release_candidate_hardening", True, "integrity/backup-freshness/browser-provider/TOTP health/release-candidate reports available")
    add("stable_lock_report", True, "v1.0 stable-lock report available for final baseline verification")
    add("browser_provider_compatibility_report", browser_provider_compatibility_report().get("ok", False), "browser save/update and 2FA provider contract checks declared")
    add("aegis_capability", True, "manifest/status/self_check/review_status/category_report/password_health/duplicate_report/backup_list/security_dashboard/theme_status/random_password_options/generate_password/totp_options/totp_report/totp_code/release_candidate_report/stable_lock_report actions available")
    add("record_organization", True, "categories/tags/favorites/notes/timestamps supported")
    add("workflow_recovery", True, "backup list/restore, metadata export, guarded decrypted JSON export/import, view secret, random generator, TOTP setup/code supported")
    # Browser credential matching and save/update behavior in memory only.
    try:
        match_plain = empty_vault()
        match_plain["records"] = [
            {"id": "exact", "title": "Example", "username": "alice", "password": "secret-one", "url": "https://auth.example.com/login", "notes": "", "tags": [], "category": "Browser", "favorite": False, "created_at": now_iso(), "updated_at": now_iso()},
            {"id": "apex", "title": "Apex", "username": "bob", "password": "secret-two", "url": "https://example.com/login", "notes": "", "tags": [], "category": "Browser", "favorite": False, "created_at": now_iso(), "updated_at": now_iso()},
            {"id": "unrelated", "title": "Other", "username": "alice", "password": "secret-three", "url": "https://evil-example.com/login", "notes": "", "tags": [], "category": "Browser", "favorite": False, "created_at": now_iso(), "updated_at": now_iso()},
        ]
        exact_rows = browser_password_fill_matches(match_plain, "https://auth.example.com/signin")
        apex_rows = browser_password_fill_matches(match_plain, "https://example.com/signin")
        add("browser_password_exact_host_only", [r["record"]["id"] for r in exact_rows] == ["exact"] and [r["record"]["id"] for r in apex_rows] == ["apex"], "password fill never falls back from an apex host to a subdomain or unrelated sibling")
        match_report = browser_save_existing_matches_payload(match_plain, origin="https://auth.example.com", username="alice", url="https://auth.example.com/login")
        add("browser_update_match_exact_host_only", [r.get("id") for r in match_report.get("existing_matches", [])] == ["exact"], "save/update chooser cannot offer records from another host")
        saved = browser_save_login_payload(match_plain, origin="https://new.example.net", title="New", username="new-user", password="new-secret", url="https://new.example.net/login")
        saved_id = saved.get("record", {}).get("id", "")
        updated = browser_update_login_payload(match_plain, saved_id, origin="https://new.example.net", username="new-user", password="changed-secret", url="https://new.example.net/login")
        stored = resolve_record(match_plain, saved_id)
        add("browser_save_update_round_trip", saved.get("saved") is True and updated.get("updated") is True and stored.get("password") == "changed-secret" and saved.get("secret_values_included") is False and updated.get("secret_values_included") is False, "approved browser credentials save and update through normal encrypted-record primitives without echoing secrets")
    except Exception as exc:
        add("browser_credential_in_memory_tests", False, type(exc).__name__)
    # Crypto round trip in memory only.
    try:
        pw = "Sentinel-Test-Master-Password-123!"
        plain = empty_vault()
        plain["records"].append({"id": "test", "title": "test", "username": "u", "password": "p", "url": "", "notes": "", "tags": [], "category": "Test", "favorite": False, "created_at": now_iso(), "updated_at": now_iso()})
        enc = encrypt_vault(plain, pw)
        dec = decrypt_vault(enc, pw)
        add("crypto_round_trip", dec["records"][0]["password"] == "p", "AES-GCM encrypt/decrypt round trip")
    except Exception as exc:
        add("crypto_round_trip", False, type(exc).__name__)
    ok = all(c["ok"] for c in checks if c["name"] != "not_root_runtime")
    return {"program": APP_NAME, "version": VERSION, "ok": ok, "checks": checks}


def _age_seconds(path: Path) -> Optional[int]:
    try:
        return max(0, int(time.time() - path.stat().st_mtime))
    except Exception:
        return None


def _age_label(seconds: Optional[int]) -> str:
    if seconds is None:
        return "unknown"
    days = seconds // 86400
    hours = (seconds % 86400) // 3600
    if days:
        return f"{days}d {hours}h"
    minutes = (seconds % 3600) // 60
    if hours:
        return f"{hours}h {minutes}m"
    return f"{minutes}m"


def backup_freshness_report() -> Dict[str, Any]:
    local_rows = list_encrypted_backups()
    aegis_rows = list_aegis_vault_backups()
    warnings: List[str] = []

    def latest_age(rows: List[Dict[str, Any]]) -> Optional[int]:
        if not rows:
            return None
        try:
            return _age_seconds(Path(rows[0]["path"]))
        except Exception:
            return None

    local_age = latest_age(local_rows)
    aegis_age = latest_age(aegis_rows)
    if vault_path().exists() and not local_rows:
        warnings.append("no_local_encrypted_backup_found")
    if vault_path().exists() and not aegis_rows:
        warnings.append("no_aegis_vault_backup_found")
    if local_age is not None and local_age > 7 * 86400:
        warnings.append("local_backup_older_than_7_days")
    if aegis_age is not None and aegis_age > 7 * 86400:
        warnings.append("aegis_backup_older_than_7_days")
    return {
        "ok": not warnings,
        "vault_exists": vault_path().exists(),
        "local_backup_count": len(local_rows),
        "latest_local_backup": local_rows[0] if local_rows else None,
        "latest_local_backup_age": _age_label(local_age),
        "aegis_backup_count": len(aegis_rows),
        "latest_aegis_backup": aegis_rows[0] if aegis_rows else None,
        "latest_aegis_backup_age": _age_label(aegis_age),
        "aegis_backup_dir": str(aegis_vault_backup_dir()),
        "warnings": warnings,
        "encrypted_only": True,
        "plaintext_secret_exported": False,
    }


def vault_data_integrity_report(plain: Dict[str, Any]) -> Dict[str, Any]:
    records = [normalize_record(r) for r in plain.get("records", [])]
    warnings: List[str] = []
    ids: Dict[str, int] = {}
    missing_title: List[str] = []
    missing_username: List[str] = []
    missing_password: List[str] = []
    bad_totp: List[Dict[str, Any]] = []
    for rec in records:
        rid = str(rec.get("id", "")) or "missing-id"
        ids[rid] = ids.get(rid, 0) + 1
        if not str(rec.get("title", "")).strip():
            missing_title.append(rid)
        if not str(rec.get("username", "")).strip():
            missing_username.append(rid)
        if not str(rec.get("password", "")).strip() and not normalize_totp_config(rec).get("enabled"):
            missing_password.append(rid)
        cfg = normalize_totp_config(rec)
        if cfg.get("enabled"):
            try:
                generate_totp_code(cfg.get("secret", ""), digits=int(cfg.get("digits", TOTP_DEFAULT_DIGITS)), period=int(cfg.get("period", TOTP_DEFAULT_PERIOD)), algorithm=str(cfg.get("algorithm", TOTP_DEFAULT_ALGORITHM)))
            except Exception as exc:
                bad_totp.append({"id": rid, "title": rec.get("title", ""), "error": type(exc).__name__})
    duplicate_ids = sorted([rid for rid, count in ids.items() if count > 1])
    if duplicate_ids:
        warnings.append("duplicate_record_ids")
    if missing_title:
        warnings.append("records_missing_title")
    if bad_totp:
        warnings.append("totp_entries_with_invalid_config")
    return {
        "ok": not duplicate_ids and not bad_totp,
        "schema": VAULT_RECORD_SCHEMA,
        "record_count": len(records),
        "totp_enabled_count": sum(1 for r in records if normalize_totp_config(r).get("enabled")),
        "favorite_count": sum(1 for r in records if bool(r.get("favorite"))),
        "duplicate_record_ids": duplicate_ids,
        "records_missing_title": missing_title,
        "records_missing_username_count": len(missing_username),
        "records_missing_secret_count": len(missing_password),
        "invalid_totp_entries": bad_totp,
        "warnings": warnings,
        "plaintext_secrets_returned": False,
    }


def totp_health_report(plain: Dict[str, Any]) -> Dict[str, Any]:
    records = [normalize_record(r) for r in plain.get("records", [])]
    entries: List[Dict[str, Any]] = []
    issues: List[Dict[str, Any]] = []
    now = int(time.time())
    for rec in records:
        cfg = normalize_totp_config(rec)
        if not cfg.get("enabled"):
            continue
        item = totp_metadata(rec)
        try:
            code = generate_totp_code(cfg.get("secret", ""), for_time=now, digits=int(cfg.get("digits", TOTP_DEFAULT_DIGITS)), period=int(cfg.get("period", TOTP_DEFAULT_PERIOD)), algorithm=str(cfg.get("algorithm", TOTP_DEFAULT_ALGORITHM)))
            item.update({"ok": True, "code_generated": bool(code), "code_included": False, "secret_included": False})
        except Exception as exc:
            item.update({"ok": False, "error": type(exc).__name__, "code_included": False, "secret_included": False})
            issues.append({"id": rec.get("id"), "title": rec.get("title", ""), "error": type(exc).__name__})
        entries.append(item)
    return {"ok": not issues, "totp_count": len(entries), "issues": issues, "entries": entries, "plaintext_totp_secrets_returned": False, "plaintext_totp_codes_returned": False}


def browser_provider_compatibility_report() -> Dict[str, Any]:
    bridge = browser_bridge_status()
    required_actions = [
        "browser_2fa_popup_status", "browser_2fa_popup",
        "browser_2fa_quick_view", "browser_2fa_code", "browser_save_preview",
        "browser_save_matches", "browser_save_login", "browser_update_login"
    ]
    supported = set(bridge.get("supported_browser_actions", []))
    missing = [a for a in required_actions if a not in supported]
    contract = {
        "provider_ready": bridge.get("bridge_stage") == "provider_ready",
        "save_login_provider_ready": bool(bridge.get("save_login_provider_ready")),
        "save_login_match_provider_ready": bool(bridge.get("save_login_match_provider_ready")),
        "update_login_provider_ready": bool(bridge.get("update_login_provider_ready")),
        "requires_user_unlock": bool(bridge.get("requires_user_unlock")),
        "requires_explicit_user_approval_for_secret_or_totp_code": bool(bridge.get("requires_explicit_user_approval_for_secret_or_totp_code")),
        "plaintext_secret_default_access": bool(bridge.get("plaintext_secret_default_access")),
        "browser_2fa_popup_receiver": bool(bridge.get("browser_2fa_popup_receiver")),
        "browser_2fa_popup_status_receiver": bool(bridge.get("browser_2fa_popup_status_receiver")),
        "browser_2fa_popup_command_declared": bool(bridge.get("browser_2fa_popup_command")),
    }
    ok = not missing and all(v for k, v in contract.items() if k != "plaintext_secret_default_access") and contract["plaintext_secret_default_access"] is False
    return {"ok": ok, "version": VERSION, "browser_program_id": "110", "required_actions": required_actions, "missing_actions": missing, "contract": contract, "supported_browser_actions": sorted(supported), "browser_should_discard_plaintext_after_save_or_update": True, "plaintext_password_echoed_by_provider": False, "plaintext_totp_secret_echoed_by_provider": False, "password_secret_in_argv": False, "password_save_transport": "stdin_or_restricted_popup", "password_fill_transport": "one_shot_stdout_pipe_after_user_approval"}


def release_candidate_report(plain: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
    sc = self_check()
    backup = backup_freshness_report()
    browser = browser_provider_compatibility_report()
    integrity = vault_data_integrity_report(plain) if plain is not None else {"ok": None, "skipped": True, "reason": "vault unlock not supplied"}
    totp = totp_health_report(plain) if plain is not None else {"ok": None, "skipped": True, "reason": "vault unlock not supplied"}
    root_safe = not is_root()
    ready_checks = [bool(sc.get("ok")), bool(browser.get("ok")), root_safe]
    if integrity.get("ok") is not None:
        ready_checks.append(bool(integrity.get("ok")))
    if totp.get("ok") is not None:
        ready_checks.append(bool(totp.get("ok")))
    warnings = []
    if not backup.get("ok"):
        warnings.extend(["backup:" + w for w in backup.get("warnings", [])])
    return {
        "ok": all(ready_checks),
        "program": APP_NAME,
        "program_id": PROGRAM_ID,
        "version": VERSION,
        "phase": "release_candidate_hardening",
        "v1_lock_candidate": all(ready_checks),
        "self_check_ok": bool(sc.get("ok")),
        "browser_provider_ok": bool(browser.get("ok")),
        "integrity_ok": integrity.get("ok"),
        "totp_health_ok": totp.get("ok"),
        "backup_freshness_ok": bool(backup.get("ok")),
        "root_runtime_ok": root_safe,
        "warnings": warnings,
        "reports": {"self_check": sc, "backup_freshness": backup, "browser_provider_compatibility": browser, "integrity": integrity, "totp_health": totp},
    }


def cmd_integrity_check(args: argparse.Namespace) -> int:
    _container, plain, _master = unlock()
    result = vault_data_integrity_report(plain)
    print(json.dumps(result, indent=2, sort_keys=True))
    audit("integrity_check", "cli")
    return 0 if result.get("ok") else 1


def cmd_backup_freshness(args: argparse.Namespace) -> int:
    result = backup_freshness_report()
    print(json.dumps(result, indent=2, sort_keys=True))
    audit("backup_freshness", "cli")
    return 0 if result.get("ok") else 1


def cmd_browser_provider_compatibility(args: argparse.Namespace) -> int:
    result = browser_provider_compatibility_report()
    print(json.dumps(result, indent=2, sort_keys=True))
    audit("browser_provider_compatibility", "cli")
    return 0 if result.get("ok") else 1


def cmd_totp_health(args: argparse.Namespace) -> int:
    _container, plain, _master = unlock()
    result = totp_health_report(plain)
    print(json.dumps(result, indent=2, sort_keys=True))
    audit("totp_health", "cli")
    return 0 if result.get("ok") else 1



def stable_lock_report(plain: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
    """Final v1.0 stable-lock readiness report. Metadata-only unless caller supplies an unlocked vault."""
    rc = release_candidate_report(plain)
    browser = browser_provider_compatibility_report()
    sc = self_check()
    theme = theme_status()
    popup = browser_2fa_popup_status()
    checks = {
        "self_check": bool(sc.get("ok")),
        "release_candidate_report": bool(rc.get("ok")),
        "browser_provider_compatibility": bool(browser.get("ok")),
        "theme_report": bool(theme.get("ok")),
        "browser_2fa_popup_receiver": bool(popup.get("ok")),
        "encrypted_local_vault_policy": True,
        "plaintext_secret_logging_disabled": True,
        "root_runtime_refusal": True,
        "aegis_ready": True,
        "browser_ready": True,
        "totp_ready": True,
        "backup_restore_ready": True,
    }
    return {
        "ok": all(checks.values()),
        "program": APP_NAME,
        "program_id": PROGRAM_ID,
        "version": VERSION,
        "phase": "v1_stable_lock",
        "stable_lock": True,
        "baseline": f"Program {PROGRAM_ID} {APP_NAME} v{VERSION}",
        "checks": checks,
        "release_candidate": rc,
        "browser_provider": browser,
        "browser_popup": popup,
        "post_v1_policy": "Only focused bug fixes, browser integration refinements, security hardening, import/export polish, and compatibility patches should follow unless explicitly superseded.",
        "plaintext_secrets_returned": False,
        "network_required": False,
        "telemetry": False,
    }


def cmd_stable_lock_report(args: argparse.Namespace) -> int:
    plain = None
    if not getattr(args, "no_unlock", False) and vault_path().exists():
        try:
            _container, plain, _master = unlock()
        except Exception as exc:
            print(json.dumps({"ok": False, "error": type(exc).__name__, "hint": "Use --stable-lock-report --no-unlock for metadata-only lock reporting, or unlock the vault."}, indent=2, sort_keys=True))
            return 1
    result = stable_lock_report(plain)
    print(json.dumps(result, indent=2, sort_keys=True))
    audit("stable_lock_report", "cli")
    return 0

def cmd_release_candidate_report(args: argparse.Namespace) -> int:
    plain = None
    if vault_path().exists() and not getattr(args, "no_unlock", False):
        _container, plain, _master = unlock()
    result = release_candidate_report(plain)
    print(json.dumps(result, indent=2, sort_keys=True))
    audit("release_candidate_report", "cli")
    return 0 if result.get("ok") else 1


def cmd_init(args: argparse.Namespace) -> int:
    ensure_dirs()
    p = vault_path()
    if p.exists() and not args.force:
        print(f"ERROR: Vault already exists: {p}", file=sys.stderr)
        print("Use --force only if you intentionally want to overwrite it.", file=sys.stderr)
        return 1
    master = prompt_master(confirm=True)
    plain = empty_vault()
    container = encrypt_vault(plain, master)
    save_container(container, p)
    audit("init_vault", "created vault")
    print(f"Created encrypted Sentinel Password Vault: {p}")
    return 0


def cmd_add(args: argparse.Namespace) -> int:
    if not vault_path().exists():
        print("ERROR: No vault exists yet. Run: sentinel-password-vault --init", file=sys.stderr)
        return 1
    container, plain, master = unlock()
    title = args.title or input("Title: ").strip()
    username = args.username or input("Username: ").strip()
    url = args.url or input("URL/service: ").strip()
    if args.password:
        password = args.password
    elif args.generate:
        password = generate_password(args.length, symbols=not getattr(args, "no_symbols", False), avoid_ambiguous=getattr(args, "avoid_ambiguous", False))
        print("Generated password for this record.")
    else:
        password = getpass.getpass("Password/secret: ")
    notes = args.notes or input("Notes: ").strip()
    category = args.category or input("Category [Other]: ").strip() or "Other"
    tags = [t.strip() for t in (args.tags or "").split(",") if t.strip()]
    ts = now_iso()
    rec = {"id": str(uuid.uuid4()), "title": title, "username": username, "url": url, "password": password, "notes": notes, "category": category, "tags": tags, "favorite": bool(args.favorite), "created_at": ts, "updated_at": ts}
    if getattr(args, "totp_secret", None) or getattr(args, "totp_uri", None):
        set_totp_config_on_record(rec, secret=args.totp_secret or "", uri=args.totp_uri or "", issuer=args.totp_issuer or title, account=args.totp_account or username, digits=args.totp_digits, period=args.totp_period, algorithm=args.totp_algorithm, enabled=True)
    plain.setdefault("records", []).append(rec)
    plain["updated_at"] = ts
    save_container(encrypt_vault(plain, master, container))
    audit("add_record", f"id={rec['id']} title={title[:80]}")
    print(f"Added record: {title} [{rec['id']}]")
    return 0


def cmd_list(args: argparse.Namespace) -> int:
    if not vault_path().exists():
        print("No vault exists yet.")
        return 0
    _, plain, _ = unlock()
    records = [record_metadata(normalize_record(r)) for r in plain.get("records", [])]
    sort_key = getattr(args, "sort", "title") or "title"
    records.sort(key=lambda r: str(r.get(sort_key if sort_key in {"title","category","username","updated_at","created_at"} else "title", "")).lower(), reverse=False)
    if args.json:
        print(json.dumps({"records": records}, indent=2))
    else:
        if not records:
            print("No records.")
        for r in records:
            tags = ",".join(r.get("tags", []))
            print(f"{r['id']} | {'★' if r.get('favorite') else '.'} | {r.get('category','Other')} | {r['title']} | {r['username']} | {r['url']} | {tags}")
    audit("list_metadata", f"count={len(records)}")
    return 0


def cmd_search(args: argparse.Namespace) -> int:
    _, plain, _ = unlock()
    matches = [record_metadata(r) for r in find_records(plain, args.query)]
    if args.json:
        print(json.dumps({"query": args.query, "records": matches}, indent=2))
    else:
        for r in matches:
            print(f"{r['id']} | {'★' if r.get('favorite') else '.'} | {r.get('category','Other')} | {r['title']} | {r['username']} | {r['url']}")
        if not matches:
            print("No matches.")
    audit("search_metadata", f"query={args.query[:80]} count={len(matches)}")
    return 0


def cmd_show(args: argparse.Namespace) -> int:
    _, plain, _ = unlock()
    rec = resolve_record(plain, args.ident)
    field = args.field
    if field == "all":
        safe = dict(rec)
        if not args.reveal:
            safe["password"] = "••••••••"
            if isinstance(safe.get("totp"), dict) and safe["totp"].get("secret"):
                safe["totp"] = dict(safe["totp"])
                safe["totp"]["secret"] = "••••••••"
        print(json.dumps(safe, indent=2))
    else:
        value = rec.get(field, "")
        if field == "password" and not args.reveal:
            print("Password/secret is hidden. Re-run with --reveal to print it.")
        else:
            print(value)
    audit("show_record", f"id={rec.get('id')} field={field} reveal={args.reveal}")
    return 0


def cmd_delete(args: argparse.Namespace) -> int:
    container, plain, master = unlock()
    rec = resolve_record(plain, args.ident)
    if not args.yes:
        ans = input(f"Delete record '{rec.get('title')}'? Type DELETE to confirm: ")
        if ans != "DELETE":
            print("Cancelled.")
            return 1
    rid = rec.get("id")
    plain["records"] = [r for r in plain.get("records", []) if r.get("id") != rid]
    plain["updated_at"] = now_iso()
    save_container(encrypt_vault(plain, master, container))
    audit("delete_record", f"id={rid} title={rec.get('title','')[:80]}")
    print("Deleted record.")
    return 0


def cmd_backup(args: argparse.Namespace) -> int:
    if not vault_path().exists():
        print("ERROR: No vault exists yet. Nothing to back up.", file=sys.stderr)
        return 1
    dest = create_encrypted_backup(args.backup_label or "manual")
    print(f"Created encrypted vault backup: {dest}")
    return 0


def cmd_change_master_password(args: argparse.Namespace) -> int:
    if not vault_path().exists():
        print("ERROR: No vault exists yet.", file=sys.stderr)
        return 1
    container, plain, _old_master = unlock()
    backup = create_encrypted_backup("before-master-change")
    new_master = prompt_master(confirm=True)
    # Re-encrypt with a fresh salt by not passing the old container.
    new_container = encrypt_vault(plain, new_master, None)
    new_container["created_at"] = container.get("created_at", new_container.get("created_at"))
    save_container(new_container)
    audit("change_master_password", f"backup={backup}")
    print("Master password changed.")
    print(f"Encrypted backup created first: {backup}")
    return 0




def cmd_add_totp(args: argparse.Namespace) -> int:
    if not vault_path().exists():
        print("ERROR: No vault exists yet.", file=sys.stderr)
        return 1
    container, plain, master = unlock()
    rec = resolve_record(plain, args.add_totp)
    secret = args.totp_secret or ""
    uri = args.totp_uri or ""
    if not secret and not uri:
        entered = input("TOTP manual secret or otpauth:// URI: ").strip()
        uri = entered if entered.lower().startswith("otpauth://") else ""
        secret = "" if uri else entered
    try:
        set_totp_config_on_record(
            rec,
            secret=secret,
            uri=uri,
            issuer=args.totp_issuer or rec.get("title", ""),
            account=args.totp_account or rec.get("username", ""),
            digits=args.totp_digits,
            period=args.totp_period,
            algorithm=args.totp_algorithm,
            enabled=True,
        )
    except Exception as exc:
        print(f"ERROR: Could not set TOTP: {exc}", file=sys.stderr)
        return 2
    plain["updated_at"] = now_iso()
    save_container(encrypt_vault(plain, master, container))
    audit("add_totp", f"id={rec.get('id')}")
    print(f"Added/updated TOTP for record: {rec.get('title')} [{rec.get('id')}]")
    return 0


def cmd_totp_code(args: argparse.Namespace) -> int:
    _, plain, _ = unlock()
    rec = resolve_record(plain, args.totp_code)
    try:
        out = totp_code_for_record(rec)
    except Exception as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        return 2
    audit("totp_code", f"id={rec.get('id')}")
    if args.json:
        print(json.dumps({"record": record_metadata(rec), **out, "secret_included": False}, indent=2, sort_keys=True))
    else:
        print(f"{out['code']}  ({out['remaining_seconds']}s remaining, {out['digits']} digits, {out['algorithm']})")
    return 0


def cmd_totp_report(args: argparse.Namespace) -> int:
    _, plain, _ = unlock()
    print(json.dumps(totp_report(plain), indent=2, sort_keys=True))
    audit("totp_report", "cli")
    return 0

def cmd_audit_tail(args: argparse.Namespace) -> int:
    rows = audit_tail_lines(args.audit_tail)
    if args.json:
        print(json.dumps({"audit": rows}, indent=2, sort_keys=True))
    else:
        for row in rows:
            print(json.dumps(row, sort_keys=True))
        if not rows:
            print("No audit entries yet.")
    return 0



def cmd_list_backups(args: argparse.Namespace) -> int:
    rows = list_encrypted_backups()
    if args.json:
        print(json.dumps({"backups": rows}, indent=2, sort_keys=True))
    else:
        if not rows:
            print("No encrypted backups found.")
        for row in rows:
            print(f"{row['name']} | {row['modified_at']} | {row['size_bytes']} bytes")
    audit("list_backups", f"count={len(rows)}")
    return 0


def cmd_restore_backup(args: argparse.Namespace) -> int:
    if not args.yes:
        print("ERROR: Restore overwrites the current encrypted vault. Re-run with --yes after confirming.", file=sys.stderr)
        return 2
    result = restore_encrypted_backup(args.restore_backup, backup_current=True)
    print(json.dumps(result, indent=2, sort_keys=True))
    return 0



def cmd_aegis_backup(args: argparse.Namespace) -> int:
    result = create_aegis_vault_backup(args.backup_label or "manual")
    print(json.dumps(result, indent=2, sort_keys=True))
    return 0

def cmd_list_aegis_backups(args: argparse.Namespace) -> int:
    rows = list_aegis_vault_backups()
    if args.json:
        print(json.dumps({"backups": rows, "backup_dir": str(aegis_vault_backup_dir())}, indent=2, sort_keys=True))
    else:
        print(f"AEGIS Vault backup dir: {aegis_vault_backup_dir()}")
        if not rows:
            print("No AEGIS Vault Password Vault backups found.")
        for row in rows:
            print(f"{row['name']} | {row['modified_at']} | {row['size_bytes']} bytes")
    audit("list_aegis_vault_backups", f"count={len(rows)}")
    return 0

def cmd_restore_aegis_backup(args: argparse.Namespace) -> int:
    if not args.yes:
        print("ERROR: Restoring from AEGIS Vault overwrites the current encrypted vault. Re-run with --yes after confirming.", file=sys.stderr)
        return 2
    result = restore_aegis_vault_backup(args.restore_aegis_backup, backup_current=True)
    print(json.dumps(result, indent=2, sort_keys=True))
    return 0

def cmd_browser_bridge_status(args: argparse.Namespace) -> int:
    print(json.dumps(browser_bridge_status(), indent=2, sort_keys=True))
    audit("browser_bridge_status", "cli")
    return 0

def cmd_browser_toolbar_spec(args: argparse.Namespace) -> int:
    print(json.dumps(browser_toolbar_spec(), indent=2, sort_keys=True))
    audit("browser_toolbar_spec", "cli")
    return 0


def cmd_browser_2fa_popup_status(args: argparse.Namespace) -> int:
    print(json.dumps(browser_2fa_popup_status(), indent=2, sort_keys=True))
    audit("browser_2fa_popup_status", "cli")
    return 0


def cmd_browser_2fa_popup(args: argparse.Namespace) -> int:
    return browser_2fa_popup_gui(getattr(args, "browser_origin", "") or "")


def cmd_browser_2fa_quick_view(args: argparse.Namespace) -> int:
    _, plain, _ = unlock()
    out = browser_2fa_quick_view(plain, getattr(args, "browser_origin", "") or "")
    print(json.dumps(out, indent=2, sort_keys=True))
    audit("browser_2fa_quick_view", f"origin={getattr(args, 'browser_origin', '') or ''} matches={out.get('match_count', 0)}")
    return 0


def cmd_browser_2fa_code(args: argparse.Namespace) -> int:
    if not args.yes:
        print("ERROR: Browser 2FA code release requires explicit user approval. Re-run with --yes after a visible user click.", file=sys.stderr)
        return 2
    _, plain, _ = unlock()
    out = browser_2fa_code_payload(plain, args.browser_2fa_code, getattr(args, "browser_origin", "") or "")
    print(json.dumps(out, indent=2, sort_keys=True))
    audit("browser_2fa_code", f"id={out.get('record', {}).get('id')} origin={getattr(args, 'browser_origin', '') or ''}")
    return 0


def cmd_browser_save_preview(args: argparse.Namespace) -> int:
    out = browser_save_candidate_preview_payload(
        origin=getattr(args, "browser_origin", "") or "",
        title=getattr(args, "browser_title", "") or "",
        username=getattr(args, "browser_username", "") or "",
        url=getattr(args, "browser_url", "") or "",
        category=getattr(args, "browser_category", "") or "Browser",
        notes=getattr(args, "browser_notes", "") or "",
    )
    print(json.dumps(out, indent=2, sort_keys=True))
    audit("browser_save_preview", f"origin={getattr(args, 'browser_origin', '') or ''}")
    return 0


def cmd_browser_save_matches(args: argparse.Namespace) -> int:
    _container, plain, _master = unlock()
    out = browser_save_existing_matches_payload(
        plain,
        origin=getattr(args, "browser_origin", "") or "",
        title=getattr(args, "browser_title", "") or "",
        username=getattr(args, "browser_username", "") or "",
        url=getattr(args, "browser_url", "") or "",
    )
    print(json.dumps(out, indent=2, sort_keys=True))
    audit("browser_save_matches", f"origin={getattr(args, 'browser_origin', '') or ''} matches={out.get('match_count', 0)}")
    return 0

def _legacy_browser_password_from_stdin(args: argparse.Namespace) -> str:
    """Read a legacy browser save/update password from stdin, never argv."""
    if getattr(args, "browser_password", None):
        raise ValueError("--browser-password is disabled because process arguments are observable; use --browser-password-stdin")
    if not getattr(args, "browser_password_stdin", False):
        raise ValueError("--browser-password-stdin is required")
    raw = sys.stdin.buffer.read(4097)
    if len(raw) > 4096:
        raise ValueError("browser password exceeds 4096 bytes")
    try:
        password = raw.decode("utf-8")
    except UnicodeDecodeError as exc:
        raise ValueError("browser password stdin must be UTF-8") from exc
    password = password.rstrip("\r\n")
    if not password:
        raise ValueError("browser password stdin is empty")
    return password


def cmd_browser_update_login(args: argparse.Namespace) -> int:
    if not getattr(args, "yes", False):
        print("ERROR: --browser-update-login requires --yes after explicit visible user approval.", file=sys.stderr)
        return 2
    try:
        browser_password = _legacy_browser_password_from_stdin(args)
    except ValueError as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        return 2
    container, plain, master = unlock()
    out = browser_update_login_payload(
        plain,
        args.browser_update_login,
        origin=getattr(args, "browser_origin", "") or "",
        title=getattr(args, "browser_title", "") or "",
        username=getattr(args, "browser_username", "") or "",
        password=browser_password,
        url=getattr(args, "browser_url", "") or "",
        notes=getattr(args, "browser_notes", "") or "",
        category=getattr(args, "browser_category", "") or "",
        tags=getattr(args, "browser_tags", "") or "browser,saved-login,updated-from-browser",
    )
    save_container(encrypt_vault(plain, master, container))
    print(json.dumps(out, indent=2, sort_keys=True))
    audit("browser_update_login", f"id={out.get('record', {}).get('id')} origin={getattr(args, 'browser_origin', '') or ''} warnings={len(out.get('warnings', []))}")
    return 0

def cmd_browser_save_login(args: argparse.Namespace) -> int:
    if not args.yes:
        print("ERROR: --browser-save-login requires --yes after an explicit visible user approval prompt.", file=sys.stderr)
        return 1
    try:
        browser_password = _legacy_browser_password_from_stdin(args)
    except ValueError as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        return 1
    container, plain, master = unlock()
    out = browser_save_login_payload(
        plain,
        origin=getattr(args, "browser_origin", "") or "",
        title=getattr(args, "browser_title", "") or "",
        username=getattr(args, "browser_username", "") or "",
        password=browser_password,
        url=getattr(args, "browser_url", "") or "",
        notes=getattr(args, "browser_notes", "") or "",
        category=getattr(args, "browser_category", "") or "Browser",
        tags=getattr(args, "browser_tags", "") or "browser,saved-login",
    )
    save_container(encrypt_vault(plain, master, container))
    print(json.dumps(out, indent=2, sort_keys=True))
    audit("browser_save_login", f"id={out.get('record', {}).get('id')} origin={getattr(args, 'browser_origin', '') or ''} warnings={len(out.get('warnings', []))}")
    return 0


def cmd_browser_password_popup_status(args: argparse.Namespace) -> int:
    print(json.dumps(browser_password_popup_status(), indent=2, sort_keys=True))
    return 0


def cmd_browser_password_popup(args: argparse.Namespace) -> int:
    out = browser_password_popup_gui(getattr(args, "browser_origin", "") or "")
    print(json.dumps(out, separators=(",", ":"), sort_keys=True), flush=True)
    return 0 if out.get("ok") or out.get("cancelled") else 1


def cmd_browser_save_popup_status(args: argparse.Namespace) -> int:
    print(json.dumps(browser_save_popup_status(), indent=2, sort_keys=True))
    return 0


def cmd_browser_save_popup(args: argparse.Namespace) -> int:
    try:
        candidate = _read_browser_candidate_from_stdin()
    except Exception as exc:
        print(json.dumps({"ok": False, "error": str(exc), "secret_values_included": False}, separators=(",", ":"), sort_keys=True), flush=True)
        return 2
    out = browser_save_popup_gui(candidate, getattr(args, "browser_origin", "") or "")
    print(json.dumps(out, separators=(",", ":"), sort_keys=True), flush=True)
    return 0 if out.get("ok") or out.get("cancelled") else 1


def cmd_duplicate_report(args: argparse.Namespace) -> int:
    _, plain, _ = unlock()
    print(json.dumps(duplicate_report(plain), indent=2, sort_keys=True))
    audit("duplicate_report", "cli")
    return 0


def cmd_favorite_report(args: argparse.Namespace) -> int:
    _, plain, _ = unlock()
    print(json.dumps(favorite_report(plain), indent=2, sort_keys=True))
    audit("favorite_report", "cli")
    return 0


def cmd_security_dashboard(args: argparse.Namespace) -> int:
    _, plain, _ = unlock()
    print(json.dumps(security_dashboard(plain), indent=2, sort_keys=True))
    audit("security_dashboard", "cli")
    return 0

def cmd_theme_report(args: argparse.Namespace) -> int:
    print(json.dumps(theme_status(), indent=2, sort_keys=True))
    audit("theme_report", "cli")
    return 0


def cmd_export_metadata(args: argparse.Namespace) -> int:
    _, plain, _ = unlock()
    dest = export_metadata_report(plain, Path(args.export_metadata))
    print(f"Exported metadata-only report: {dest}")
    return 0


def cmd_export_json(args: argparse.Namespace) -> int:
    if not args.yes:
        print("ERROR: --export-json writes decrypted secret values. Re-run with --yes only if you understand the risk.", file=sys.stderr)
        return 2
    _, plain, _ = unlock()
    dest = export_decrypted_json(plain, Path(args.export_json))
    print(f"Exported DECRYPTED vault JSON: {dest}")
    print("WARNING: This file contains plaintext secrets. Protect or delete it immediately after use.")
    return 0


def cmd_import_json(args: argparse.Namespace) -> int:
    if not args.yes:
        print("ERROR: Import replaces the current vault contents. Re-run with --yes after confirming.", file=sys.stderr)
        return 2
    container, _plain, master = unlock()
    backup = create_encrypted_backup("before-import") if vault_path().exists() else None
    imported = load_plain_import(Path(args.import_json))
    save_container(encrypt_vault(imported, master, container))
    audit("import_json", f"path={args.import_json} backup={backup}")
    print(f"Imported JSON records from: {args.import_json}")
    if backup:
        print(f"Encrypted backup created first: {backup}")
    return 0

def handle_json_action(payload: Dict[str, Any]) -> Dict[str, Any]:
    action = payload.get("action") or payload.get("name")
    if action == "manifest":
        return AEGIS_MANIFEST
    if action == "status":
        return status_dict()
    if action == "self_check":
        return self_check()
    if action == "review_status":
        d = status_dict(); d.update({"aegis_ready": True, "accessibility_ready": True, "theme_ready": True, "secret_values_returned_without_unlock": False, "network_required": False}); return d
    if action == "theme_status":
        return theme_status()
    if action == "backup_freshness":
        return backup_freshness_report()
    if action == "browser_provider_compatibility":
        return browser_provider_compatibility_report()
    if action == "integrity_check":
        password = payload.get("master_password")
        if not password:
            return {"ok": False, "error": "master_password required by local gateway for integrity_check"}
        _container, plain, _master = unlock(password=password)
        return vault_data_integrity_report(plain)
    if action == "totp_health":
        password = payload.get("master_password")
        if not password:
            return {"ok": False, "error": "master_password required by local gateway for totp_health"}
        _container, plain, _master = unlock(password=password)
        return totp_health_report(plain)
    if action == "release_candidate_report":
        password = payload.get("master_password")
        plain = None
        if password:
            _container, plain, _master = unlock(password=password)
        return release_candidate_report(plain)
    if action == "stable_lock_report":
        password = payload.get("master_password")
        plain = None
        if password:
            _container, plain, _master = unlock(password=password)
        return stable_lock_report(plain)
    if action == "random_password_options":
        return {"ok": True, **password_generator_options()}
    if action == "totp_options":
        return totp_options()
    if action == "totp_register_options":
        d = totp_options()
        d.update({"register_modes": ["manual_secret", "otpauth_uri", "standalone_authenticator_record", "attach_to_password_record_checkbox"], "requires_unlock_to_write": True, "plaintext_secret_logged": False})
        return d
    if action == "generate_password":
        length = int(payload.get("length", 20))
        return {"password": generate_password(length, lowercase=bool(payload.get("lowercase", True)), uppercase=bool(payload.get("uppercase", True)), digits=bool(payload.get("digits", True)), symbols=bool(payload.get("symbols", True)), avoid_ambiguous=bool(payload.get("avoid_ambiguous", False))), "length": max(12, min(128, length)), "secrets_logged": False}
    if action == "audit_tail":
        return {"ok": True, "audit": audit_tail_lines(int(payload.get("limit", 20)))}
    if action == "backup_list":
        return {"ok": True, "backups": list_encrypted_backups()}
    if action == "restore_preview":
        ident = str(payload.get("backup", ""))
        try:
            p = resolve_backup_path(ident)
            return {"ok": True, "backup": {"name": p.name, "path": str(p), "exists": p.exists(), "size_bytes": p.stat().st_size if p.exists() else 0}, "would_backup_current_before_restore": vault_path().exists()}
        except Exception as exc:
            return {"ok": False, "error": str(exc)}
    if action == "aegis_backup_status":
        return aegis_backup_status()
    if action == "aegis_backup_list":
        return {"ok": True, "backups": list_aegis_vault_backups(), "backup_dir": str(aegis_vault_backup_dir())}
    if action == "aegis_backup_create":
        try:
            return create_aegis_vault_backup(str(payload.get("label", "aegis")))
        except Exception as exc:
            return {"ok": False, "error": str(exc)}
    if action == "aegis_restore_preview":
        ident = str(payload.get("backup", ""))
        try:
            p = resolve_aegis_backup_path(ident)
            return {"ok": True, "backup": {"name": p.name, "path": str(p), "exists": p.exists(), "size_bytes": p.stat().st_size if p.exists() else 0}, "would_backup_current_before_restore": vault_path().exists()}
        except Exception as exc:
            return {"ok": False, "error": str(exc)}
    if action == "browser_bridge_status":
        return browser_bridge_status()
    if action == "browser_toolbar_spec":
        return browser_toolbar_spec()
    if action == "browser_2fa_quick_view_spec":
        return browser_2fa_quick_view_spec()
    if action == "browser_2fa_quick_view":
        _, plain, _ = unlock()
        return browser_2fa_quick_view(plain, str(payload.get("origin", "")))
    if action == "browser_2fa_code":
        if not bool(payload.get("approved")):
            return {"ok": False, "error": "browser_2fa_code requires explicit approved=true after user click"}
        _, plain, _ = unlock()
        out = browser_2fa_code_payload(plain, str(payload.get("ident", "")), str(payload.get("origin", "")))
        audit("aegis_browser_2fa_code", f"id={out.get('record', {}).get('id')} origin={payload.get('origin', '')}")
        return out
    if action == "browser_save_preview":
        return browser_save_candidate_preview_payload(
            origin=str(payload.get("origin", "")),
            title=str(payload.get("title", "")),
            username=str(payload.get("username", "")),
            url=str(payload.get("url", "")),
            category=str(payload.get("category", "Browser")),
            notes=str(payload.get("notes", "")),
        )
    if action == "browser_save_matches":
        password = payload.get("master_password")
        if not password:
            return {"ok": False, "error": "master_password required by local browser/vault gateway to check existing login matches"}
        _container, plain, _master = unlock(password=password)
        return browser_save_existing_matches_payload(
            plain,
            origin=str(payload.get("origin", "")),
            title=str(payload.get("title", "")),
            username=str(payload.get("username", "")),
            url=str(payload.get("url", "")),
        )
    if action == "browser_save_login":
        if not bool(payload.get("approved")):
            return {"ok": False, "error": "browser_save_login requires explicit approved=true after visible user prompt"}
        password = payload.get("master_password")
        if not password:
            return {"ok": False, "error": "master_password required by local browser/vault gateway to save login"}
        if not str(payload.get("password", "")):
            return {"ok": False, "error": "candidate password required after approval"}
        container, plain, master = unlock(password=password)
        out = browser_save_login_payload(
            plain,
            origin=str(payload.get("origin", "")),
            title=str(payload.get("title", "")),
            username=str(payload.get("username", "")),
            password=str(payload.get("password", "")),
            url=str(payload.get("url", "")),
            notes=str(payload.get("notes", "")),
            category=str(payload.get("category", "Browser")),
            tags=str(payload.get("tags", "browser,saved-login")),
        )
        save_container(encrypt_vault(plain, master, container))
        audit("aegis_browser_save_login", f"id={out.get('record', {}).get('id')} origin={payload.get('origin', '')}")
        return out
    if action == "browser_update_login":
        if not bool(payload.get("approved")):
            return {"ok": False, "error": "browser_update_login requires explicit approved=true after visible user prompt"}
        password = payload.get("master_password")
        if not password:
            return {"ok": False, "error": "master_password required by local browser/vault gateway to update login"}
        if not str(payload.get("password", "")):
            return {"ok": False, "error": "candidate password required after approval"}
        ident = str(payload.get("ident") or payload.get("record") or payload.get("id") or "")
        if not ident:
            return {"ok": False, "error": "record ident/id required for browser_update_login"}
        container, plain, master = unlock(password=password)
        out = browser_update_login_payload(
            plain,
            ident,
            origin=str(payload.get("origin", "")),
            title=str(payload.get("title", "")),
            username=str(payload.get("username", "")),
            password=str(payload.get("password", "")),
            url=str(payload.get("url", "")),
            notes=str(payload.get("notes", "")),
            category=str(payload.get("category", "")),
            tags=str(payload.get("tags", "browser,saved-login,updated-from-browser")),
        )
        save_container(encrypt_vault(plain, master, container))
        audit("aegis_browser_update_login", f"id={out.get('record', {}).get('id')} origin={payload.get('origin', '')}")
        return out
    # Unlock-requiring metadata/code actions; password can be passed by local gateway only.
    if action == "totp_code":
        password = payload.get("master_password")
        if not password:
            return {"ok": False, "error": "master_password required"}
        if not bool(payload.get("user_approved", False)):
            return {"ok": False, "error": "user_approved=true required before returning a TOTP code"}
        _container, plain, _ = unlock(password=password)
        rec = resolve_record(plain, str(payload.get("ident") or payload.get("record") or ""))
        out = totp_code_for_record(rec)
        audit("aegis_totp_code", f"id={rec.get('id')}")
        return {"ok": True, **out, "plaintext_code_returned": True, "secret_included": False}
    if action in {"list_metadata", "search_metadata", "category_report", "password_health", "duplicate_report", "favorite_report", "security_dashboard", "totp_report"}:
        password = payload.get("master_password")
        if not password:
            return {"ok": False, "error": "master_password required by local gateway; no secrets are returned by this action"}
        container, plain, _ = unlock(password=password)
        records = plain.get("records", [])
        if action == "category_report":
            return {"ok": True, **category_report(plain)}
        if action == "password_health":
            return {"ok": True, **vault_health_report(plain)}
        if action == "duplicate_report":
            return {"ok": True, **duplicate_report(plain)}
        if action == "favorite_report":
            return {"ok": True, **favorite_report(plain)}
        if action == "totp_report":
            return {"ok": True, **totp_report(plain)}
        if action == "security_dashboard":
            return security_dashboard(plain)
        if action == "search_metadata":
            q = str(payload.get("query", ""))
            records = find_records(plain, q)
        return {"ok": True, "records": [record_metadata(r) for r in records]}
    return {"ok": False, "error": f"Unsupported action: {action}"}


def parse_json_action(value: str) -> Dict[str, Any]:
    if value.startswith("@"):
        with open(value[1:], "r", encoding="utf-8") as f:
            return json.load(f)
    return json.loads(value)



def gui_icon_candidates(size: int = 256) -> List[Path]:
    script_dir = Path(__file__).resolve().parent
    return [
        script_dir / "assets" / f"sentinel-password-vault-{size}.png",
        script_dir / "assets" / "sentinel-password-vault.png",
        Path(f"/usr/share/icons/hicolor/{size}x{size}/apps/sentinel-password-vault.png"),
        Path("/usr/share/pixmaps/sentinel-password-vault.png"),
    ]


def first_existing_icon(size: int = 256) -> Optional[Path]:
    for p in gui_icon_candidates(size):
        try:
            if p.exists():
                return p
        except Exception:
            pass
    return None


def gui_error_path() -> Path:
    return state_dir() / "gui-error.log"


def log_gui_error(context: str, exc: BaseException) -> None:
    try:
        ensure_dirs()
        with gui_error_path().open("a", encoding="utf-8") as f:
            f.write(json.dumps({
                "ts": now_iso(),
                "context": context,
                "error_type": type(exc).__name__,
                "error": str(exc)[:1000],
            }, sort_keys=True) + "\n")
    except Exception:
        pass

def launch_gui() -> int:
    """Launch the Tk GUI.

    v0.2.2 deliberately avoids modal simpledialog unlock on startup. The previous
    startup path could appear to hang or vanish on some MATE/Tk setups after the
    master-password prompt. This path keeps one visible root window alive, shows
    an explicit unlock/create screen, and only builds the vault UI after the
    encrypted vault has been opened successfully.
    """
    try:
        import tkinter as tk
        from tkinter import messagebox, ttk
    except Exception as exc:
        print(f"ERROR: Tkinter GUI is unavailable: {exc}", file=sys.stderr)
        return 2

    ensure_dirs()
    root = tk.Tk()
    root.title(f"{APP_NAME} v{VERSION} — Program {PROGRAM_ID}")
    root.geometry("1120x640")
    root.minsize(1020, 560)
    root.configure(bg="#101316")

    # Use the supplied Sentinel Password Vault icon for the window where Tk supports PNG icons.
    try:
        icon_path = first_existing_icon(256) or first_existing_icon(128)
        if icon_path is not None and icon_path.suffix.lower() == ".png":
            root._sentinel_icon = tk.PhotoImage(file=str(icon_path))
            root.iconphoto(True, root._sentinel_icon)
    except Exception as exc:
        log_gui_error("window_icon", exc)

    COLORS = {"bg": "#101316", "panel": "#171C20", "field": "#0D1114", "fg": "#F4E9D1", "muted": "#D6C892", "gold": "#E19B00", "cyan": "#03C8FF", "danger": "#ff6b6b"}
    style = ttk.Style()
    try:
        style.theme_use("clam")
    except Exception:
        pass
    style.configure("Treeview", background=COLORS["field"], foreground=COLORS["fg"], fieldbackground=COLORS["field"], rowheight=24, bordercolor=COLORS["gold"], lightcolor=COLORS["panel"], darkcolor=COLORS["bg"])
    style.configure("Treeview.Heading", background=COLORS["panel"], foreground=COLORS["gold"], relief="flat")
    style.map("Treeview", background=[("selected", COLORS["gold"])], foreground=[("selected", "#101316")])
    style.configure("TButton", padding=5, background=COLORS["panel"], foreground=COLORS["fg"], bordercolor=COLORS["gold"], focusthickness=2, focuscolor=COLORS["cyan"], relief="raised")
    style.map("TButton", background=[("active", COLORS["gold"]), ("pressed", "#b87d00"), ("disabled", "#2a2f33")], foreground=[("active", "#101316"), ("pressed", "#101316"), ("disabled", COLORS["muted"])])
    style.configure("Sentinel.TButton", padding=5, background=COLORS["panel"], foreground=COLORS["fg"], bordercolor=COLORS["gold"], focusthickness=2, focuscolor=COLORS["cyan"], relief="raised")
    style.map("Sentinel.TButton", background=[("active", COLORS["gold"]), ("pressed", "#b87d00"), ("disabled", "#2a2f33")], foreground=[("active", "#101316"), ("pressed", "#101316"), ("disabled", COLORS["muted"])])
    style.configure("TCheckbutton", background=COLORS["panel"], foreground=COLORS["fg"], focuscolor=COLORS["cyan"], indicatorcolor=COLORS["field"], bordercolor=COLORS["gold"], padding=3)
    style.map("TCheckbutton", background=[("active", COLORS["panel"])], foreground=[("active", COLORS["gold"]), ("disabled", COLORS["muted"])] , indicatorcolor=[("selected", COLORS["gold"]), ("!selected", COLORS["field"])])
    style.configure("Sentinel.TCheckbutton", background=COLORS["panel"], foreground=COLORS["fg"], focuscolor=COLORS["cyan"], indicatorcolor=COLORS["field"], bordercolor=COLORS["gold"], padding=3)
    style.map("Sentinel.TCheckbutton", background=[("active", COLORS["panel"])], foreground=[("active", COLORS["gold"]), ("disabled", COLORS["muted"])] , indicatorcolor=[("selected", COLORS["gold"]), ("!selected", COLORS["field"])])
    style.configure("TCombobox", fieldbackground=COLORS["field"], background=COLORS["panel"], foreground=COLORS["fg"], arrowcolor=COLORS["gold"], bordercolor=COLORS["gold"], insertcolor=COLORS["cyan"], padding=3)
    style.map("TCombobox", fieldbackground=[("readonly", COLORS["field"]), ("!disabled", COLORS["field"])], foreground=[("readonly", COLORS["fg"]), ("!disabled", COLORS["fg"])], background=[("active", COLORS["panel"]), ("!disabled", COLORS["panel"])])
    style.configure("Sentinel.TCombobox", fieldbackground=COLORS["field"], background=COLORS["panel"], foreground=COLORS["fg"], arrowcolor=COLORS["gold"], bordercolor=COLORS["gold"], insertcolor=COLORS["cyan"], padding=3)
    style.map("Sentinel.TCombobox", fieldbackground=[("readonly", COLORS["field"]), ("!disabled", COLORS["field"])], foreground=[("readonly", COLORS["fg"]), ("!disabled", COLORS["fg"])], background=[("active", COLORS["panel"]), ("!disabled", COLORS["panel"])])
    style.configure("Horizontal.TSeparator", background=COLORS["gold"])
    THEMED_CATEGORY_VALUES = ["Banking", "Email", "Shopping", "Work", "Servers", "Wi-Fi", "Family", "SentinelOS", "AEGIS", "Other"]
    try:
        root.option_add("*TCombobox*Listbox.background", COLORS["field"])
        root.option_add("*TCombobox*Listbox.foreground", COLORS["fg"])
        root.option_add("*TCombobox*Listbox.selectBackground", COLORS["gold"])
        root.option_add("*TCombobox*Listbox.selectForeground", "#101316")
        root.option_add("*Button.background", COLORS["panel"])
        root.option_add("*Button.foreground", COLORS["fg"])
        root.option_add("*Checkbutton.background", COLORS["panel"])
        root.option_add("*Checkbutton.foreground", COLORS["fg"])
        root.option_add("*Checkbutton.selectColor", COLORS["field"])
        root.option_add("*Entry.background", COLORS["field"])
        root.option_add("*Entry.foreground", COLORS["fg"])
        root.option_add("*Entry.insertBackground", COLORS["cyan"])
        root.option_add("*Listbox.background", COLORS["field"])
        root.option_add("*Listbox.foreground", COLORS["fg"])
        root.option_add("*Listbox.selectBackground", COLORS["gold"])
        root.option_add("*Listbox.selectForeground", "#101316")
    except Exception:
        pass

    session: Dict[str, Any] = {"container": None, "plain": None, "master": None, "selected_id": None, "form_mode": "new", "last_activity": _dt.datetime.now(), "clipboard_clear_token": None, "vault_stamp": (0, 0), "ui_generation": 0}
    status_text = tk.StringVar(value="Locked")

    def label(parent, text, **kw):
        return tk.Label(parent, text=text, bg=kw.pop("bg", COLORS["bg"]), fg=kw.pop("fg", COLORS["fg"]), **kw)

    def entry(parent, show=None):
        return tk.Entry(parent, bg=COLORS["field"], fg=COLORS["fg"], insertbackground=COLORS["cyan"], relief="solid", highlightthickness=1, highlightbackground=COLORS["gold"], highlightcolor=COLORS["cyan"], show=show)

    def dropdown(parent, values=None):
        cb = ttk.Combobox(parent, values=values or [], style="Sentinel.TCombobox", state="normal")
        try:
            cb.configure(font=("Sans", 10))
        except Exception:
            pass
        return cb

    def themed_dialog(title: str, message: str, kind: str = "info", buttons=("OK",), default: Optional[str] = None, parent=None) -> Optional[str]:
        parent = parent or root
        dialog = tk.Toplevel(parent)
        dialog.title(title)
        dialog.configure(bg=COLORS["bg"])
        dialog.transient(parent)
        dialog.grab_set()
        dialog.resizable(False, False)
        result = {"value": None}
        accent = COLORS["danger"] if kind == "error" else COLORS["gold"]
        outer = tk.Frame(dialog, bg=COLORS["bg"], padx=2, pady=2)
        outer.pack(fill="both", expand=True)
        panel = tk.Frame(outer, bg=COLORS["panel"], highlightthickness=1, highlightbackground=accent)
        panel.pack(fill="both", expand=True, padx=10, pady=10)
        label(panel, title, bg=COLORS["panel"], fg=accent, font=("Sans", 13, "bold")).pack(anchor="w", padx=14, pady=(12, 6))
        tk.Message(panel, text=message, width=520, bg=COLORS["panel"], fg=COLORS["fg"], font=("Sans", 10)).pack(fill="both", expand=True, padx=14, pady=6)
        btn_frame = tk.Frame(panel, bg=COLORS["panel"])
        btn_frame.pack(fill="x", padx=12, pady=(8, 12))
        default_value = default or (buttons[0] if buttons else None)
        def choose(value: str):
            result["value"] = value
            dialog.destroy()
        for b in reversed(tuple(buttons)):
            ttk.Button(btn_frame, text=b, command=lambda value=b: choose(value)).pack(side="right", padx=4)
        def on_return(_evt=None):
            if default_value is not None:
                choose(default_value)
            return "break"
        def on_escape(_evt=None):
            if "No" in buttons:
                choose("No")
            elif "Cancel" in buttons:
                choose("Cancel")
            else:
                choose(buttons[-1] if buttons else "OK")
            return "break"
        dialog.bind("<Return>", on_return)
        dialog.bind("<Escape>", on_escape)
        try:
            dialog.update_idletasks()
            x = parent.winfo_rootx() + max(40, (parent.winfo_width() - dialog.winfo_width()) // 2)
            y = parent.winfo_rooty() + max(40, (parent.winfo_height() - dialog.winfo_height()) // 2)
            dialog.geometry(f"+{x}+{y}")
        except Exception:
            pass
        root.wait_window(dialog)
        return result["value"]

    def themed_confirm(title: str, message: str, parent=None) -> bool:
        return themed_dialog(title, message, kind="confirm", buttons=("Yes", "No"), default="No", parent=parent) == "Yes"

    def show_error(title: str, message: str) -> None:
        try:
            themed_dialog(title, message, kind="error", buttons=("OK",), default="OK")
        except Exception:
            print(f"{title}: {message}", file=sys.stderr)

    def show_info(title: str, message: str) -> None:
        try:
            themed_dialog(title, message, kind="info", buttons=("OK",), default="OK")
        except Exception:
            print(f"{title}: {message}")

    def mark_activity(_evt=None) -> None:
        session["last_activity"] = _dt.datetime.now()

    def maybe_auto_lock() -> None:
        try:
            if not session.get("plain"):
                root.after(30_000, maybe_auto_lock)
                return
            elapsed = (_dt.datetime.now() - session.get("last_activity", _dt.datetime.now())).total_seconds()
            if elapsed >= AUTO_LOCK_SECONDS:
                audit("gui_auto_lock", f"idle_seconds={int(elapsed)}")
                root.destroy()
                return
        except Exception as exc:
            log_gui_error("auto_lock", exc)
        root.after(30_000, maybe_auto_lock)

    def schedule_clipboard_clear(copied_value: str) -> None:
        token = secrets.token_hex(8)
        session["clipboard_clear_token"] = token
        def clear_if_same() -> None:
            try:
                if session.get("clipboard_clear_token") == token and root.clipboard_get() == copied_value:
                    root.clipboard_clear()
                    status_text.set(f"Clipboard cleared after {CLIPBOARD_CLEAR_SECONDS}s")
                    audit("gui_clipboard_auto_clear", "cleared copied field")
            except Exception as exc:
                log_gui_error("clipboard_auto_clear", exc)
        root.after(CLIPBOARD_CLEAR_SECONDS * 1000, clear_if_same)

    root.bind_all("<Key>", mark_activity, add="+")
    root.bind_all("<Button>", mark_activity, add="+")
    root.after(30_000, maybe_auto_lock)

    def create_header(parent):
        header = tk.Frame(parent, bg=COLORS["bg"])
        header.pack(fill="x", padx=12, pady=(10, 6))
        try:
            header_icon_path = first_existing_icon(64)
            if header_icon_path is not None and header_icon_path.suffix.lower() == ".png":
                root._sentinel_header_icon = tk.PhotoImage(file=str(header_icon_path))
                tk.Label(header, image=root._sentinel_header_icon, bg=COLORS["bg"]).pack(side="left", padx=(0, 10))
        except Exception as exc:
            log_gui_error("header_icon", exc)
        label(header, f"{APP_NAME} v{VERSION}", fg=COLORS["gold"], font=("Sans", 14, "bold")).pack(side="left")
        label(header, "Local encrypted vault · no cloud · no telemetry", fg=COLORS["muted"]).pack(side="left", padx=14)
        return header

    def build_main_ui() -> None:
        session["ui_generation"] = int(session.get("ui_generation", 0)) + 1
        ui_generation = session["ui_generation"]
        for child in root.winfo_children():
            child.destroy()
        status_text.set("Unlocked")
        root.title(f"{APP_NAME} v{VERSION} — Program {PROGRAM_ID}")
        create_header(root)

        main = tk.Frame(root, bg=COLORS["bg"])
        main.pack(fill="both", expand=True, padx=8, pady=6)
        left = tk.Frame(main, bg=COLORS["panel"], width=455)
        left.pack(side="left", fill="both", padx=(0, 6))
        left.pack_propagate(False)
        right = tk.Frame(main, bg=COLORS["panel"])
        right.pack(side="left", fill="both", expand=True)

        search_frame = tk.Frame(left, bg=COLORS["panel"])
        search_frame.pack(fill="x", padx=6, pady=6)
        search_entry = entry(search_frame)
        search_entry.pack(side="left", fill="x", expand=True)

        tree = ttk.Treeview(left, columns=("fav", "category", "title", "username"), show="headings", selectmode="browse")
        tree.heading("fav", text="★")
        tree.heading("category", text="Category")
        tree.heading("title", text="Title")
        tree.heading("username", text="Username")
        tree.column("fav", width=32, anchor="center", stretch=False)
        tree.column("category", width=110, stretch=False)
        tree.column("title", width=165, stretch=True)
        tree.column("username", width=130, stretch=True)
        tree.pack(fill="both", expand=True, padx=6, pady=(0, 6))

        fields: Dict[str, Any] = {}
        form = tk.Frame(right, bg=COLORS["panel"])
        form.pack(fill="both", expand=True, padx=10, pady=10)
        for i, name in enumerate(["Title", "Username", "URL / Service", "Password / Secret", "Category", "Tags"]):
            label(form, name, bg=COLORS["panel"], fg=COLORS["gold"]).grid(row=i, column=0, sticky="w", pady=3)
            if name == "Category":
                ent = dropdown(form, THEMED_CATEGORY_VALUES)
            else:
                ent = entry(form, show="*" if name == "Password / Secret" else None)
            ent.grid(row=i, column=1, sticky="ew", pady=3, padx=6)
            fields[name] = ent
        strength_var = tk.StringVar(value="")

        def update_strength(_evt=None) -> None:
            label_text, _score = password_strength_label(fields["Password / Secret"].get())
            strength_var.set(label_text)

        fields["Password / Secret"].bind("<KeyRelease>", update_strength)
        label(form, "Strength", bg=COLORS["panel"], fg=COLORS["muted"]).grid(row=6, column=0, sticky="w", pady=4)
        label(form, "", bg=COLORS["panel"], fg=COLORS["cyan"], textvariable=strength_var).grid(row=6, column=1, sticky="w", padx=8, pady=4)
        form.columnconfigure(1, weight=1)
        favorite_var = tk.BooleanVar(value=False)
        totp_enabled_var = tk.BooleanVar(value=False)
        flags_row = tk.Frame(form, bg=COLORS["panel"])
        flags_row.grid(row=7, column=1, sticky="w", padx=8, pady=3)
        ttk.Checkbutton(flags_row, text="Favorite / pinned", variable=favorite_var, style="Sentinel.TCheckbutton").pack(side="left", padx=(0, 12))
        ttk.Checkbutton(flags_row, text="Enable 2FA / Authenticator", variable=totp_enabled_var, style="Sentinel.TCheckbutton").pack(side="left")
        meta_var = tk.StringVar(value="Created/modified timestamps appear after selecting a record.")
        label(form, "Record Info", bg=COLORS["panel"], fg=COLORS["muted"]).grid(row=8, column=0, sticky="w", pady=4)
        label(form, "", bg=COLORS["panel"], fg=COLORS["muted"], textvariable=meta_var).grid(row=8, column=1, sticky="w", padx=8, pady=4)
        label(form, "Notes", bg=COLORS["panel"], fg=COLORS["gold"]).grid(row=9, column=0, sticky="nw", pady=4)
        notes = tk.Text(form, height=5, bg=COLORS["field"], fg=COLORS["fg"], insertbackground=COLORS["cyan"], relief="solid", wrap="word")
        notes.grid(row=9, column=1, sticky="nsew", pady=3, padx=6)
        form.rowconfigure(9, weight=1)

        show_password = tk.BooleanVar(value=False)

        def refresh_list(query: str = "") -> None:
            tree.delete(*tree.get_children())
            records = session["plain"].get("records", [])
            if query:
                records = find_records(session["plain"], query)
            records = sorted([normalize_record(r) for r in records], key=lambda r: (not bool(r.get("favorite")), str(r.get("category", "Other")).lower(), str(r.get("title", "")).lower()))
            for rec in records:
                tree.insert("", "end", iid=rec["id"], values=("★" if rec.get("favorite") else "", rec.get("category", "Other"), rec.get("title", ""), rec.get("username", "")))

        def clear_form() -> None:
            session["selected_id"] = None
            session["form_mode"] = "new"
            try:
                tree.selection_remove(*tree.selection())
            except Exception:
                pass
            for ent in fields.values():
                ent.delete(0, "end")
            notes.delete("1.0", "end")
            favorite_var.set(False)
            totp_enabled_var.set(False)
            meta_var.set("New record · Save will add a new record")
            fields["Category"].insert(0, "Other")
            update_strength()
            fields["Title"].focus_set()

        def load_selected(_evt=None) -> None:
            sel = tree.selection()
            if not sel:
                return
            rid = sel[0]
            rec = next((r for r in session["plain"].get("records", []) if r.get("id") == rid), None)
            if not rec:
                return
            session["selected_id"] = rid
            session["form_mode"] = "edit"
            rec = normalize_record(rec)
            mapping = {"Title": "title", "Username": "username", "URL / Service": "url", "Password / Secret": "password", "Category": "category", "Tags": "tags"}
            for field_name, key in mapping.items():
                ent = fields[field_name]
                ent.delete(0, "end")
                val = rec.get(key, "")
                if key == "tags" and isinstance(val, list):
                    val = ", ".join(val)
                ent.insert(0, str(val))
            update_strength()
            notes.delete("1.0", "end")
            notes.insert("1.0", rec.get("notes", ""))
            favorite_var.set(bool(rec.get("favorite", False)))
            totp_enabled_var.set(bool(normalize_totp_config(rec).get("enabled")))
            h = password_health(rec, session["plain"].get("records", []))
            totp_label = " · 2FA enabled" if normalize_totp_config(rec).get("enabled") else " · 2FA not set"
            meta_var.set(f"Editing selected record · Created: {rec.get('created_at','')} · Modified: {rec.get('updated_at','')} · Health: {h['label']} · Issues: {', '.join(h['issues']) or 'none'}{totp_label}")

        def save_plain() -> None:
            if not session.get("plain") or session.get("master") is None:
                return
            session["plain"]["updated_at"] = now_iso()
            new_container = encrypt_vault(session["plain"], session["master"], session.get("container"))
            save_container(new_container)
            session["container"] = new_container
            session["vault_stamp"] = vault_file_stamp()

        def reload_external_changes(show_status: bool = True) -> bool:
            if not session.get("plain") or session.get("master") is None:
                return False
            stamp = vault_file_stamp()
            if stamp == (0, 0) or stamp == tuple(session.get("vault_stamp") or (0, 0)):
                return False
            try:
                container = load_container(vault_path())
                plain = decrypt_vault(container, session["master"])
                session["container"] = container
                session["plain"] = plain
                session["vault_stamp"] = stamp
                refresh_list(search_entry.get().strip())
                if show_status:
                    status_text.set("Vault refreshed — browser-saved login is now available")
                audit("gui_external_vault_refresh", f"records={len(plain.get('records', []))}")
                return True
            except Exception as exc:
                log_gui_error("external_vault_refresh", exc)
                return False

        def poll_external_changes() -> None:
            if session.get("ui_generation") != ui_generation:
                return
            try:
                reload_external_changes(True)
            finally:
                if session.get("ui_generation") == ui_generation:
                    root.after(2000, poll_external_changes)

        root.after(2000, poll_external_changes)

        def save_record() -> None:
            reload_external_changes(False)
            title = fields["Title"].get().strip()
            if not title:
                show_error(APP_NAME, "Title is required.")
                return
            ts = now_iso()
            selected = session.get("selected_id")
            explicit_edit = session.get("form_mode") == "edit" and bool(selected)
            records = session["plain"].setdefault("records", [])
            rec = next((r for r in records if r.get("id") == selected), None) if explicit_edit else None
            is_new_record = rec is None
            if is_new_record:
                rec = {"id": str(uuid.uuid4()), "created_at": ts}
                records.append(rec)
            candidate = {
                "title": title,
                "username": fields["Username"].get().strip(),
                "url": fields["URL / Service"].get().strip(),
                "password": fields["Password / Secret"].get(),
            }
            warnings = duplicate_warnings_for_candidate(session["plain"], candidate, existing_id=(rec.get("id") if not is_new_record else None))
            if warnings:
                msg = "Potential duplicate/reuse warning:\n\n- " + "\n- ".join(warnings) + "\n\nSave anyway?"
                if not themed_confirm(APP_NAME, msg, parent=root):
                    status_text.set("Save cancelled after duplicate warning")
                    return
            rec.update({
                "title": title,
                "username": fields["Username"].get().strip(),
                "url": fields["URL / Service"].get().strip(),
                "password": fields["Password / Secret"].get(),
                "category": fields["Category"].get().strip() or "Other",
                "tags": [t.strip() for t in fields["Tags"].get().split(",") if t.strip()],
                "favorite": bool(favorite_var.get()),
                "notes": notes.get("1.0", "end").strip(),
                "updated_at": ts,
            })
            # 2FA is controlled by a visible checkbox. Checking it does not invent a
            # secret; it prompts registration after the record is saved. Unchecking
            # disables stored TOTP for this record but keeps metadata local/encrypted.
            cfg_existing = normalize_totp_config(rec)
            needs_totp_registration = bool(totp_enabled_var.get()) and not cfg_existing.get("secret")
            if not bool(totp_enabled_var.get()) and cfg_existing.get("enabled"):
                rec["totp"] = {
                    "enabled": False, "secret": "",
                    "issuer": cfg_existing.get("issuer", ""),
                    "account": cfg_existing.get("account", ""),
                    "digits": cfg_existing.get("digits", TOTP_DEFAULT_DIGITS),
                    "period": cfg_existing.get("period", TOTP_DEFAULT_PERIOD),
                    "algorithm": cfg_existing.get("algorithm", TOTP_DEFAULT_ALGORITHM),
                }
            elif bool(totp_enabled_var.get()) and cfg_existing.get("secret"):
                rec["totp"] = cfg_existing
                rec["totp"]["enabled"] = True
            save_plain()
            refresh_list(search_entry.get().strip())
            if is_new_record:
                audit("gui_add_record", f"id={rec['id']} title={title[:80]}")
                status_text.set("Record added. Form cleared for the next new record.")
                if needs_totp_registration:
                    tree.selection_set(rec["id"])
                    session["selected_id"] = rec["id"]
                    session["form_mode"] = "edit"
                    totp_setup_dialog(rec)
                clear_form()
            else:
                tree.selection_set(rec["id"])
                session["selected_id"] = rec["id"]
                session["form_mode"] = "edit"
                audit("gui_update_record", f"id={rec['id']} title={title[:80]}")
                status_text.set("Selected record updated")
                if needs_totp_registration:
                    totp_setup_dialog(rec)

        def delete_record() -> None:
            rid = session.get("selected_id")
            if not rid:
                return
            rec = next((r for r in session["plain"].get("records", []) if r.get("id") == rid), None)
            if not rec:
                return
            if not themed_confirm(APP_NAME, f"Delete record '{rec.get('title')}'? This cannot be undone without restoring a backup.", parent=root):
                return
            session["plain"]["records"] = [r for r in session["plain"].get("records", []) if r.get("id") != rid]
            save_plain()
            audit("gui_delete_record", f"id={rid}")
            clear_form()
            refresh_list(search_entry.get().strip())
            status_text.set("Record deleted")

        def copy_field(key: str) -> None:
            value = fields[key].get()
            root.clipboard_clear()
            root.clipboard_append(value)
            root.update()
            schedule_clipboard_clear(value)
            audit("gui_copy_field", key)
            status_text.set(f"Copied {key}; clipboard clears in {CLIPBOARD_CLEAR_SECONDS}s")

        def view_secret_dialog(record: Optional[Dict[str, Any]] = None) -> None:
            if record is not None:
                record = normalize_record(record)
                secret_value = str(record.get("password", ""))
                title_value = str(record.get("title", "") or "selected record")
                record_id_for_audit = str(record.get("id", "selected"))
            else:
                secret_value = fields["Password / Secret"].get()
                title_value = fields["Title"].get().strip() or "current record"
                record_id_for_audit = str(session.get("selected_id") or "unsaved")
            if not secret_value:
                show_error(APP_NAME, "No password/secret is present in the current field.")
                return
            dialog = tk.Toplevel(root)
            dialog.title("View Secret")
            dialog.configure(bg=COLORS["bg"])
            dialog.transient(root)
            dialog.grab_set()
            panel = tk.Frame(dialog, bg=COLORS["panel"], highlightthickness=1, highlightbackground=COLORS["gold"])
            panel.pack(fill="both", expand=True, padx=12, pady=12)
            label(panel, "Password / Secret", bg=COLORS["panel"], fg=COLORS["gold"], font=("Sans", 13, "bold")).pack(anchor="w", padx=12, pady=(12, 4))
            label(panel, f"Visible secret for: {title_value}", bg=COLORS["panel"], fg=COLORS["muted"]).pack(anchor="w", padx=12, pady=(0, 8))
            secret_entry = entry(panel)
            secret_entry.pack(fill="x", padx=12, pady=8)
            secret_entry.insert(0, secret_value)
            secret_entry.focus_set()
            secret_entry.selection_range(0, "end")
            label(panel, "Plaintext is visible on screen only because you clicked View Secret. Nothing plaintext is written to the audit log.", bg=COLORS["panel"], fg=COLORS["muted"], wraplength=620, justify="left").pack(anchor="w", padx=12, pady=6)
            def copy_secret() -> None:
                root.clipboard_clear()
                root.clipboard_append(secret_value)
                root.update()
                schedule_clipboard_clear(secret_value)
                audit("gui_copy_secret_from_view", f"record_id={record_id_for_audit}")
                status_text.set(f"Copied visible secret; clipboard clears in {CLIPBOARD_CLEAR_SECONDS}s")
            btns3 = tk.Frame(panel, bg=COLORS["panel"])
            btns3.pack(fill="x", padx=12, pady=(8, 12))
            ttk.Button(btns3, text="Close", command=dialog.destroy).pack(side="right", padx=4)
            ttk.Button(btns3, text="Copy Secret", command=copy_secret).pack(side="right", padx=4)
            dialog.bind("<Escape>", lambda e: (dialog.destroy(), "break"))
            audit("gui_view_secret", f"record_id={record_id_for_audit}")
            status_text.set("Secret viewed by explicit user action")

        def selected_record() -> Optional[Dict[str, Any]]:
            rid = session.get("selected_id")
            if not rid:
                sel = tree.selection()
                rid = sel[0] if sel else None
            if not rid:
                return None
            return next((r for r in session["plain"].get("records", []) if r.get("id") == rid), None)



        def selected_record_for_totp(require: bool = True) -> Optional[Dict[str, Any]]:
            rec = selected_record()
            if rec is None:
                if require:
                    show_error(APP_NAME, "Select a record first, or use 2FA → Register New 2FA to create an authenticator-only entry.")
                return None
            return normalize_record(rec)

        def create_totp_only_record(title: str, issuer: str = "", account: str = "") -> Dict[str, Any]:
            ts = now_iso()
            rec = normalize_record({
                "id": str(uuid.uuid4()),
                "title": title or issuer or account or "2FA Authenticator",
                "username": account or "",
                "url": "",
                "password": "",
                "category": "2FA",
                "tags": ["2fa", "authenticator"],
                "favorite": False,
                "notes": "Authenticator-only entry created by Sentinel Password Vault.",
                "created_at": ts,
                "updated_at": ts,
            })
            session["plain"].setdefault("records", []).append(rec)
            return rec

        def totp_setup_dialog(record: Optional[Dict[str, Any]] = None, standalone: bool = False) -> None:
            rec = record or (None if standalone else selected_record_for_totp(require=False))
            if rec is None:
                rec = create_totp_only_record("2FA Authenticator")
            rec = normalize_record(rec)
            cfg = normalize_totp_config(rec)
            dialog = tk.Toplevel(root)
            dialog.title("Add 2FA Authenticator")
            dialog.configure(bg=COLORS["bg"])
            dialog.transient(root)
            dialog.grab_set()
            dialog.geometry("720x560")
            dialog.minsize(660, 500)
            outer = tk.Frame(dialog, bg=COLORS["bg"], padx=12, pady=12)
            outer.pack(fill="both", expand=True)
            panel = tk.Frame(outer, bg=COLORS["panel"], padx=14, pady=14, highlightthickness=1, highlightbackground=COLORS["gold"])
            panel.pack(fill="both", expand=True)
            label(panel, "Add 2FA / Authenticator", bg=COLORS["panel"], fg=COLORS["gold"], font=("Sans", 14, "bold")).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 4))
            label(panel, "Use this when a website says: Enter a setup key, scan a QR code, or use an authenticator app.", bg=COLORS["panel"], fg=COLORS["muted"], wraplength=650, justify="left").grid(row=1, column=0, columnspan=2, sticky="w", pady=(0, 10))

            step1 = "Step 1 — Paste the setup key from the website"
            label(panel, step1, bg=COLORS["panel"], fg=COLORS["gold"]).grid(row=2, column=0, columnspan=2, sticky="w", pady=(4, 2))
            label(panel, "Spaces are OK. You can also paste the whole instruction text or an otpauth:// URI.", bg=COLORS["panel"], fg=COLORS["muted"], wraplength=650, justify="left").grid(row=3, column=0, columnspan=2, sticky="w", pady=(0, 4))
            setup_text = tk.Text(panel, height=4, bg=COLORS["field"], fg=COLORS["fg"], insertbackground=COLORS["cyan"], relief="solid", wrap="word")
            setup_text.grid(row=4, column=0, columnspan=2, sticky="ew", pady=(0, 8))
            if cfg.get("secret"):
                setup_text.insert("1.0", cfg.get("secret", ""))

            label(panel, "Step 2 — Account shown by the website", bg=COLORS["panel"], fg=COLORS["gold"]).grid(row=5, column=0, sticky="w", pady=4)
            account_entry = entry(panel)
            account_entry.grid(row=5, column=1, sticky="ew", padx=8, pady=4)
            account_entry.insert(0, cfg.get("account") or rec.get("username", ""))

            label(panel, "Step 3 — Website / issuer", bg=COLORS["panel"], fg=COLORS["gold"]).grid(row=6, column=0, sticky="w", pady=4)
            issuer_entry = entry(panel)
            issuer_entry.grid(row=6, column=1, sticky="ew", padx=8, pady=4)
            issuer_entry.insert(0, cfg.get("issuer") or rec.get("title", ""))

            enabled_var = tk.BooleanVar(value=True if cfg.get("secret") else True)
            ttk.Checkbutton(panel, text="Enable 2FA for this record", variable=enabled_var, style="Sentinel.TCheckbutton").grid(row=7, column=1, sticky="w", padx=8, pady=4)

            adv_visible = tk.BooleanVar(value=False)
            adv_frame = tk.Frame(panel, bg=COLORS["panel"])
            digits_cb = ttk.Combobox(adv_frame, values=[6, 8], style="Sentinel.TCombobox", state="readonly", width=8)
            period_cb = ttk.Combobox(adv_frame, values=[30, 60], style="Sentinel.TCombobox", state="readonly", width=8)
            alg_cb = ttk.Combobox(adv_frame, values=["SHA1", "SHA256", "SHA512"], style="Sentinel.TCombobox", state="readonly", width=10)
            digits_cb.set(cfg.get("digits", TOTP_DEFAULT_DIGITS))
            period_cb.set(cfg.get("period", TOTP_DEFAULT_PERIOD))
            alg_cb.set(cfg.get("algorithm", TOTP_DEFAULT_ALGORITHM))
            label(adv_frame, "Digits", bg=COLORS["panel"], fg=COLORS["muted"]).grid(row=0, column=0, sticky="w", padx=(0, 6), pady=2)
            digits_cb.grid(row=0, column=1, sticky="w", padx=(0, 14), pady=2)
            label(adv_frame, "Timer", bg=COLORS["panel"], fg=COLORS["muted"]).grid(row=0, column=2, sticky="w", padx=(0, 6), pady=2)
            period_cb.grid(row=0, column=3, sticky="w", padx=(0, 14), pady=2)
            label(adv_frame, "Algorithm", bg=COLORS["panel"], fg=COLORS["muted"]).grid(row=0, column=4, sticky="w", padx=(0, 6), pady=2)
            alg_cb.grid(row=0, column=5, sticky="w", pady=2)

            msg = tk.StringVar(value="Most websites use the defaults: time based, 6 digits, 30 seconds, SHA1. After saving, use the code shown to finish setup on the website.")
            label(panel, "", bg=COLORS["panel"], fg=COLORS["muted"], textvariable=msg, wraplength=650, justify="left").grid(row=9, column=0, columnspan=2, sticky="ew", pady=(6, 6))

            test_var = tk.StringVar(value="")
            label(panel, "Current code preview", bg=COLORS["panel"], fg=COLORS["gold"]).grid(row=10, column=0, sticky="w", pady=4)
            label(panel, "", bg=COLORS["panel"], fg=COLORS["cyan"], textvariable=test_var, font=("Monospace", 18, "bold")).grid(row=10, column=1, sticky="w", padx=8, pady=4)

            def toggle_advanced() -> None:
                if adv_visible.get():
                    adv_frame.grid_forget()
                    adv_visible.set(False)
                    adv_btn.configure(text="Show Advanced")
                else:
                    adv_frame.grid(row=8, column=0, columnspan=2, sticky="w", pady=(0, 4))
                    adv_visible.set(True)
                    adv_btn.configure(text="Hide Advanced")

            def read_setup_payload() -> Dict[str, str]:
                return extract_totp_setup_input(setup_text.get("1.0", "end"))

            def preview_code() -> bool:
                try:
                    parsed = read_setup_payload()
                    if parsed["mode"] == "uri":
                        cfg2 = parse_otpauth_uri(parsed["value"])
                        code = generate_totp_code(cfg2["secret"], digits=cfg2["digits"], period=cfg2["period"], algorithm=cfg2["algorithm"])
                        issuer_entry.delete(0, "end"); issuer_entry.insert(0, issuer_entry.get().strip() or cfg2.get("issuer", ""))
                        account_entry.delete(0, "end"); account_entry.insert(0, account_entry.get().strip() or cfg2.get("account", ""))
                    else:
                        code = generate_totp_code(parsed["value"], digits=int(digits_cb.get()), period=int(period_cb.get()), algorithm=alg_cb.get())
                    test_var.set(code)
                    msg.set("Code generated locally. If the website asks for a code, use this current code after saving.")
                    return True
                except Exception as exc:
                    test_var.set("")
                    msg.set(f"Setup key problem: {exc}")
                    return False

            def save_totp(show_after: bool = True):
                try:
                    parsed = read_setup_payload()
                    if parsed["mode"] == "uri":
                        set_totp_config_on_record(rec, uri=parsed["value"], issuer=issuer_entry.get().strip(), account=account_entry.get().strip(), enabled=bool(enabled_var.get()))
                    else:
                        set_totp_config_on_record(rec, secret=parsed["value"], issuer=issuer_entry.get().strip(), account=account_entry.get().strip(), digits=int(digits_cb.get()), period=int(period_cb.get()), algorithm=alg_cb.get(), enabled=bool(enabled_var.get()))
                    if rec.get("category") in {"", "Other"} and standalone:
                        rec["category"] = "2FA"
                    if rec.get("title") in {"", "2FA Authenticator"}:
                        rec["title"] = issuer_entry.get().strip() or account_entry.get().strip() or "2FA Authenticator"
                    if not rec.get("username") and account_entry.get().strip():
                        rec["username"] = account_entry.get().strip()
                    save_plain()
                    refresh_list(search_entry.get().strip())
                    status_text.set("2FA saved. Use the current code to finish setup on the website.")
                    audit("gui_totp_setup", f"record_id={rec.get('id')}")
                    dialog.destroy()
                    if show_after:
                        view_totp_code_dialog(rec)
                except Exception as exc:
                    msg.set(f"2FA setup error: {exc}")

            btns_totp = tk.Frame(panel, bg=COLORS["panel"])
            btns_totp.grid(row=11, column=0, columnspan=2, sticky="ew", pady=(10, 0))
            adv_btn = ttk.Button(btns_totp, text="Show Advanced", command=toggle_advanced, width=16, style="Sentinel.TButton")
            adv_btn.pack(side="left", padx=4)
            ttk.Button(btns_totp, text="Test Code", command=preview_code, width=14, style="Sentinel.TButton").pack(side="left", padx=4)
            ttk.Button(btns_totp, text="Cancel", command=dialog.destroy, width=14, style="Sentinel.TButton").pack(side="right", padx=4)
            ttk.Button(btns_totp, text="Save && Show Code", command=lambda: save_totp(True), width=18, style="Sentinel.TButton").pack(side="right", padx=4)
            panel.columnconfigure(1, weight=1)
            dialog.bind("<Return>", lambda e: (preview_code(), "break"))
            dialog.bind("<Escape>", lambda e: (dialog.destroy(), "break"))
            setup_text.focus_set()

        def view_totp_code_dialog(record: Optional[Dict[str, Any]] = None) -> None:
            rec = normalize_record(record or selected_record_for_totp() or {})
            if not rec.get("id"):
                return
            dialog = tk.Toplevel(root)
            dialog.title("2FA Code")
            dialog.configure(bg=COLORS["bg"])
            dialog.transient(root)
            dialog.grab_set()
            panel = tk.Frame(dialog, bg=COLORS["panel"], padx=14, pady=14, highlightthickness=1, highlightbackground=COLORS["gold"])
            panel.pack(fill="both", expand=True, padx=12, pady=12)
            label(panel, f"2FA code for {rec.get('title','record')}", bg=COLORS["panel"], fg=COLORS["gold"], font=("Sans", 13, "bold")).pack(anchor="w")
            code_var = tk.StringVar(value="------")
            remain_var = tk.StringVar(value="")
            label(panel, "", bg=COLORS["panel"], fg=COLORS["cyan"], textvariable=code_var, font=("Monospace", 30, "bold")).pack(pady=(12, 2))
            label(panel, "", bg=COLORS["panel"], fg=COLORS["muted"], textvariable=remain_var).pack(pady=(0, 10))
            def refresh_code():
                try:
                    out = totp_code_for_record(rec)
                    code_var.set(out["code"])
                    remain_var.set(f"{out['remaining_seconds']}s remaining · {out['digits']} digits · {out['algorithm']} · local only")
                except Exception as exc:
                    code_var.set("ERROR")
                    remain_var.set(str(exc))
                    return
                if dialog.winfo_exists():
                    dialog.after(1000, refresh_code)
            def copy_code():
                value = code_var.get()
                if not value or value == "ERROR":
                    return
                root.clipboard_clear(); root.clipboard_append(value); root.update()
                schedule_clipboard_clear(value)
                audit("gui_copy_totp_code", f"record_id={rec.get('id')}")
                status_text.set(f"Copied 2FA code; clipboard clears in {CLIPBOARD_CLEAR_SECONDS}s")
            btns_totp2 = tk.Frame(panel, bg=COLORS["panel"]); btns_totp2.pack(fill="x", pady=(8,0))
            ttk.Button(btns_totp2, text="Close", command=dialog.destroy, width=14, style="Sentinel.TButton").pack(side="right", padx=4)
            ttk.Button(btns_totp2, text="Copy Code", command=copy_code, width=14, style="Sentinel.TButton").pack(side="right", padx=4)
            audit("gui_view_totp_code", f"record_id={rec.get('id')}")
            refresh_code()

        def render_totp_qr(parent, rec: Dict[str, Any]) -> tk.Widget:
            """Render a local otpauth QR code or a clear URI fallback.

            v0.9.2 deliberately avoids ImageTk/Pillow for the visible QR path.
            When python3-qrcode is available, it draws the QR matrix directly on a
            Tk canvas. If qrcode is missing, it shows the otpauth URI and an
            explicit dependency hint. The URI contains the TOTP secret, so it is
            never logged or exposed to AEGIS by default.
            """
            uri = build_otpauth_uri_for_record(rec)
            box = tk.Frame(parent, bg=COLORS["panel"])

            def copy_setup_uri() -> None:
                root.clipboard_clear()
                root.clipboard_append(uri)
                root.update()
                schedule_clipboard_clear(uri)
                audit("gui_copy_totp_setup_uri", f"record_id={rec.get('id')}")
                status_text.set(f"Copied 2FA setup URI; clipboard clears in {CLIPBOARD_CLEAR_SECONDS}s")

            try:
                import qrcode
                qr = qrcode.QRCode(version=None, error_correction=qrcode.constants.ERROR_CORRECT_M, box_size=1, border=3)
                qr.add_data(uri)
                qr.make(fit=True)
                matrix = qr.get_matrix()
                size = 190
                cells = len(matrix)
                cell = max(2, size // cells)
                canvas_size = cell * cells
                canvas = tk.Canvas(box, width=canvas_size, height=canvas_size, bg=COLORS["fg"], highlightthickness=1, highlightbackground=COLORS["gold"], bd=0)
                canvas.pack(pady=(4, 4), anchor="w")
                for y, row in enumerate(matrix):
                    for x, dark in enumerate(row):
                        if dark:
                            canvas.create_rectangle(x*cell, y*cell, (x+1)*cell, (y+1)*cell, fill=COLORS["bg"], outline=COLORS["bg"])
                label(box, "QR rendered locally. Use it only when registering this authenticator with a service.", bg=COLORS["panel"], fg=COLORS["muted"], wraplength=340, justify="left").pack(anchor="w")
                ttk.Button(box, text="Copy Setup URI", command=copy_setup_uri, width=18, style="Sentinel.TButton").pack(anchor="w", pady=(6, 0))
            except Exception as exc:
                label(box, f"QR renderer unavailable ({type(exc).__name__}). otpauth setup URI:", bg=COLORS["panel"], fg=COLORS["muted"], wraplength=340, justify="left").pack(anchor="w")
                uri_box = tk.Text(box, height=4, width=52, bg=COLORS["field"], fg=COLORS["fg"], insertbackground=COLORS["cyan"], relief="solid", wrap="word")
                uri_box.pack(fill="x", pady=(4, 0))
                uri_box.insert("1.0", uri)
                uri_box.configure(state="disabled")
                label(box, "Install python3-qrcode for QR rendering, or copy the setup URI manually.", bg=COLORS["panel"], fg=COLORS["muted"], wraplength=340, justify="left").pack(anchor="w", pady=(4, 0))
                ttk.Button(box, text="Copy Setup URI", command=copy_setup_uri, width=18, style="Sentinel.TButton").pack(anchor="w", pady=(6, 0))
            return box

        def totp_authenticator_dialog() -> None:
            """Always-available local authenticator panel for all enabled TOTP entries."""
            dialog = tk.Toplevel(root)
            dialog.title("2FA Authenticator")
            dialog.configure(bg=COLORS["bg"])
            dialog.transient(root)
            dialog.geometry("780x520")
            dialog.minsize(700, 460)
            outer = tk.Frame(dialog, bg=COLORS["bg"], padx=10, pady=10)
            outer.pack(fill="both", expand=True)
            panel = tk.Frame(outer, bg=COLORS["panel"], highlightthickness=1, highlightbackground=COLORS["gold"])
            panel.pack(fill="both", expand=True)
            label(panel, "2FA Authenticator", bg=COLORS["panel"], fg=COLORS["gold"], font=("Sans", 14, "bold")).pack(anchor="w", padx=12, pady=(12, 2))
            label(panel, "Standard authenticator-app TOTP only. Codes and QR/setup secrets are local, encrypted, and never logged.", bg=COLORS["panel"], fg=COLORS["muted"]).pack(anchor="w", padx=12, pady=(0, 8))
            body = tk.Frame(panel, bg=COLORS["panel"])
            body.pack(fill="both", expand=True, padx=12, pady=6)
            left2 = tk.Frame(body, bg=COLORS["panel"])
            left2.pack(side="left", fill="both", expand=True, padx=(0, 8))
            right2 = tk.Frame(body, bg=COLORS["panel"])
            right2.pack(side="left", fill="both", expand=True)
            auth_tree = ttk.Treeview(left2, columns=("title", "issuer", "account"), show="headings", selectmode="browse", height=12)
            for col, text, width in [("title", "Title", 160), ("issuer", "Issuer", 120), ("account", "Account", 140)]:
                auth_tree.heading(col, text=text)
                auth_tree.column(col, width=width)
            auth_tree.pack(fill="both", expand=True)
            code_var = tk.StringVar(value="Select or register a 2FA entry")
            remain_var = tk.StringVar(value="")
            qr_frame_holder = tk.Frame(right2, bg=COLORS["panel"])
            qr_visible_for = {"id": None}
            label(right2, "Current Code", bg=COLORS["panel"], fg=COLORS["gold"], font=("Sans", 12, "bold")).pack(anchor="w")
            label(right2, "", bg=COLORS["panel"], fg=COLORS["cyan"], textvariable=code_var, font=("Monospace", 26, "bold")).pack(anchor="w", pady=(8, 0))
            label(right2, "", bg=COLORS["panel"], fg=COLORS["muted"], textvariable=remain_var).pack(anchor="w", pady=(0, 8))
            qr_frame_holder.pack(fill="both", expand=True)
            label(qr_frame_holder, "Setup QR is hidden until you click Show Setup QR. This prevents redraw flashing during normal code viewing.", bg=COLORS["panel"], fg=COLORS["muted"], wraplength=330, justify="left").pack(anchor="w")

            def enabled_totp_records() -> List[Dict[str, Any]]:
                return [normalize_record(r) for r in session["plain"].get("records", []) if normalize_totp_config(r).get("enabled")]

            def reload_auth_list(select_id: Optional[str] = None) -> None:
                auth_tree.delete(*auth_tree.get_children())
                rows = enabled_totp_records()
                for r in rows:
                    cfg = normalize_totp_config(r)
                    auth_tree.insert("", "end", iid=r["id"], values=(r.get("title", ""), cfg.get("issuer", ""), cfg.get("account", "")))
                if select_id and auth_tree.exists(select_id):
                    auth_tree.selection_set(select_id)
                elif rows:
                    auth_tree.selection_set(rows[0]["id"])
                else:
                    code_var.set("No 2FA registered")
                    remain_var.set("Use Register 2FA to add an authenticator entry.")

            def selected_auth_record() -> Optional[Dict[str, Any]]:
                sel = auth_tree.selection()
                rid = sel[0] if sel else None
                if not rid:
                    return None
                return next((r for r in session["plain"].get("records", []) if r.get("id") == rid), None)

            def clear_qr_placeholder(message: str = "Setup QR is hidden until you click Show Setup QR.") -> None:
                qr_visible_for["id"] = None
                for child in qr_frame_holder.winfo_children():
                    child.destroy()
                label(qr_frame_holder, message, bg=COLORS["panel"], fg=COLORS["muted"], wraplength=330, justify="left").pack(anchor="w")

            def show_selected_setup_qr() -> None:
                rec2 = selected_auth_record()
                if rec2 is None:
                    clear_qr_placeholder("Select or register a 2FA entry first.")
                    return
                rid = rec2.get("id")
                # Render once per explicit click. Do not redraw every countdown tick.
                for child in qr_frame_holder.winfo_children():
                    child.destroy()
                render_totp_qr(qr_frame_holder, rec2).pack(fill="x", anchor="w")
                qr_visible_for["id"] = rid

            def refresh_auth_code() -> None:
                try:
                    rec2 = selected_auth_record()
                    if rec2 is None:
                        if not enabled_totp_records():
                            code_var.set("No 2FA registered")
                            remain_var.set("Use Register 2FA to add one.")
                        else:
                            code_var.set("Select a 2FA entry")
                            remain_var.set("")
                        if qr_visible_for.get("id") is not None:
                            clear_qr_placeholder()
                    else:
                        out = totp_code_for_record(rec2)
                        code_var.set(out["code"])
                        remain_var.set(f"{out['remaining_seconds']}s remaining · {out['digits']} digits · {out['algorithm']} · local only")
                        if qr_visible_for.get("id") and qr_visible_for.get("id") != rec2.get("id"):
                            clear_qr_placeholder("Setup QR hidden for the newly selected entry. Click Show Setup QR if needed.")
                except Exception as exc:
                    code_var.set("ERROR")
                    remain_var.set(str(exc))
                if dialog.winfo_exists():
                    dialog.after(1000, refresh_auth_code)

            def copy_auth_code() -> None:
                rec2 = selected_auth_record()
                if rec2 is None:
                    return
                try:
                    value = totp_code_for_record(rec2)["code"]
                    root.clipboard_clear(); root.clipboard_append(value); root.update()
                    schedule_clipboard_clear(value)
                    audit("gui_copy_totp_code", f"record_id={rec2.get('id')}")
                    status_text.set(f"Copied 2FA code; clipboard clears in {CLIPBOARD_CLEAR_SECONDS}s")
                except Exception as exc:
                    show_error(APP_NAME, f"2FA copy failed: {exc}")

            def register_new_2fa() -> None:
                rec2 = create_totp_only_record("2FA Authenticator")
                totp_setup_dialog(rec2, standalone=True)
                reload_auth_list(rec2.get("id"))
                refresh_auth_code()

            def setup_selected_2fa() -> None:
                rec2 = selected_auth_record() or selected_record_for_totp(require=False)
                if rec2 is None:
                    register_new_2fa()
                    return
                totp_setup_dialog(rec2)
                reload_auth_list(rec2.get("id"))
                refresh_auth_code()

            btns_auth = tk.Frame(panel, bg=COLORS["panel"])
            btns_auth.pack(fill="x", padx=12, pady=(6, 12))
            ttk.Button(btns_auth, text="Close", command=dialog.destroy, width=14, style="Sentinel.TButton").pack(side="right", padx=4)
            ttk.Button(btns_auth, text="Show Setup QR", command=show_selected_setup_qr, width=16, style="Sentinel.TButton").pack(side="right", padx=4)
            ttk.Button(btns_auth, text="Copy Code", command=copy_auth_code, width=14, style="Sentinel.TButton").pack(side="right", padx=4)
            ttk.Button(btns_auth, text="Setup Selected", command=setup_selected_2fa, width=14, style="Sentinel.TButton").pack(side="right", padx=4)
            ttk.Button(btns_auth, text="Register 2FA", command=register_new_2fa, width=14, style="Sentinel.TButton").pack(side="right", padx=4)
            auth_tree.bind("<<TreeviewSelect>>", lambda e: refresh_auth_code())
            dialog.bind("<Escape>", lambda e: (dialog.destroy(), "break"))
            reload_auth_list()
            refresh_auth_code()
            audit("gui_totp_authenticator_panel", "opened")

        def copy_record_value(rec: Dict[str, Any], field_name: str, label_text: str) -> None:
            value = str(normalize_record(rec).get(field_name, ""))
            if not value:
                show_error(APP_NAME, f"No {label_text} is present for this record.")
                return
            root.clipboard_clear()
            root.clipboard_append(value)
            root.update()
            schedule_clipboard_clear(value)
            audit("gui_context_copy", f"record_id={rec.get('id')} field={label_text}")
            status_text.set(f"Copied {label_text}; clipboard clears in {CLIPBOARD_CLEAR_SECONDS}s")

        def view_record_details_dialog(record_id: Optional[str] = None) -> None:
            rec = None
            if record_id:
                rec = next((r for r in session["plain"].get("records", []) if r.get("id") == record_id), None)
            if rec is None:
                rec = selected_record()
            if rec is None:
                show_error(APP_NAME, "Select a record first.")
                return
            rec = normalize_record(rec)
            health = password_health(rec, session["plain"].get("records", []))
            dialog = tk.Toplevel(root)
            dialog.title("Record Details")
            dialog.configure(bg=COLORS["bg"])
            dialog.transient(root)
            dialog.grab_set()
            panel = tk.Frame(dialog, bg=COLORS["panel"], highlightthickness=1, highlightbackground=COLORS["gold"])
            panel.pack(fill="both", expand=True, padx=12, pady=12)
            label(panel, "Record Details", bg=COLORS["panel"], fg=COLORS["gold"], font=("Sans", 13, "bold")).grid(row=0, column=0, columnspan=2, sticky="w", padx=12, pady=(12, 8))
            rows = [
                ("Title", rec.get("title", "")),
                ("Username", rec.get("username", "")),
                ("URL / Service", rec.get("url", "")),
                ("Category", rec.get("category", "Other")),
                ("Tags", ", ".join(rec.get("tags", []) if isinstance(rec.get("tags"), list) else [])),
                ("Favorite", "yes" if rec.get("favorite") else "no"),
                ("Created", rec.get("created_at", "")),
                ("Modified", rec.get("updated_at", "")),
                ("Health", f"{health.get('label')} · {', '.join(health.get('issues', [])) or 'no issues'}"),
                ("Secret", "Hidden — use View Secret to reveal temporarily"),
            ]
            for i, (k, v) in enumerate(rows, start=1):
                label(panel, k, bg=COLORS["panel"], fg=COLORS["gold"]).grid(row=i, column=0, sticky="nw", padx=12, pady=3)
                label(panel, str(v), bg=COLORS["panel"], fg=COLORS["fg"], wraplength=640, justify="left").grid(row=i, column=1, sticky="w", padx=8, pady=3)
            note_row = len(rows) + 1
            label(panel, "Notes", bg=COLORS["panel"], fg=COLORS["gold"]).grid(row=note_row, column=0, sticky="nw", padx=12, pady=3)
            txt = tk.Text(panel, height=6, width=76, bg=COLORS["field"], fg=COLORS["fg"], insertbackground=COLORS["cyan"], relief="solid", wrap="word")
            txt.grid(row=note_row, column=1, sticky="nsew", padx=8, pady=3)
            txt.insert("1.0", str(rec.get("notes", "")))
            txt.configure(state="disabled")
            panel.rowconfigure(note_row, weight=1)
            panel.columnconfigure(1, weight=1)
            cfg = normalize_totp_config(rec)
            label(panel, f"2FA: {'Enabled' if cfg.get('enabled') else 'Not set'} · {cfg.get('issuer','')} {cfg.get('account','')}", bg=COLORS["panel"], fg=COLORS["cyan"], wraplength=640, justify="left").grid(row=note_row + 1, column=0, columnspan=2, sticky="w", padx=12, pady=4)
            btns4 = tk.Frame(panel, bg=COLORS["panel"])
            btns4.grid(row=note_row + 2, column=0, columnspan=2, sticky="ew", padx=12, pady=(10, 12))
            ttk.Button(btns4, text="Close", command=dialog.destroy).pack(side="right", padx=4)
            ttk.Button(btns4, text="View Secret", command=lambda r=rec: view_secret_dialog(r)).pack(side="right", padx=4)
            ttk.Button(btns4, text="Copy Secret", command=lambda r=rec: copy_record_value(r, "password", "Secret")).pack(side="right", padx=4)
            ttk.Button(btns4, text="Copy Username", command=lambda r=rec: copy_record_value(r, "username", "Username")).pack(side="right", padx=4)
            dialog.bind("<Escape>", lambda e: (dialog.destroy(), "break"))
            audit("gui_view_record_details", f"record_id={rec.get('id')}")
            status_text.set("Record details opened")

        def show_record_context_menu(event) -> str:
            iid = tree.identify_row(event.y)
            if not iid:
                return "break"
            tree.selection_set(iid)
            tree.focus(iid)
            load_selected()
            rec = selected_record()
            if rec is None:
                return "break"
            menu = tk.Menu(root, tearoff=0, bg=COLORS["panel"], fg=COLORS["fg"], activebackground=COLORS["gold"], activeforeground="#101316", relief="solid", bd=1)
            menu.add_command(label="View Details", command=lambda rid=iid: view_record_details_dialog(rid))
            menu.add_command(label="View Secret", command=lambda r=rec: view_secret_dialog(r))
            menu.add_command(label="View 2FA Code", command=lambda r=rec: view_totp_code_dialog(r))
            menu.add_command(label="Setup 2FA", command=lambda r=rec: totp_setup_dialog(r))
            menu.add_separator()
            menu.add_command(label="Copy Username", command=lambda r=rec: copy_record_value(r, "username", "Username"))
            menu.add_command(label="Copy Secret", command=lambda r=rec: copy_record_value(r, "password", "Secret"))
            menu.add_separator()
            menu.add_command(label="Edit Selected", command=lambda: fields["Title"].focus_set())
            menu.add_command(label="Delete Selected", command=delete_record)
            try:
                menu.tk_popup(event.x_root, event.y_root)
            finally:
                menu.grab_release()
            return "break"

        def password_generator_dialog() -> None:
            dialog = tk.Toplevel(root)
            dialog.title("Random Password Generator")
            dialog.configure(bg=COLORS["bg"])
            dialog.transient(root)
            dialog.grab_set()
            panel = tk.Frame(dialog, bg=COLORS["panel"], highlightthickness=1, highlightbackground=COLORS["gold"])
            panel.pack(fill="both", expand=True, padx=12, pady=12)
            label(panel, "Random Password Generator", bg=COLORS["panel"], fg=COLORS["gold"], font=("Sans", 13, "bold")).grid(row=0, column=0, columnspan=3, sticky="w", padx=12, pady=(12, 8))
            label(panel, "Length", bg=COLORS["panel"], fg=COLORS["fg"]).grid(row=1, column=0, sticky="w", padx=12, pady=4)
            length_var = tk.IntVar(value=20)
            length_entry = entry(panel)
            length_entry.grid(row=1, column=1, sticky="ew", padx=8, pady=4)
            length_entry.insert(0, "20")
            lower_var = tk.BooleanVar(value=True)
            upper_var = tk.BooleanVar(value=True)
            digit_var = tk.BooleanVar(value=True)
            symbol_var = tk.BooleanVar(value=True)
            ambiguous_var = tk.BooleanVar(value=False)
            ttk.Checkbutton(panel, text="Lowercase", variable=lower_var, style="Sentinel.TCheckbutton").grid(row=2, column=1, sticky="w", padx=8, pady=2)
            ttk.Checkbutton(panel, text="Uppercase", variable=upper_var, style="Sentinel.TCheckbutton").grid(row=3, column=1, sticky="w", padx=8, pady=2)
            ttk.Checkbutton(panel, text="Digits", variable=digit_var, style="Sentinel.TCheckbutton").grid(row=4, column=1, sticky="w", padx=8, pady=2)
            ttk.Checkbutton(panel, text="Symbols", variable=symbol_var, style="Sentinel.TCheckbutton").grid(row=5, column=1, sticky="w", padx=8, pady=2)
            ttk.Checkbutton(panel, text="Avoid ambiguous characters", variable=ambiguous_var, style="Sentinel.TCheckbutton").grid(row=6, column=1, sticky="w", padx=8, pady=2)
            generated = tk.StringVar(value="")
            label(panel, "Generated", bg=COLORS["panel"], fg=COLORS["fg"]).grid(row=7, column=0, sticky="w", padx=12, pady=8)
            out_entry = entry(panel)
            out_entry.grid(row=7, column=1, sticky="ew", padx=8, pady=8)
            panel.columnconfigure(1, weight=1)
            def run_generate(_evt=None) -> None:
                try:
                    length = int(length_entry.get().strip() or "20")
                    pw = generate_password(length, lowercase=lower_var.get(), uppercase=upper_var.get(), digits=digit_var.get(), symbols=symbol_var.get(), avoid_ambiguous=ambiguous_var.get())
                    out_entry.delete(0, "end")
                    out_entry.insert(0, pw)
                    out_entry.selection_range(0, "end")
                    audit("gui_random_password_generated", f"length={max(12, min(128, length))} symbols={symbol_var.get()} avoid_ambiguous={ambiguous_var.get()}")
                    status_text.set("Generated random password locally")
                except Exception as exc:
                    show_error(APP_NAME, f"Password generation failed: {exc}")
            def use_generated() -> None:
                pw = out_entry.get()
                if not pw:
                    run_generate()
                    pw = out_entry.get()
                if pw:
                    fields["Password / Secret"].delete(0, "end")
                    fields["Password / Secret"].insert(0, pw)
                    update_strength()
                    status_text.set("Generated password inserted into record")
                    dialog.destroy()
            def copy_generated() -> None:
                pw = out_entry.get()
                if not pw:
                    run_generate()
                    pw = out_entry.get()
                if pw:
                    root.clipboard_clear()
                    root.clipboard_append(pw)
                    root.update()
                    schedule_clipboard_clear(pw)
                    audit("gui_copy_generated_password", "clipboard auto-clear scheduled")
                    status_text.set(f"Copied generated password; clipboard clears in {CLIPBOARD_CLEAR_SECONDS}s")
            btns3 = tk.Frame(panel, bg=COLORS["panel"])
            btns3.grid(row=8, column=0, columnspan=3, sticky="ew", padx=12, pady=(8, 12))
            ttk.Button(btns3, text="Close", command=dialog.destroy).pack(side="right", padx=4)
            ttk.Button(btns3, text="Use for Record", command=use_generated).pack(side="right", padx=4)
            ttk.Button(btns3, text="Copy", command=copy_generated).pack(side="right", padx=4)
            ttk.Button(btns3, text="Generate", command=run_generate).pack(side="right", padx=4)
            dialog.bind("<Return>", run_generate)
            dialog.bind("<Escape>", lambda e: (dialog.destroy(), "break"))
            run_generate()

        def gen_into_password() -> None:
            password_generator_dialog()

        def backup_gui() -> None:
            try:
                dest = create_encrypted_backup("gui")
                status_text.set(f"Encrypted backup created: {dest.name}")
                show_info(APP_NAME, f"Encrypted backup created:\n{dest}")
            except Exception as exc:
                log_gui_error("backup_gui", exc)
                show_error(APP_NAME, f"Backup failed: {exc}")


        def restore_backup_gui() -> None:
            rows = list_encrypted_backups()
            if not rows:
                show_info(APP_NAME, "No encrypted backups found yet.")
                return
            dialog = tk.Toplevel(root)
            dialog.title("Restore Encrypted Backup")
            dialog.configure(bg=COLORS["bg"])
            dialog.transient(root)
            dialog.grab_set()
            label(dialog, "Select encrypted backup to restore. Current vault will be backed up first.", bg=COLORS["bg"], fg=COLORS["gold"]).pack(padx=10, pady=8, anchor="w")
            lb = tk.Listbox(dialog, bg=COLORS["field"], fg=COLORS["fg"], selectbackground=COLORS["gold"], selectforeground="#101316", highlightthickness=1, highlightbackground=COLORS["gold"], highlightcolor=COLORS["cyan"], width=86, height=10)
            lb.pack(fill="both", expand=True, padx=10, pady=8)
            for row in rows:
                lb.insert("end", f"{row['name']}  ·  {row['modified_at']}  ·  {row['size_bytes']} bytes")
            result = {"chosen": None}
            def restore_now(_evt=None):
                sel = lb.curselection()
                if not sel:
                    return
                row = rows[int(sel[0])]
                if not themed_confirm(APP_NAME, f"Restore backup '{row['name']}'? The current vault will be backed up first and the app will lock.", parent=dialog):
                    return
                try:
                    info = restore_encrypted_backup(row["path"], backup_current=True)
                    status_text.set(f"Restored backup: {row['name']}; vault locked")
                    show_info(APP_NAME, "Backup restored. Unlock again with the master password for that backup.\n\n" + json.dumps(info, indent=2))
                    dialog.destroy()
                    build_unlock_screen()
                except Exception as exc:
                    log_gui_error("restore_backup_gui", exc)
                    show_error(APP_NAME, f"Restore failed: {exc}")
            btns2 = tk.Frame(dialog, bg=COLORS["bg"])
            btns2.pack(fill="x", padx=10, pady=8)
            ttk.Button(btns2, text="Cancel", command=dialog.destroy).pack(side="right", padx=4)
            ttk.Button(btns2, text="Restore Selected", command=restore_now).pack(side="right", padx=4)
            lb.bind("<Double-Button-1>", restore_now)
            lb.focus_set()
            if rows:
                lb.selection_set(0)

        def aegis_backup_gui() -> None:
            try:
                result = create_aegis_vault_backup("gui")
                status_text.set("Encrypted AEGIS Vault backup created")
                show_info(APP_NAME, "Encrypted vault backup saved to AEGIS Vault. Passwords and 2FA records remain encrypted.\n\n" + json.dumps(result, indent=2, sort_keys=True))
            except Exception as exc:
                log_gui_error("aegis_backup_gui", exc)
                show_error(APP_NAME, f"AEGIS Vault backup failed: {exc}")

        def restore_aegis_backup_gui() -> None:
            rows = list_aegis_vault_backups()
            if not rows:
                show_info(APP_NAME, f"No AEGIS Vault backups found yet.\n\nExpected folder:\n{aegis_vault_backup_dir()}")
                return
            dialog = tk.Toplevel(root)
            dialog.title("Restore from AEGIS Vault")
            dialog.configure(bg=COLORS["bg"])
            dialog.transient(root)
            dialog.grab_set()
            label(dialog, "Select encrypted AEGIS Vault backup to restore. Current vault will be backed up first.", bg=COLORS["bg"], fg=COLORS["gold"]).pack(padx=10, pady=8, anchor="w")
            lb = tk.Listbox(dialog, bg=COLORS["field"], fg=COLORS["fg"], selectbackground=COLORS["gold"], selectforeground="#101316", highlightthickness=1, highlightbackground=COLORS["gold"], highlightcolor=COLORS["cyan"], width=92, height=10)
            lb.pack(fill="both", expand=True, padx=10, pady=8)
            for row in rows:
                lb.insert("end", f"{row['name']}  ·  {row['modified_at']}  ·  {row['size_bytes']} bytes")
            def restore_now(_evt=None):
                sel = lb.curselection()
                if not sel:
                    return
                row = rows[int(sel[0])]
                if not themed_confirm(APP_NAME, f"Restore AEGIS Vault backup '{row['name']}'? The current vault will be backed up first and the app will lock.", parent=dialog):
                    return
                try:
                    info = restore_aegis_vault_backup(row["path"], backup_current=True)
                    status_text.set(f"Restored AEGIS backup: {row['name']}; vault locked")
                    show_info(APP_NAME, "AEGIS Vault backup restored. Unlock again with that vault's master password.\n\n" + json.dumps(info, indent=2))
                    dialog.destroy()
                    build_unlock_screen()
                except Exception as exc:
                    log_gui_error("restore_aegis_backup_gui", exc)
                    show_error(APP_NAME, f"AEGIS restore failed: {exc}")
            btns2 = tk.Frame(dialog, bg=COLORS["bg"])
            btns2.pack(fill="x", padx=10, pady=8)
            ttk.Button(btns2, text="Cancel", command=dialog.destroy, width=16, style="Sentinel.TButton").pack(side="right", padx=4)
            ttk.Button(btns2, text="Restore Selected", command=restore_now, width=18, style="Sentinel.TButton").pack(side="right", padx=4)
            lb.bind("<Double-Button-1>", restore_now)
            lb.focus_set()
            if rows:
                lb.selection_set(0)

        def browser_toolbar_spec_gui() -> None:
            try:
                show_info("Sentinel Browser 2FA Toolbar Spec", json.dumps(browser_toolbar_spec(), indent=2, sort_keys=True)[:7000])
            except Exception as exc:
                log_gui_error("browser_toolbar_spec_gui", exc)
                show_error(APP_NAME, f"Browser toolbar spec failed: {exc}")

        def browser_2fa_quick_view_spec_gui() -> None:
            try:
                show_info("Browser 2FA Quick View / Setup", json.dumps(browser_2fa_quick_view_spec(), indent=2, sort_keys=True)[:7000])
            except Exception as exc:
                log_gui_error("browser_2fa_quick_view_spec_gui", exc)
                show_error(APP_NAME, f"Browser 2FA quick-view spec failed: {exc}")

        def export_metadata_gui() -> None:
            try:
                dest = data_dir() / f"password-vault-metadata-{_dt.datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
                export_metadata_report(session["plain"], dest)
                status_text.set(f"Metadata-only export: {dest.name}")
                show_info(APP_NAME, f"Metadata-only report exported. No secret values included.\n\n{dest}")
            except Exception as exc:
                log_gui_error("export_metadata_gui", exc)
                show_error(APP_NAME, f"Export failed: {exc}")

        def duplicate_report_gui() -> None:
            try:
                report = duplicate_report(session["plain"])
                status_text.set(f"Duplicate report: {report.get('issue_count', 0)} issue groups")
                show_info(APP_NAME, json.dumps(report, indent=2, sort_keys=True)[:5000])
            except Exception as exc:
                log_gui_error("duplicate_report_gui", exc)
                show_error(APP_NAME, f"Duplicate report failed: {exc}")


        def security_dashboard_gui() -> None:
            try:
                report = security_dashboard(session["plain"])
                status_text.set(f"Security dashboard: {report.get('record_count', 0)} records, {report.get('duplicate_issue_count', 0)} duplicate issue groups")
                show_info("Security Dashboard", json.dumps(report, indent=2, sort_keys=True)[:7000])
            except Exception as exc:
                log_gui_error("security_dashboard_gui", exc)
                show_error(APP_NAME, f"Security dashboard failed: {exc}")

        def theme_status_gui() -> None:
            try:
                report = theme_status()
                status_text.set("Theme status: all declared Sentinel-themed surfaces active")
                show_info("Theme Status", json.dumps(report, indent=2, sort_keys=True)[:5000])
            except Exception as exc:
                log_gui_error("theme_status_gui", exc)
                show_error(APP_NAME, f"Theme status failed: {exc}")

        def ask_master_create_inline() -> Optional[str]:
            dialog = tk.Toplevel(root)
            dialog.title("Change Master Password")
            dialog.configure(bg=COLORS["bg"])
            dialog.transient(root)
            dialog.grab_set()
            result = {"pw": None}
            label(dialog, "New master password:", bg=COLORS["bg"], fg=COLORS["gold"]).grid(row=0, column=0, padx=10, pady=8, sticky="w")
            e1 = entry(dialog, show="*")
            e1.grid(row=0, column=1, padx=10, pady=8)
            label(dialog, "Confirm:", bg=COLORS["bg"], fg=COLORS["gold"]).grid(row=1, column=0, padx=10, pady=8, sticky="w")
            e2 = entry(dialog, show="*")
            e2.grid(row=1, column=1, padx=10, pady=8)
            msg = tk.StringVar(value="")
            label(dialog, "", bg=COLORS["bg"], fg=COLORS["danger"], textvariable=msg).grid(row=2, column=0, columnspan=2, padx=10, pady=4)
            def ok(_evt=None):
                pw = e1.get()
                if len(pw) < 10:
                    msg.set("Use at least 10 characters.")
                    return
                if pw != e2.get():
                    msg.set("Passwords do not match.")
                    return
                result["pw"] = pw
                dialog.destroy()
            ttk.Button(dialog, text="Cancel", command=dialog.destroy).grid(row=3, column=0, padx=10, pady=10)
            ttk.Button(dialog, text="Change", command=ok).grid(row=3, column=1, padx=10, pady=10)
            e2.bind("<Return>", ok)
            e1.focus_set()
            root.wait_window(dialog)
            return result["pw"]

        def change_master_gui() -> None:
            if not themed_confirm(APP_NAME, "Change the master password? An encrypted backup will be created first.", parent=root):
                return
            try:
                backup = create_encrypted_backup("before-master-change")
                pw = ask_master_create_inline()
                if pw is None:
                    return
                new_container = encrypt_vault(session["plain"], pw, None)
                if session.get("container"):
                    new_container["created_at"] = session["container"].get("created_at", new_container.get("created_at"))
                save_container(new_container)
                session["container"] = new_container
                session["master"] = pw
                session["vault_stamp"] = vault_file_stamp()
                audit("gui_change_master_password", f"backup={backup}")
                status_text.set("Master password changed")
                show_info(APP_NAME, f"Master password changed. Backup created first:\n{backup}")
            except Exception as exc:
                log_gui_error("change_master_gui", exc)
                show_error(APP_NAME, f"Master password change failed: {exc}")

        def themed_menu(parent=None):
            return tk.Menu(parent or root, tearoff=0, bg=COLORS["panel"], fg=COLORS["fg"], activebackground=COLORS["gold"], activeforeground="#101316", relief="solid", bd=1)

        def install_top_menu() -> None:
            menubar = tk.Menu(root, bg=COLORS["panel"], fg=COLORS["fg"], activebackground=COLORS["gold"], activeforeground="#101316")
            vault_menu = themed_menu(menubar)
            vault_menu.add_command(label="Backup Vault", command=backup_gui)
            vault_menu.add_command(label="Restore Backup", command=restore_backup_gui)
            vault_menu.add_command(label="Backup to AEGIS Vault", command=aegis_backup_gui)
            vault_menu.add_command(label="Restore from AEGIS Vault", command=restore_aegis_backup_gui)
            vault_menu.add_separator()
            vault_menu.add_command(label="Change Master Password", command=change_master_gui)
            vault_menu.add_command(label="Lock Vault", command=build_unlock_screen)
            vault_menu.add_separator()
            vault_menu.add_command(label="Exit", command=root.destroy)
            menubar.add_cascade(label="Vault", menu=vault_menu)

            records_menu = themed_menu(menubar)
            records_menu.add_command(label="New Record", command=clear_form)
            records_menu.add_command(label="Save Record", command=save_record)
            records_menu.add_command(label="View Details", command=view_record_details_dialog)
            records_menu.add_command(label="Delete Selected", command=delete_record)
            records_menu.add_separator()
            records_menu.add_command(label="Duplicate Report", command=duplicate_report_gui)
            menubar.add_cascade(label="Records", menu=records_menu)

            security_menu = themed_menu(menubar)
            security_menu.add_command(label="View Secret", command=view_secret_dialog)
            security_menu.add_command(label="Random Password Generator", command=gen_into_password)
            security_menu.add_command(label="Security Dashboard", command=security_dashboard_gui)
            menubar.add_cascade(label="Security", menu=security_menu)

            twofa_menu = themed_menu(menubar)
            twofa_menu.add_command(label="2FA Authenticator", command=totp_authenticator_dialog)
            twofa_menu.add_command(label="Browser 2FA Quick View Spec", command=browser_2fa_quick_view_spec_gui)
            twofa_menu.add_command(label="Register New 2FA", command=lambda: totp_setup_dialog(None, standalone=True))
            twofa_menu.add_command(label="Setup Selected Record 2FA", command=totp_setup_dialog)
            twofa_menu.add_command(label="View Selected 2FA Code", command=view_totp_code_dialog)
            twofa_menu.add_command(label="2FA Report", command=lambda: show_info("2FA Report", json.dumps(totp_report(session["plain"]), indent=2, sort_keys=True)[:5000]))
            menubar.add_cascade(label="2FA", menu=twofa_menu)

            tools_menu = themed_menu(menubar)
            tools_menu.add_command(label="Export Metadata", command=export_metadata_gui)
            tools_menu.add_command(label="Theme Status", command=theme_status_gui)
            tools_menu.add_command(label="Browser Toolbar Spec", command=browser_toolbar_spec_gui)
            menubar.add_cascade(label="Tools", menu=tools_menu)

            help_menu = themed_menu(menubar)
            help_menu.add_command(label="About", command=lambda: show_info(APP_NAME, f"{APP_NAME} v{VERSION} — Program {PROGRAM_ID}\nLocal encrypted vault · AEGIS-ready · no cloud · no telemetry"))
            menubar.add_cascade(label="Help", menu=help_menu)
            root.config(menu=menubar)

        install_top_menu()

        def toggle_show() -> None:
            fields["Password / Secret"].config(show="" if show_password.get() else "*")

        def on_search(_evt=None) -> None:
            refresh_list(search_entry.get().strip())

        search_entry.bind("<KeyRelease>", on_search)
        tree.bind("<<TreeviewSelect>>", load_selected)
        tree.bind("<Button-3>", show_record_context_menu)
        tree.bind("<Button-2>", show_record_context_menu)
        def on_record_double_click(event) -> str:
            iid = tree.identify_row(event.y)
            if iid:
                tree.selection_set(iid)
                tree.focus(iid)
                view_record_details_dialog(iid)
            return "break"
        tree.bind("<Double-Button-1>", on_record_double_click)
        tree.bind("<Return>", lambda e: (view_record_details_dialog(), "break"))
        root.bind("<Control-f>", lambda e: (search_entry.focus_set(), "break"))
        root.bind("<Control-F>", lambda e: (search_entry.focus_set(), "break"))
        root.bind("<Control-n>", lambda e: (clear_form(), "break"))
        root.bind("<Control-N>", lambda e: (clear_form(), "break"))
        root.bind("<Control-s>", lambda e: (save_record(), "break"))
        root.bind("<Control-S>", lambda e: (save_record(), "break"))
        root.bind("<Control-l>", lambda e: (build_unlock_screen(), "break"))
        root.bind("<Control-L>", lambda e: (build_unlock_screen(), "break"))

        # Compact main actions only. Utility/security/recovery actions now live in the top menu
        # to avoid button bloat while keeping the core workflow visible.
        btns = tk.Frame(form, bg=COLORS["panel"])
        btns.grid(row=10, column=1, sticky="ew", pady=(6, 4), padx=6)
        main_actions = [
            ("New", clear_form),
            ("Save", save_record),
            ("Details", view_record_details_dialog),
            ("View Secret", view_secret_dialog),
            ("2FA", totp_authenticator_dialog),
        ]
        for col, (_text, _cmd) in enumerate(main_actions):
            btns.columnconfigure(col, weight=1, uniform="vault_main_actions")
            ttk.Button(btns, text=_text, command=_cmd, width=15, style="Sentinel.TButton").grid(row=0, column=col, sticky="ew", padx=3, pady=2)
        show_row = tk.Frame(btns, bg=COLORS["panel"])
        show_row.grid(row=1, column=0, columnspan=len(main_actions), sticky="w", pady=(2, 0))
        ttk.Checkbutton(show_row, text="Show password field", variable=show_password, command=toggle_show, style="Sentinel.TCheckbutton").pack(side="left", padx=2)

        footer = tk.Frame(root, bg=COLORS["bg"])
        footer.pack(fill="x", padx=8, pady=(0, 6))
        label(footer, "", fg=COLORS["muted"], textvariable=status_text).pack(side="left")
        label(footer, f"Vault: {vault_path()}", fg=COLORS["muted"]).pack(side="left", padx=8)
        ttk.Button(footer, text="Lock Vault", command=build_unlock_screen, width=12, style="Sentinel.TButton").pack(side="right", padx=4)
        ttk.Button(footer, text="Exit", command=root.destroy, width=12, style="Sentinel.TButton").pack(side="right", padx=4)

        refresh_list()
        clear_form()
        search_entry.focus_set()

    def build_unlock_screen() -> None:
        session["ui_generation"] = int(session.get("ui_generation", 0)) + 1
        session.update({"container": None, "plain": None, "master": None, "selected_id": None, "form_mode": "new", "vault_stamp": (0, 0)})
        try:
            root.config(menu=tk.Menu(root))
        except Exception:
            pass
        for child in root.winfo_children():
            child.destroy()
        create_header(root)
        panel = tk.Frame(root, bg=COLORS["panel"])
        panel.pack(fill="both", expand=True, padx=80, pady=40)
        vault_exists = vault_path().exists()
        mode_text = "Unlock existing vault" if vault_exists else "Create new encrypted vault"
        label(panel, mode_text, bg=COLORS["panel"], fg=COLORS["gold"], font=("Sans", 14, "bold")).pack(pady=(18, 6))
        label(panel, "Local unlock screen. Enter unlocks/creates. Shortcuts: Ctrl+F search, Ctrl+N new, Ctrl+S save, Ctrl+L lock, Ctrl+Q close.", bg=COLORS["panel"], fg=COLORS["muted"], wraplength=620).pack(pady=(0, 12))
        form = tk.Frame(panel, bg=COLORS["panel"])
        form.pack(pady=8)
        label(form, "Master password", bg=COLORS["panel"], fg=COLORS["fg"]).grid(row=0, column=0, sticky="w", padx=8, pady=6)
        pw_entry = entry(form, show="*")
        pw_entry.grid(row=0, column=1, sticky="ew", padx=8, pady=6)
        confirm_entry = None
        if not vault_exists:
            label(form, "Confirm password", bg=COLORS["panel"], fg=COLORS["fg"]).grid(row=1, column=0, sticky="w", padx=8, pady=6)
            confirm_entry = entry(form, show="*")
            confirm_entry.grid(row=1, column=1, sticky="ew", padx=8, pady=6)
        form.columnconfigure(1, weight=1)
        local_status = tk.StringVar(value=f"Vault path: {vault_path()}")
        label(panel, "", bg=COLORS["panel"], fg=COLORS["cyan"], textvariable=local_status).pack(pady=10)
        button_frame = tk.Frame(panel, bg=COLORS["panel"])
        button_frame.pack(pady=10)
        unlock_button = ttk.Button(button_frame, text="Unlock" if vault_exists else "Create Vault", width=16, style="Sentinel.TButton")
        unlock_button.pack(side="left", padx=5)
        ttk.Button(button_frame, text="Exit", command=root.destroy, width=16, style="Sentinel.TButton").pack(side="left", padx=5)

        def set_busy(busy: bool) -> None:
            state = "disabled" if busy else "normal"
            try:
                unlock_button.configure(state=state)
                pw_entry.configure(state=state)
                if confirm_entry is not None:
                    confirm_entry.configure(state=state)
                root.configure(cursor="watch" if busy else "")
                root.update_idletasks()
            except Exception:
                pass

        def submit(_evt=None) -> None:
            pw = pw_entry.get()
            if len(pw) < 1:
                local_status.set("Enter the master password first.")
                return
            if not vault_exists:
                if len(pw) < 10:
                    local_status.set("Master password must be at least 10 characters.")
                    return
                if confirm_entry is None or pw != confirm_entry.get():
                    local_status.set("Passwords do not match.")
                    return
            local_status.set("Unlocking encrypted vault locally…")
            set_busy(True)
            root.after(50, lambda: finish_unlock(pw))

        def finish_unlock(pw: str) -> None:
            try:
                if not vault_exists:
                    plain = empty_vault()
                    container = encrypt_vault(plain, pw)
                    save_container(container)
                    audit("gui_init_vault", "created vault")
                    session.update({"container": container, "plain": plain, "master": pw, "vault_stamp": vault_file_stamp()})
                else:
                    container = load_container()
                    plain = decrypt_vault(container, pw)
                    session.update({"container": container, "plain": plain, "master": pw, "vault_stamp": vault_file_stamp()})
                    audit("gui_unlock", "success")
                set_busy(False)
                build_main_ui()
            except Exception as exc:
                log_gui_error("finish_unlock", exc)
                audit("unlock_failed", type(exc).__name__)
                set_busy(False)
                local_status.set("Unlock failed. Check the master password or vault file.")
                pw_entry.configure(state="normal")
                if confirm_entry is not None:
                    confirm_entry.configure(state="normal")
                pw_entry.delete(0, "end")
                pw_entry.focus_set()

        unlock_button.configure(command=submit)
        pw_entry.bind("<Return>", submit)
        if confirm_entry is not None:
            confirm_entry.bind("<Return>", submit)
        pw_entry.focus_set()

    root.bind("<Control-q>", lambda e: root.destroy())
    root.bind("<Control-Q>", lambda e: root.destroy())
    try:
        build_unlock_screen()
        root.mainloop()
    except Exception as exc:
        log_gui_error("mainloop", exc)
        try:
            show_error(APP_NAME, f"GUI error logged to:\n{gui_error_path()}\n\n{type(exc).__name__}: {exc}")
        except Exception:
            pass
        return 1
    return 0

def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(description=f"{APP_NAME} v{VERSION} — Program {PROGRAM_ID}")
    p.add_argument("--version", action="store_true", help="show version")
    p.add_argument("--manifest", action="store_true", help="print AEGIS manifest JSON")
    p.add_argument("--write-manifest", metavar="PATH", help="write AEGIS manifest JSON to PATH")
    p.add_argument("--status", action="store_true", help="print local status JSON")
    p.add_argument("--self-check", action="store_true", help="run local self-check")
    p.add_argument("--backup", action="store_true", help="create encrypted backup copy of the vault")
    p.add_argument("--backup-label", default="manual", help="label for --backup output filename")
    p.add_argument("--change-master-password", action="store_true", help="change the master password after creating an encrypted backup")
    p.add_argument("--audit-tail", type=int, metavar="N", help="show the last N audit log entries")
    p.add_argument("--list-backups", action="store_true", help="list encrypted local vault backups")
    p.add_argument("--restore-backup", metavar="NAME_OR_PATH", help="restore an encrypted backup after backing up the current vault; requires --yes")
    p.add_argument("--aegis-backup", action="store_true", help="copy the encrypted vault to the AEGIS Vault export folder")
    p.add_argument("--list-aegis-backups", action="store_true", help="list encrypted Password Vault backups stored in the AEGIS Vault")
    p.add_argument("--restore-aegis-backup", metavar="NAME_OR_PATH", help="restore an encrypted Password Vault backup from the AEGIS Vault; requires --yes")
    p.add_argument("--browser-bridge-status", action="store_true", help="print Sentinel Browser password/2FA bridge readiness JSON")
    p.add_argument("--browser-toolbar-spec", action="store_true", help="print Sentinel Browser 2FA toolbar button spec JSON")
    p.add_argument("--browser-2fa-popup-status", action="store_true", help="print restricted browser 2FA mini-popup receiver status JSON")
    p.add_argument("--browser-2fa-popup", action="store_true", help="open restricted Vault-owned browser 2FA mini popup")
    p.add_argument("--browser-password-popup-status", action="store_true", help="print restricted browser password-fill receiver status JSON")
    p.add_argument("--browser-password-popup", action="store_true", help="open restricted Vault-owned login chooser and return one approved credential over stdout")
    p.add_argument("--browser-save-popup-status", action="store_true", help="print restricted browser save/update receiver status JSON")
    p.add_argument("--browser-save-popup", action="store_true", help="read one credential candidate from stdin JSON and open the restricted save/update popup")
    p.add_argument("--browser-2fa-quick-view", action="store_true", help="print metadata-only Sentinel Browser 2FA quick-view payload after unlock")
    p.add_argument("--browser-origin", help="browser origin/URL used for local 2FA matching")
    p.add_argument("--browser-2fa-code", metavar="IDENT", help="print current TOTP code for browser after explicit --yes approval")
    p.add_argument("--browser-save-preview", action="store_true", help="preview browser save-login candidate without returning or storing secret values")
    p.add_argument("--browser-save-login", action="store_true", help="save a user-approved browser login into the encrypted vault; requires --yes")
    p.add_argument("--browser-save-matches", action="store_true", help="after unlock, return metadata-only existing login matches for a browser save prompt")
    p.add_argument("--browser-update-login", metavar="IDENT", help="update an existing vault login from an approved browser prompt; requires --yes")
    p.add_argument("--browser-title", help="candidate login title supplied by Sentinel Browser")
    p.add_argument("--browser-username", help="candidate login username/email supplied by Sentinel Browser")
    p.add_argument("--browser-url", help="candidate login URL supplied by Sentinel Browser")
    p.add_argument("--browser-password", help=argparse.SUPPRESS)
    p.add_argument("--browser-password-stdin", action="store_true", help="read an approved browser save/update password from stdin; never place secrets in argv")
    p.add_argument("--browser-notes", help="candidate notes supplied by Sentinel Browser")
    p.add_argument("--browser-category", default="Browser", help="category for browser-saved login records")
    p.add_argument("--browser-tags", default="browser,saved-login", help="comma-separated tags for browser-saved login records")
    p.add_argument("--export-metadata", metavar="PATH", help="export metadata-only JSON report without secret values")
    p.add_argument("--export-json", metavar="PATH", help="export decrypted JSON including secrets; requires --yes")
    p.add_argument("--import-json", metavar="PATH", help="import decrypted JSON records into current vault after backup; requires --yes")
    p.add_argument("--duplicate-report", action="store_true", help="show duplicate/reuse report after unlock")
    p.add_argument("--favorite-report", action="store_true", help="show favorite/pinned record metadata after unlock")
    p.add_argument("--security-dashboard", action="store_true", help="show local metadata-only security dashboard after unlock")
    p.add_argument("--theme-report", action="store_true", help="show Sentinel GUI theme coverage report")
    p.add_argument("--integrity-check", action="store_true", help="unlock and run metadata-only vault integrity checks")
    p.add_argument("--backup-freshness", action="store_true", help="check local and AEGIS encrypted backup freshness")
    p.add_argument("--browser-provider-compatibility", action="store_true", help="show Sentinel Browser provider compatibility report")
    p.add_argument("--totp-health", action="store_true", help="unlock and run metadata-only TOTP health checks")
    p.add_argument("--release-candidate-report", action="store_true", help="run v1.0 pre-lock release-candidate readiness report")
    p.add_argument("--stable-lock-report", action="store_true", help="run v1.0 stable-lock readiness report")
    p.add_argument("--no-unlock", action="store_true", help="with --release-candidate-report, skip vault unlock-dependent checks")
    p.add_argument("--totp-options", action="store_true", help="show supported local TOTP/2FA options")
    p.add_argument("--totp-report", action="store_true", help="show metadata-only TOTP/2FA report after unlock")
    p.add_argument("--add-totp", metavar="IDENT", help="add/update a standard authenticator-app TOTP secret for a record")
    p.add_argument("--totp-code", metavar="IDENT", help="show current TOTP code for a record after unlock")
    p.add_argument("--totp-secret", help="manual base32 TOTP secret for --add-totp")
    p.add_argument("--totp-uri", help="otpauth://totp URI for --add-totp")
    p.add_argument("--totp-issuer", help="TOTP issuer/service label")
    p.add_argument("--totp-account", help="TOTP account label")
    p.add_argument("--totp-digits", type=int, choices=[6, 8], default=TOTP_DEFAULT_DIGITS)
    p.add_argument("--totp-period", type=int, choices=[30, 60], default=TOTP_DEFAULT_PERIOD)
    p.add_argument("--totp-algorithm", choices=["SHA1", "SHA256", "SHA512"], default=TOTP_DEFAULT_ALGORITHM)
    p.add_argument("--category-report", action="store_true", help="show local category counts after unlock")
    p.add_argument("--password-health", action="store_true", help="show local password health metadata after unlock")
    p.add_argument("--json-action", help="run an AEGIS JSON action from JSON string or @file")
    p.add_argument("--aegis-action", help="alias of --json-action")
    p.add_argument("--init", action="store_true", help="initialize encrypted vault")
    p.add_argument("--force", action="store_true", help="allow --init to overwrite existing vault")
    p.add_argument("--add", action="store_true", help="add a record interactively or from flags")
    p.add_argument("--title")
    p.add_argument("--username")
    p.add_argument("--url")
    p.add_argument("--password")
    p.add_argument("--notes")
    p.add_argument("--category")
    p.add_argument("--tags")
    p.add_argument("--favorite", action="store_true", help="mark new CLI record as favorite")
    p.add_argument("--generate", action="store_true", help="generate password for --add")
    p.add_argument("--length", type=int, default=20, help="generated password length")
    p.add_argument("--list", action="store_true", help="list vault metadata")
    p.add_argument("--sort", choices=["title", "category", "username", "updated_at", "created_at"], default="title", help="sort --list output")
    p.add_argument("--search", dest="query", help="search vault metadata")
    p.add_argument("--show", dest="ident", help="show one record by id/title/search term")
    p.add_argument("--field", choices=["all", "title", "username", "url", "password", "notes"], default="all")
    p.add_argument("--reveal", action="store_true", help="print password/secret when using --show")
    p.add_argument("--delete", dest="delete_ident", help="delete record by id/title/search term")
    p.add_argument("--yes", action="store_true", help="confirm destructive CLI action")
    p.add_argument("--json", action="store_true", help="JSON output for list/search")
    p.add_argument("--generate-password", action="store_true", help="generate and print standalone random password")
    p.add_argument("--no-symbols", action="store_true", help="omit symbols from generated passwords")
    p.add_argument("--avoid-ambiguous", action="store_true", help="avoid visually ambiguous generated-password characters")
    p.add_argument("--gui", action="store_true", help="launch GUI explicitly")
    return p


def main(argv: Optional[List[str]] = None) -> int:
    argv = list(sys.argv[1:] if argv is None else argv)
    enforce_not_root_for_vault_actions(argv)
    parser = build_parser()
    args = parser.parse_args(argv)
    if args.version:
        print(f"{APP_NAME} {VERSION} (Program {PROGRAM_ID})")
        return 0
    if args.manifest:
        print(json.dumps(AEGIS_MANIFEST, indent=2, sort_keys=True))
        return 0
    if args.write_manifest:
        out = Path(args.write_manifest).expanduser()
        out.parent.mkdir(parents=True, exist_ok=True)
        out.write_text(json.dumps(AEGIS_MANIFEST, indent=2, sort_keys=True) + "\n", encoding="utf-8")
        print(f"Wrote manifest: {out}")
        return 0
    if args.status:
        print(json.dumps(status_dict(), indent=2, sort_keys=True))
        return 0
    if args.self_check:
        result = self_check()
        print(json.dumps(result, indent=2, sort_keys=True))
        return 0 if result.get("ok") else 1
    if args.backup:
        return cmd_backup(args)
    if args.change_master_password:
        return cmd_change_master_password(args)
    if args.audit_tail is not None:
        return cmd_audit_tail(args)
    if args.list_backups:
        return cmd_list_backups(args)
    if args.restore_backup:
        return cmd_restore_backup(args)
    if args.aegis_backup:
        return cmd_aegis_backup(args)
    if args.list_aegis_backups:
        return cmd_list_aegis_backups(args)
    if args.restore_aegis_backup:
        return cmd_restore_aegis_backup(args)
    if args.browser_bridge_status:
        return cmd_browser_bridge_status(args)
    if args.browser_toolbar_spec:
        return cmd_browser_toolbar_spec(args)
    if args.browser_2fa_popup_status:
        return cmd_browser_2fa_popup_status(args)
    if args.browser_2fa_popup:
        return cmd_browser_2fa_popup(args)
    if args.browser_password_popup_status:
        return cmd_browser_password_popup_status(args)
    if args.browser_password_popup:
        return cmd_browser_password_popup(args)
    if args.browser_save_popup_status:
        return cmd_browser_save_popup_status(args)
    if args.browser_save_popup:
        return cmd_browser_save_popup(args)
    if args.browser_2fa_quick_view:
        return cmd_browser_2fa_quick_view(args)
    if args.browser_2fa_code:
        return cmd_browser_2fa_code(args)
    if args.browser_save_preview:
        return cmd_browser_save_preview(args)
    if args.browser_save_matches:
        return cmd_browser_save_matches(args)
    if args.browser_save_login:
        return cmd_browser_save_login(args)
    if args.browser_update_login:
        return cmd_browser_update_login(args)
    if args.export_metadata:
        return cmd_export_metadata(args)
    if args.export_json:
        return cmd_export_json(args)
    if args.import_json:
        return cmd_import_json(args)
    if args.duplicate_report:
        return cmd_duplicate_report(args)
    if args.favorite_report:
        return cmd_favorite_report(args)
    if args.security_dashboard:
        return cmd_security_dashboard(args)
    if args.theme_report:
        return cmd_theme_report(args)
    if args.integrity_check:
        return cmd_integrity_check(args)
    if args.backup_freshness:
        return cmd_backup_freshness(args)
    if args.browser_provider_compatibility:
        return cmd_browser_provider_compatibility(args)
    if args.totp_health:
        return cmd_totp_health(args)
    if args.release_candidate_report:
        return cmd_release_candidate_report(args)
    if args.stable_lock_report:
        return cmd_stable_lock_report(args)
    if args.totp_options:
        print(json.dumps(totp_options(), indent=2, sort_keys=True))
        return 0
    if args.totp_report:
        return cmd_totp_report(args)
    if args.add_totp:
        return cmd_add_totp(args)
    if args.totp_code:
        return cmd_totp_code(args)
    if args.category_report:
        _, plain, _ = unlock()
        print(json.dumps(category_report(plain), indent=2, sort_keys=True))
        return 0
    if args.password_health:
        _, plain, _ = unlock()
        print(json.dumps(vault_health_report(plain), indent=2, sort_keys=True))
        return 0
    action_payload = args.json_action or args.aegis_action
    if action_payload:
        result = handle_json_action(parse_json_action(action_payload))
        print(json.dumps(result, indent=2, sort_keys=True))
        return 0 if result.get("ok", True) is not False else 1
    if args.generate_password:
        print(generate_password(args.length, symbols=not getattr(args, "no_symbols", False), avoid_ambiguous=getattr(args, "avoid_ambiguous", False)))
        return 0
    if args.init:
        return cmd_init(args)
    if args.add:
        return cmd_add(args)
    if args.list:
        return cmd_list(args)
    if args.query:
        return cmd_search(args)
    if args.ident:
        return cmd_show(args)
    if args.delete_ident:
        args.ident = args.delete_ident
        return cmd_delete(args)
    return launch_gui()


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