import json
import re
import secrets
import string
import sys
from pathlib import Path

import click
from rich.console import Console
from rich.markdown import Markdown
from rich.prompt import Confirm, Prompt
from rich.columns import Columns
from rich.panel import Panel
from rich.rule import Rule
from rich.syntax import Syntax
from rich.table import Table

from .config import get_vault_path
from .crypto import derive_key, encrypt as crypto_encrypt, decrypt as crypto_decrypt, generate_salt
from .vault import AuthError, EntryExistsError, EntryNotFoundError, Vault


def _complete_entry_name(ctx, param, incomplete):
    """Shell completion: return entry names matching the incomplete prefix."""
    try:
        from .session import load_session
        vault_path = get_vault_path()
        vault = Vault(vault_path)
        if not vault.is_initialized():
            return []
        key = load_session(vault_path)
        if key is None:
            return []
        vault.unlock_with_key(key)
        entries = vault.list_entries()
        return [e["name"] for e in entries if e["name"].lower().startswith(incomplete.lower())]
    except Exception:
        return []

console = Console()

# ── Constants ─────────────────────────────────────────────────────────────────

DEFAULT_FOLDER = "personal"
ENTRY_TYPES    = ["login", "database", "api", "note"]
DB_KINDS       = ["mysql", "postgres", "redis", "mongodb", "sqlite", "other"]
DB_PORTS       = {"mysql": "3306", "postgres": "5432", "redis": "6379", "mongodb": "27017"}
ENVIRONMENTS   = ["production", "staging", "development"]

TYPE_LABELS = {
    "login":    "Login",
    "database": "Database",
    "api":      "API Credential",
    "note":     "Secure Note",
}

TYPE_SHORT = {
    "login":    "Login",
    "database": "Database",
    "api":      "API",
    "note":     "Note",
}

OUTPUT_CHOICES = ["table", "json", "raw"]

# ── Shared helpers ────────────────────────────────────────────────────────────

def _generate_password(length: int = 20, symbols: bool = True, numbers: bool = True) -> str:
    chars = string.ascii_letters
    if numbers:
        chars += string.digits
    if symbols:
        chars += "!@#$%^&*()-_=+[]{}|;:,.<>?"
    return "".join(secrets.choice(chars) for _ in range(length))


# Unique sentinel that won't appear in real content
_EDITOR_SENTINEL = "# ─── pm: save and close — everything above this line is kept ───\n"

def _prompt_notes(default: str = "") -> str:
    """Prompt for notes.

    - Type inline and press Enter for a short single-line note.
    - Press Enter immediately to open $EDITOR for multiline content
      (SQL scripts, markdown, SSH keys, setup instructions, etc.).
    """
    console.print(
        "  Notes [dim](Enter = open \\$EDITOR for multiline)[/dim]: ",
        end="",
    )
    try:
        line = input()
    except EOFError:
        return default

    if line.strip() != "":
        return line

    # Empty → open editor; sentinel marks where content ends
    initial = (default + "\n\n") if default else "\n"
    initial += _EDITOR_SENTINEL
    result = click.edit(initial)
    if result is None:
        return default
    # Keep only content above the sentinel (preserves all # lines in content)
    if _EDITOR_SENTINEL in result:
        content = result[: result.index(_EDITOR_SENTINEL)]
    else:
        content = result
    return content.strip()


def _prompt_password(label: str = "Password") -> str:
    """Ask generate or enter for any secret field."""
    if Confirm.ask(f"  Generate {label}?", default=True):
        pw = _generate_password()
        console.print(f"  Generated: [bold cyan]{pw}[/bold cyan]")
        return pw
    return click.prompt(f"  {label}", hide_input=True, confirmation_prompt=True)


def _confirm_folder(vault: Vault, folder: str) -> str:
    """Warn and confirm before creating a brand-new folder name."""
    existing = {r["folder"] for r in vault.list_folders()}
    if folder in existing or not existing:
        return folder
    names = ", ".join(sorted(existing))
    console.print(
        f"[yellow]  Folder '[bold]{folder}[/bold]' doesn't exist.[/yellow]"
        f"  Existing folders: [cyan]{names}[/cyan]"
    )
    if not Confirm.ask(f"  Create new folder '[bold]{folder}[/bold]'?", default=False):
        console.print("Aborted.")
        sys.exit(0)
    return folder


def _ask_folder(vault: Vault, default: str = DEFAULT_FOLDER) -> str:
    folder = Prompt.ask("  Folder", default=default)
    return _confirm_folder(vault, folder)


def _open_vault(master: str | None) -> Vault:
    vault_path = get_vault_path()
    vault = Vault(vault_path)
    if not vault.is_initialized():
        console.print("[red]Vault not initialized.[/red] Run: [bold]pm init[/bold]")
        sys.exit(1)

    # 1. Try cached session key (skip prompt if still valid)
    if master is None:
        from .session import load_session, clear_session
        cached_key = load_session(vault_path)
        if cached_key is not None:
            try:
                vault.unlock_with_key(cached_key)
                return vault
            except AuthError:
                # Session key stale (vault re-initialised?) — clear and fall through
                clear_session(vault_path)

    # 2. Prompt / use env-var password
    if master is None:
        master = click.prompt("Master password", hide_input=True)
    try:
        vault.unlock(master)
    except AuthError as exc:
        console.print(f"[red]Error:[/red] {exc}")
        sys.exit(1)

    # 3. Cache the derived key for subsequent commands
    from .session import save_session
    save_session(vault_path, vault._key)

    return vault


# ── Wizards ───────────────────────────────────────────────────────────────────

def _prompt_custom_fields(existing: list | None = None) -> list:
    """Interactively add/edit custom fields. Returns list of {label, value} dicts."""
    fields = list(existing or [])
    if fields:
        console.print(f"  [dim]Current custom fields: {len(fields)}[/dim]")
        for i, f in enumerate(fields, 1):
            console.print(f"  [dim]  {i}. {f['label']}: {f['value']}[/dim]")
    if not Confirm.ask("  Add/edit custom fields?", default=False):
        return fields
    console.print("  [dim]Enter label=value pairs, one per line. Empty line to finish.[/dim]")
    new_fields = []
    while True:
        console.print("  Custom field (label=value or empty to finish): ", end="")
        try:
            line = input().strip()
        except EOFError:
            break
        if not line:
            break
        if "=" in line:
            label, _, value = line.partition("=")
            new_fields.append({"label": label.strip(), "value": value.strip()})
        else:
            console.print("  [yellow]Format: label=value[/yellow]")
    return fields + new_fields


def _prompt_totp(default: str = "") -> str:
    """Ask for an optional TOTP secret. Returns the secret or empty string."""
    hint = f" [dim](current: set)[/dim]" if default else " [dim](leave blank to skip)[/dim]"
    console.print(f"  TOTP Secret{hint}: ", end="")
    try:
        val = input().strip()
    except EOFError:
        return default
    if not val:
        return default
    # Validate it's a valid base32 TOTP secret
    try:
        import pyotp
        pyotp.TOTP(val).now()
        return val
    except Exception:
        console.print("[yellow]  Invalid TOTP secret — skipped.[/yellow]")
        return default


def _wizard_login(vault: Vault) -> dict:
    name     = Prompt.ask("  Name")
    username = Prompt.ask("  Username / email")
    password = _prompt_password()
    url      = Prompt.ask("  Website", default="")
    totp     = _prompt_totp()
    notes    = _prompt_notes()
    folder   = _ask_folder(vault)
    custom = _prompt_custom_fields()
    meta: dict = {}
    if totp:
        meta["totp"] = totp
    if custom:
        meta["custom_fields"] = custom
    return dict(name=name, username=username, password=password,
                url=url, notes=notes, folder=folder,
                entry_type="login", metadata=meta)


def _wizard_database(vault: Vault) -> dict:
    name     = Prompt.ask("  Name")
    kind     = Prompt.ask("  Kind", choices=DB_KINDS, default="postgres")
    host     = Prompt.ask("  Host", default="localhost")
    port     = Prompt.ask("  Port", default=DB_PORTS.get(kind, "5432"))
    database = Prompt.ask("  Database")
    username = Prompt.ask("  Username", default="")
    password = _prompt_password()
    notes    = _prompt_notes()
    folder   = _ask_folder(vault)
    return dict(name=name, username=username, password=password,
                url="", notes=notes, folder=folder,
                entry_type="database",
                metadata={"kind": kind, "host": host, "port": port, "database": database})


def _wizard_api(vault: Vault) -> dict:
    name        = Prompt.ask("  Name")
    service     = Prompt.ask("  Service (e.g. stripe, openai)")
    api_key     = click.prompt("  API Key", hide_input=True)
    api_secret  = Prompt.ask("  API Secret (leave blank if none)", default="")
    endpoint    = Prompt.ask("  Endpoint URL", default="")
    environment = Prompt.ask("  Environment", choices=ENVIRONMENTS, default="production")
    notes       = _prompt_notes()
    folder      = _ask_folder(vault)
    return dict(name=name, username=api_key, password=api_secret,
                url=endpoint, notes=notes, folder=folder,
                entry_type="api",
                metadata={"service": service, "environment": environment})


def _wizard_note(vault: Vault) -> dict:
    name    = Prompt.ask("  Title")
    content = _prompt_notes()   # content IS the note — always multiline-capable
    folder  = _ask_folder(vault)
    return dict(name=name, username="", password="",
                url="", notes=content, folder=folder,
                entry_type="note", metadata={})


_WIZARDS = {
    "login":    _wizard_login,
    "database": _wizard_database,
    "api":      _wizard_api,
    "note":     _wizard_note,
}


def _run_wizard(vault: Vault) -> dict:
    console.print(Rule("[bold]New Entry[/bold]"))
    entry_type = Prompt.ask("  Type", choices=ENTRY_TYPES, default="login")
    console.print(Rule(f"[cyan]{TYPE_LABELS[entry_type]}[/cyan]"))
    return _WIZARDS[entry_type](vault)


# ── Edit wizards (pre-populated with existing values) ─────────────────────────

def _edit_wizard(vault: Vault, entry: dict) -> dict:
    etype = entry["entry_type"]
    meta  = entry["metadata"]
    console.print(Rule(f"[cyan]Edit {TYPE_LABELS.get(etype, etype)}: {entry['name']}[/cyan]"))
    updates: dict = {}

    if etype == "login":
        updates["username"] = Prompt.ask("  Username / email", default=entry["username"])
        if Confirm.ask("  Change password?", default=False):
            updates["password"] = _prompt_password()
        updates["url"]  = Prompt.ask("  Website", default=entry["url"])
        totp = _prompt_totp(default=meta.get("totp", ""))
        custom = _prompt_custom_fields(existing=meta.get("custom_fields"))
        new_meta = {**meta}
        if totp:
            new_meta["totp"] = totp
        elif "totp" in new_meta:
            del new_meta["totp"]
        new_meta["custom_fields"] = custom
        updates["metadata"] = new_meta
        updates["notes"] = _prompt_notes(default=entry["notes"])

    elif etype == "database":
        kind = Prompt.ask("  Kind", choices=DB_KINDS, default=meta.get("kind", "postgres"))
        host = Prompt.ask("  Host",     default=meta.get("host", "localhost"))
        port = Prompt.ask("  Port",     default=meta.get("port", DB_PORTS.get(kind, "5432")))
        db   = Prompt.ask("  Database", default=meta.get("database", ""))
        updates["username"] = Prompt.ask("  Username", default=entry["username"])
        if Confirm.ask("  Change password?", default=False):
            updates["password"] = _prompt_password()
        updates["notes"]    = _prompt_notes(default=entry["notes"])
        updates["metadata"] = {**meta, "kind": kind, "host": host, "port": port, "database": db}

    elif etype == "api":
        svc = Prompt.ask("  Service", default=meta.get("service", ""))
        if Confirm.ask("  Change API Key?", default=False):
            updates["username"] = _prompt_password("API Key")
        if Confirm.ask("  Change API Secret?", default=False):
            updates["password"] = _prompt_password("API Secret")
        updates["url"]  = Prompt.ask("  Endpoint URL", default=entry["url"])
        env = Prompt.ask("  Environment", choices=ENVIRONMENTS,
                         default=meta.get("environment", "production"))
        updates["notes"]    = _prompt_notes(default=entry["notes"])
        updates["metadata"] = {**meta, "service": svc, "environment": env}

    elif etype == "note":
        updates["notes"] = _prompt_notes(default=entry["notes"])

    updates["folder"] = _ask_folder(vault, default=entry["folder"])
    return updates


# ── Output helpers ───────────────────────────────────────────────────────────

CLIPBOARD_CLEAR_SECONDS = 45


def _copy_to_clipboard(text: str, auto_clear: bool = True) -> bool:
    try:
        import pyperclip  # type: ignore
        pyperclip.copy(text)
        if auto_clear:
            _schedule_clipboard_clear(text, CLIPBOARD_CLEAR_SECONDS)
        return True
    except ImportError:
        console.print("[yellow]Install pyperclip for clipboard support: pip install pyperclip[/yellow]")
        return False


def _schedule_clipboard_clear(copied_text: str, delay: int) -> None:
    """Spawn a detached background process that clears the clipboard after *delay* seconds.

    Only clears if the clipboard still contains the same text we copied — so a
    manually copied value is never accidentally wiped.
    """
    import subprocess
    script = (
        f"import time, sys\n"
        f"time.sleep({delay})\n"
        f"try:\n"
        f"    import pyperclip\n"
        f"    if pyperclip.paste() == sys.argv[1]:\n"
        f"        pyperclip.copy('')\n"
        f"except Exception:\n"
        f"    pass\n"
    )
    try:
        subprocess.Popen(
            [sys.executable, "-c", script, copied_text],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            start_new_session=True,   # detach from parent — survives terminal close
        )
    except Exception:
        pass  # non-critical — clipboard just won't auto-clear


def _entry_raw(entry: dict) -> str:
    """Primary secret for --output raw and clipboard copy.

    login    → password
    database → connection string  (most useful for devs)
    api      → api_key
    note     → content
    """
    etype = entry["entry_type"]
    meta  = entry["metadata"]
    if etype == "login":
        return entry["password"]
    elif etype == "database":
        u, p  = entry["username"], entry["password"]
        host  = meta.get("host", "localhost")
        port  = meta.get("port", "")
        db    = meta.get("database", "")
        kind  = meta.get("kind", "")
        hp    = f"{host}:{port}" if port else host
        if kind == "postgres":
            return f"postgresql://{u}:{p}@{hp}/{db}"
        elif kind == "mysql":
            return f"mysql://{u}:{p}@{hp}/{db}"
        elif kind == "redis":
            return f"redis://:{p}@{hp}"
        elif kind == "mongodb":
            return f"mongodb://{u}:{p}@{hp}/{db}"
        return entry["password"]
    elif etype == "api":
        return entry["username"]   # api_key
    elif etype == "note":
        return entry["notes"]
    return entry["password"]


def _copy_hint_label(entry: dict) -> str:
    """Human-readable label for what 'c' will copy."""
    return "api key" if entry["entry_type"] == "api" else "password"


def _entry_reveal_lines(entry: dict) -> list[tuple[str, str]]:
    """Lines to display when user presses 's' — show actual secret fields only.

    database → just the password (host/port already visible in the table)
    api      → api key + api secret separately
    login    → password
    """
    etype = entry["entry_type"]
    if etype == "database":
        return [("Password", entry["password"])]
    elif etype == "api":
        lines = [("API Key", entry["username"])]
        if entry["password"]:
            lines.append(("API Secret", entry["password"]))
        return lines
    return [("Password", entry["password"])]


_MASKED = "••••••••"

def _entry_to_dict(entry: dict, show: bool = False) -> dict:
    """Serialize entry for --output json. Masks secrets unless show=True."""
    d = {k: v for k, v in entry.items() if k != "id"}
    if not show:
        etype = entry["entry_type"]
        if etype in ("login", "database"):
            d["password"] = _MASKED
        elif etype == "api":
            d["username"] = _MASKED   # api_key
            d["password"] = _MASKED   # api_secret
    return d


# ── TOTP ─────────────────────────────────────────────────────────────────────

def _get_totp_code(secret: str) -> str | None:
    """Return current 6-digit TOTP code or None on error."""
    try:
        import pyotp
        totp = pyotp.TOTP(secret)
        code = totp.now()
        remaining = 30 - (__import__("time").time() % 30)
        return f"[bold cyan]{code}[/bold cyan]  [dim]({int(remaining)}s)[/dim]"
    except Exception:
        return None


# ── Notes rendering ──────────────────────────────────────────────────────────

_MD_PATTERNS = re.compile(
    r'```|'           # code fences
    r'^#{1,6} |'      # ATX headings
    r'^\*{1,2}\S|'    # bold/italic
    r'^- |\* |'       # unordered lists
    r'^\d+\. ',       # ordered lists
    re.MULTILINE,
)

def _has_markdown(text: str) -> bool:
    return bool(_MD_PATTERNS.search(text))


def _render_notes(text: str, title: str = "Notes") -> None:
    """Render notes below the entry table — markdown with syntax highlighting
    or plain text, depending on content."""
    if not text:
        return
    console.print()
    if _has_markdown(text):
        console.print(Rule(f"[dim]{title}[/dim]"))
        console.print(Markdown(text))
    else:
        console.print(Rule(f"[dim]{title}[/dim]"))
        console.print(text)
    console.print()


# ── Health report display ─────────────────────────────────────────────────────

def _display_health_report(report: dict) -> None:
    console.print()
    console.print(Rule("[bold]Health Report[/bold]"))

    issues = 0

    # Reused passwords
    reused = report.get("reused_passwords", [])
    if reused:
        issues += len(reused)
        console.print(f"\n[bold red]● Reused passwords ({len(reused)} group{'s' if len(reused) > 1 else ''})[/bold red]")
        for group in reused:
            console.print(f"  [yellow]{', '.join(group)}[/yellow]  [dim]← same password[/dim]")
    else:
        console.print("\n[green]✓[/green] No reused passwords.")

    # Weak passwords
    weak = report.get("weak_passwords", [])
    if weak:
        issues += len(weak)
        console.print(f"\n[bold red]● Weak passwords ({len(weak)})[/bold red]")
        t = Table(box=None, padding=(0, 2))
        t.add_column("Name", style="bold yellow")
        t.add_column("Reason", style="dim")
        for w in weak:
            t.add_row(w["name"], w["reason"])
        console.print(t)
    else:
        console.print("[green]✓[/green] No weak passwords.")

    # Never rotated
    never = report.get("never_rotated", [])
    if never:
        issues += len(never)
        console.print(f"\n[bold yellow]● Never rotated ({len(never)})[/bold yellow]")
        t2 = Table(box=None, padding=(0, 2))
        t2.add_column("Name", style="bold")
        t2.add_column("Type", style="dim")
        t2.add_column("Created", style="dim")
        for e in never:
            t2.add_row(e["name"], TYPE_SHORT.get(e["entry_type"], e["entry_type"]), e["created_at"][:10])
        console.print(t2)
    else:
        console.print("[green]✓[/green] All credentials have been rotated at least once.")

    # Missing TOTP
    no_totp = report.get("no_totp", [])
    if no_totp:
        console.print(f"\n[dim]● {len(no_totp)} login entr{'ies' if len(no_totp) > 1 else 'y'} without TOTP 2FA[/dim]")
        console.print(f"  [dim]{', '.join(e['name'] for e in no_totp[:8])}"
                      f"{'…' if len(no_totp) > 8 else ''}[/dim]")

    console.print()
    if issues == 0:
        console.print("[green]✓[/green] Vault is healthy.")
    else:
        console.print(f"[yellow]{issues} issue{'s' if issues > 1 else ''} found.[/yellow]"
                      f"  Run [bold]pm edit <name>[/bold] to fix each entry.")


# ── Display helpers ───────────────────────────────────────────────────────────

def _display_entry(entry: dict, show: bool = False):
    etype = entry["entry_type"]
    meta  = entry["metadata"]

    table = Table(show_header=False, box=None, padding=(0, 1))
    table.add_column("Field", style="bold cyan", width=14)
    table.add_column("Value")

    table.add_row("Name",   entry["name"])
    table.add_row("Type",   TYPE_LABELS.get(etype, etype))
    table.add_row("Folder", entry["folder"])

    if etype == "login":
        table.add_row("Username", entry["username"] or "—")
        table.add_row("Password", entry["password"] if show else "••••••••")
        table.add_row("Website",  entry["url"] or "—")
        if meta.get("totp"):
            totp_code = _get_totp_code(meta["totp"])
            table.add_row("TOTP", totp_code if totp_code else "[dim]—[/dim]")

    elif etype == "database":
        table.add_row("Kind",     meta.get("kind", "—"))
        table.add_row("Host",     meta.get("host", "—"))
        table.add_row("Port",     meta.get("port", "—"))
        table.add_row("Database", meta.get("database", "—"))
        table.add_row("Username", entry["username"] or "—")
        table.add_row("Password", entry["password"] if show else "••••••••")

    elif etype == "api":
        table.add_row("Service",     meta.get("service", "—"))
        table.add_row("Environment", meta.get("environment", "—"))
        table.add_row("API Key",     entry["username"] if show else "••••••••")
        table.add_row("API Secret",  entry["password"] if show else "••••••••")
        table.add_row("Endpoint",    entry["url"] or "—")

    elif etype == "note":
        content = entry["notes"]
        if _has_markdown(content) or len(content) > 80:
            table.add_row("Content", "[dim](see below)[/dim]")
        else:
            table.add_row("Content", content)

    # Inline notes for non-note types (truncated preview if long/markdown)
    if etype != "note" and entry["notes"]:
        notes_text = entry["notes"]
        if _has_markdown(notes_text) or len(notes_text) > 80:
            table.add_row("Notes", "[dim](see below)[/dim]")
        else:
            table.add_row("Notes", notes_text)

    # Custom fields (any entry type)
    for cf in meta.get("custom_fields", []):
        table.add_row(cf.get("label", ""), cf.get("value", ""))

    table.add_row("Created", entry["created_at"][:10])
    table.add_row("Updated", entry["updated_at"][:10])
    console.print(table)

    # Render full notes/content below the table
    if etype == "note":
        _render_notes(entry["notes"], title="Content")
    elif entry["notes"] and (_has_markdown(entry["notes"]) or len(entry["notes"]) > 80):
        _render_notes(entry["notes"], title="Notes")


# ── CLI root ──────────────────────────────────────────────────────────────────

@click.group()
@click.version_option("1.0.0", prog_name="pm")
def cli():
    """pm — secure local password manager.

    Set PM_MASTER_PASSWORD env var to skip the password prompt in scripts.
    Vault is stored at ~/.pm/vault.db (override with PM_VAULT_PATH).
    """


# ── init ──────────────────────────────────────────────────────────────────────

@cli.command()
def init():
    """Initialize a new vault (run once)."""
    vault_path = get_vault_path()
    if vault_path.exists():
        console.print(f"[yellow]Vault already exists at {vault_path}[/yellow]")
        if not Confirm.ask("Re-initialize? This will [bold red]delete all entries[/bold red]."):
            return
        vault_path.unlink()
    password = click.prompt("Set master password", hide_input=True, confirmation_prompt=True)
    vault = Vault(vault_path)
    vault.initialize(password)
    console.print(f"[green]✓[/green] Vault initialized at [cyan]{vault_path}[/cyan]")


# ── create ────────────────────────────────────────────────────────────────────

@cli.command()
@click.option("--master", "-m", envvar="PM_MASTER_PASSWORD", default=None, is_eager=True)
def create(master):
    """Create a new entry via interactive wizard.

    \b
    Types:
      login      — username, password, website
      database   — kind, host, port, database, username, password
      api        — service, api key, api secret, endpoint, environment
      note       — title, content
    """
    vault = _open_vault(master)
    data = _run_wizard(vault)
    try:
        vault.create(**data)
        console.print(
            f"\n[green]✓[/green] [bold]{data['name']}[/bold]"
            f" ([cyan]{TYPE_LABELS[data['entry_type']]}[/cyan])"
            f" saved in folder [cyan]{data['folder']}[/cyan]."
        )
    except EntryExistsError as exc:
        console.print(f"[red]Error:[/red] {exc}")
        sys.exit(1)


# ── get ───────────────────────────────────────────────────────────────────────

@cli.command()
@click.argument("name", shell_complete=_complete_entry_name)
@click.option("--show", "-s", is_flag=True, help="Reveal secrets in table output")
@click.option("--copy", "-c", is_flag=True, help="Copy primary secret to clipboard")
@click.option("--output", "-o", "fmt", type=click.Choice(OUTPUT_CHOICES), default="table",
              show_default=True, help="Output format")
@click.option("--master", "-m", envvar="PM_MASTER_PASSWORD", default=None, is_eager=True)
def get(name, show, copy, fmt, master):
    """Get an entry by name.

    \b
    Output formats:
      table  rich display (default)
      json   full entry as JSON including secrets
      raw    primary secret only — password, connection string, or api key
             (useful for piping: pm get github -o raw | pbcopy)
    """
    vault = _open_vault(master)
    try:
        entry = vault.get(name)
    except EntryNotFoundError as exc:
        console.print(f"[red]Error:[/red] {exc}")
        sys.exit(1)

    if copy:
        if _copy_to_clipboard(_entry_raw(entry)):
            console.print(
                f"[green]✓[/green] Copied to clipboard."
                f"  [dim]Auto-clears in {CLIPBOARD_CLEAR_SECONDS}s.[/dim]"
            )

    if fmt == "json":
        console.print_json(json.dumps(_entry_to_dict(entry, show), default=str))
    elif fmt == "raw":
        click.echo(_entry_raw(entry))
    else:
        _display_entry(entry, show)
        # Interactive hint when password is masked (not in --show mode, not a note)
        if not show and not copy and entry["entry_type"] != "note":
            copy_label = _copy_hint_label(entry)
            has_totp = entry["entry_type"] == "login" and bool(entry["metadata"].get("totp"))
            totp_hint = "  [bold]t[/bold] copy totp" if has_totp else ""
            console.print(
                f"  [dim]↳ [bold]c[/bold] copy {copy_label}"
                f"{totp_hint}"
                f"  [bold]s[/bold] reveal  [bold]q[/bold]/any key to quit[/dim]"
            )
            try:
                key = click.getchar(echo=False)
                if key.lower() == "c":
                    secret = _entry_reveal_lines(entry)[0][1]
                    if _copy_to_clipboard(secret):
                        console.print(
                            f"  [green]✓[/green] {copy_label.capitalize()} copied to clipboard."
                            f"  [dim]Auto-clears in {CLIPBOARD_CLEAR_SECONDS}s.[/dim]"
                        )
                elif key.lower() == "t" and has_totp:
                    try:
                        import pyotp
                        code = pyotp.TOTP(entry["metadata"]["totp"]).now()
                        if _copy_to_clipboard(code):
                            console.print(
                                f"  [green]✓[/green] TOTP code [bold cyan]{code}[/bold cyan] copied."
                                f"  [dim]Auto-clears in {CLIPBOARD_CLEAR_SECONDS}s.[/dim]"
                            )
                    except Exception:
                        console.print("  [red]Could not generate TOTP code.[/red]")
                elif key.lower() == "s":
                    for label, value in _entry_reveal_lines(entry):
                        console.print(f"  {label}: [bold cyan]{value}[/bold cyan]")
                    console.print("  [dim red]↳ clear terminal after use:  clear  or  cmd+k[/dim red]")
            except Exception:
                pass  # Non-interactive environment (piped input etc.)


# ── edit ──────────────────────────────────────────────────────────────────────

@cli.command()
@click.argument("name", shell_complete=_complete_entry_name)
@click.option("--folder", "-f", default=None, help="Move to a different folder (non-interactive)")
@click.option("--master", "-m", envvar="PM_MASTER_PASSWORD", default=None, is_eager=True)
def edit(name, folder, master):
    """Edit an existing entry.

    Runs an interactive wizard pre-filled with current values.
    Use --folder to only move an entry without opening the wizard.
    """
    vault = _open_vault(master)
    try:
        entry = vault.get(name)
    except EntryNotFoundError as exc:
        console.print(f"[red]Error:[/red] {exc}")
        sys.exit(1)

    if folder is not None:
        # Quick folder move — no wizard needed
        updates = {"folder": _confirm_folder(vault, folder)}
    else:
        updates = _edit_wizard(vault, entry)

    vault.edit(name, **updates)
    console.print(f"[green]✓[/green] Entry [bold]{name}[/bold] updated.")


# ── list ──────────────────────────────────────────────────────────────────────

@cli.command("list")
@click.option("--search", "-s", default="", help="Filter by name / username / URL")
@click.option("--folder", "-f", default="", help="Filter by folder")
@click.option("--folders", is_flag=True, help="Show folder summary")
@click.option("--output", "-o", "fmt", type=click.Choice(OUTPUT_CHOICES), default="table",
              show_default=True, help="Output format")
@click.option("--master", "-m", envvar="PM_MASTER_PASSWORD", default=None, is_eager=True)
def list_entries(search, folder, folders, fmt, master):
    """List all entries (passwords hidden).

    \b
    Examples:
      pm list                      # all entries grouped by folder
      pm list --folder talosix     # one folder only
      pm list --folders            # folder summary with counts
      pm list --search postgres    # search across all folders
    """
    vault = _open_vault(master)

    if folders:
        rows = vault.list_folders()
        if not rows:
            console.print("[yellow]Vault is empty.[/yellow]")
            return
        if fmt == "json":
            console.print_json(json.dumps(rows))
        elif fmt == "raw":
            click.echo("\n".join(r["folder"] for r in rows))
        else:
            table = Table(title="Folders")
            table.add_column("Folder", style="bold cyan")
            table.add_column("Entries", justify="right", style="dim")
            for r in rows:
                table.add_row(r["folder"], str(r["count"]))
            console.print(table)
        return

    entries = vault.list_entries(search=search, folder=folder)
    if not entries:
        hint = folder or search
        msg = f"No entries in [bold]{hint}[/bold]." if hint else "Vault is empty."
        console.print(f"[yellow]{msg}[/yellow]")
        return

    if fmt == "json":
        console.print_json(json.dumps(entries, default=str))
        return
    if fmt == "raw":
        click.echo("\n".join(e["name"] for e in entries))
        return

    title = f"Vault — {len(entries)} entr{'y' if len(entries) == 1 else 'ies'}"
    if folder:
        title += f" in [cyan]{folder}[/cyan]"

    table = Table(title=title)
    table.add_column("#",       style="dim", width=4, justify="right")
    table.add_column("Folder",  style="cyan", no_wrap=True)
    table.add_column("Type",    style="dim", width=10)
    table.add_column("Name",    style="bold")
    table.add_column("Username")
    table.add_column("Updated", style="dim", width=12)

    current_folder = None
    for i, e in enumerate(entries, 1):
        folder_cell = ""
        if e["folder"] != current_folder:
            folder_cell = e["folder"]
            current_folder = e["folder"]
        table.add_row(
            str(i),
            folder_cell,
            TYPE_SHORT.get(e["entry_type"], e["entry_type"]),
            e["name"],
            e["username"] or "",
            e["updated_at"][:10],
        )
    console.print(table)


# ── status ───────────────────────────────────────────────────────────────────

@cli.command()
@click.option("--days", "-d", default=90, show_default=True,
              help="Flag credentials not rotated within this many days")
@click.option("--health", is_flag=True, help="Include vault health checks (reused/weak passwords, missing TOTP)")
@click.option("--output", "-o", "fmt", type=click.Choice(OUTPUT_CHOICES), default="table",
              show_default=True, help="Output format")
@click.option("--master", "-m", envvar="PM_MASTER_PASSWORD", default=None, is_eager=True)
def status(days, health, fmt, master):
    """Show vault summary and stale credential report.

    Add --health to also check for reused passwords, weak passwords,
    entries never rotated, and logins missing 2FA.
    """
    vault = _open_vault(master)
    stats = vault.get_stats()
    stale = vault.get_stale_entries(days)
    health_report = vault.get_health_report() if health else None

    if fmt == "json":
        payload: dict = {
            "total": stats["total"],
            "by_folder": stats["by_folder"],
            "by_type": stats["by_type"],
            "stale_threshold_days": days,
            "stale": stale,
        }
        if health_report:
            payload["health"] = health_report
        console.print_json(json.dumps(payload, default=str))
        return
    if fmt == "raw":
        lines = [f"total={stats['total']}"]
        for r in stats["by_folder"]:
            lines.append(f"folder.{r['folder']}={r['count']}")
        for r in stats["by_type"]:
            lines.append(f"type.{r['entry_type']}={r['count']}")
        lines.append(f"stale={len(stale)}")
        if health_report:
            lines.append(f"reused={len(health_report['reused_passwords'])}")
            lines.append(f"weak={len(health_report['weak_passwords'])}")
        click.echo("\n".join(lines))
        return

    # ── Summary panel ──────────────────────────────────────────────────────
    console.print(Rule("[bold]Vault Status[/bold]"))
    console.print(f"  Total secrets: [bold green]{stats['total']}[/bold green]\n")

    # By folder and by type side by side
    folder_table = Table(title="By Folder", box=None, padding=(0, 2))
    folder_table.add_column("Folder", style="cyan")
    folder_table.add_column("Count", justify="right", style="bold")
    for r in stats["by_folder"]:
        folder_table.add_row(r["folder"], str(r["count"]))

    type_table = Table(title="By Type", box=None, padding=(0, 2))
    type_table.add_column("Type", style="cyan")
    type_table.add_column("Count", justify="right", style="bold")
    for r in stats["by_type"]:
        type_table.add_row(TYPE_LABELS.get(r["entry_type"], r["entry_type"]), str(r["count"]))

    console.print(Columns([
        Panel(folder_table, border_style="dim"),
        Panel(type_table,   border_style="dim"),
    ]))

    # ── Stale credentials ──────────────────────────────────────────────────
    console.print()
    if not stale:
        console.print(f"[green]✓[/green] All credentials rotated within the last [bold]{days}[/bold] days.")
        if health_report:
            _display_health_report(health_report)
        return

    console.print(
        f"[bold yellow]⚠ {len(stale)} credential{'s' if len(stale) > 1 else ''}"
        f" not rotated in {days}+ days[/bold yellow]"
    )
    stale_table = Table(box=None, padding=(0, 2))
    stale_table.add_column("Name",    style="bold red")
    stale_table.add_column("Folder",  style="cyan")
    stale_table.add_column("Type",    style="dim")
    stale_table.add_column("Created",  style="dim")
    stale_table.add_column("Last Rotated", style="dim")
    stale_table.add_column("Age",     style="bold yellow")

    for e in stale:
        age = e["age_days"]
        age_str = f"{age // 365}y {age % 365 // 30}m" if age >= 365 else f"{age // 30}m {age % 30}d"
        stale_table.add_row(
            e["name"],
            e["folder"],
            TYPE_SHORT.get(e["entry_type"], e["entry_type"]),
            e["created_at"][:10],
            e["updated_at"][:10],
            age_str,
        )
    console.print(stale_table)
    console.print(
        f"\n  Run [bold]pm edit <name>[/bold] to rotate a credential."
    )

    # ── Health report ───────────────────────────────────────────────────────
    if health_report:
        _display_health_report(health_report)


# ── find ─────────────────────────────────────────────────────────────────────

@cli.command()
@click.argument("query")
@click.option("--show", "-s", is_flag=True, help="Reveal secrets in table output")
@click.option("--output", "-o", "fmt", type=click.Choice(OUTPUT_CHOICES), default="table",
              show_default=True, help="Output format")
@click.option("--master", "-m", envvar="PM_MASTER_PASSWORD", default=None, is_eager=True)
def find(query, show, fmt, master):
    """Search entries across all fields including metadata.

    \b
    Searches: name, username, URL, notes, folder, and type-specific
    fields (host, database, service, environment, etc.).

    \b
    Examples:
      pm find postgres               # finds by name or db host/database
      pm find talosix                # finds by folder name
      pm find stripe -o json         # JSON output with all fields
      pm find production -o raw      # just names, one per line
    """
    vault = _open_vault(master)
    entries = vault.search(query)

    if not entries:
        if fmt == "json":
            click.echo("[]")
        elif fmt == "raw":
            pass
        else:
            console.print(f"[yellow]No entries matching '[bold]{query}[/bold]'.[/yellow]")
        return

    if fmt == "json":
        console.print_json(json.dumps([_entry_to_dict(e, show) for e in entries], default=str))
        return
    if fmt == "raw":
        click.echo("\n".join(e["name"] for e in entries))
        return

    count = len(entries)
    console.print(
        f"[green]{count}[/green] entr{'y' if count == 1 else 'ies'}"
        f" matching '[bold cyan]{query}[/bold cyan]'\n"
    )
    for entry in entries:
        _display_entry(entry, show)
        console.print()


# ── delete ────────────────────────────────────────────────────────────────────

@cli.command()
@click.argument("name", shell_complete=_complete_entry_name)
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
@click.option("--master", "-m", envvar="PM_MASTER_PASSWORD", default=None, is_eager=True)
def delete(name, yes, master):
    """Delete an entry."""
    vault = _open_vault(master)
    try:
        vault.get(name)
    except EntryNotFoundError as exc:
        console.print(f"[red]Error:[/red] {exc}")
        sys.exit(1)

    if not yes and not Confirm.ask(f"Delete [bold red]{name}[/bold red]?"):
        console.print("Aborted.")
        return

    vault.delete(name)
    console.print(f"[green]✓[/green] Entry [bold]{name}[/bold] deleted.")


# ── export ────────────────────────────────────────────────────────────────────

@cli.command()
@click.argument("output", type=click.Path())
@click.option(
    "--format", "fmt",
    type=click.Choice(["encrypted", "json"]),
    default="encrypted", show_default=True,
)
@click.option("--master", "-m", envvar="PM_MASTER_PASSWORD", default=None, is_eager=True)
def export(output, fmt, master):
    """Export vault to a portable backup file.

    \b
    Encrypted (safe for cloud storage):
      pm export backup.enc

    Plain JSON (keep secure — contains raw secrets):
      pm export backup.json --format json
    """
    vault = _open_vault(master)
    entries = vault.export_entries()
    output_path = Path(output)

    if fmt == "json":
        console.print("[yellow]Warning:[/yellow] Plain JSON export — keep this file secure!")
        output_path.write_text(json.dumps(entries, indent=2, default=str))
    else:
        backup_password = click.prompt("Backup password", hide_input=True, confirmation_prompt=True)
        salt = generate_salt()
        key = derive_key(backup_password, salt)
        ciphertext = crypto_encrypt(json.dumps(entries, default=str), key)
        payload = json.dumps({"version": 1, "salt": salt.hex(), "data": ciphertext.hex()})
        output_path.write_text(payload)

    console.print(f"[green]✓[/green] Exported [bold]{len(entries)}[/bold] entries to [cyan]{output}[/cyan]")


# ── import ────────────────────────────────────────────────────────────────────

def _parse_chrome_csv(text: str) -> list[dict]:
    """Parse Chrome / Edge password export CSV.

    Columns: name, url, username, password  (may also have note column)
    """
    import csv, io
    entries = []
    reader = csv.DictReader(io.StringIO(text))
    for row in reader:
        name = (row.get("name") or row.get("Name") or "").strip()
        url  = (row.get("url")  or row.get("URL")  or "").strip()
        user = (row.get("username") or row.get("Username") or "").strip()
        pw   = (row.get("password") or row.get("Password") or "").strip()
        note = (row.get("note") or row.get("Note") or "").strip()
        if not name and url:
            from urllib.parse import urlparse
            name = urlparse(url).hostname or url
        if not name:
            continue
        entries.append(dict(name=name, username=user, password=pw,
                            url=url, notes=note, folder="imported",
                            entry_type="login", metadata={}))
    return entries


def _parse_bitwarden_csv(text: str) -> list[dict]:
    """Parse Bitwarden CSV export (unencrypted).

    Columns: folder, favorite, type, name, notes, fields, reprompt,
             login_uri, login_username, login_password, login_totp
    """
    import csv, io
    entries = []
    reader = csv.DictReader(io.StringIO(text))
    for row in reader:
        item_type = (row.get("type") or "login").lower()
        if item_type not in ("login", "note"):
            item_type = "login"
        name  = (row.get("name") or "").strip()
        if not name:
            continue
        folder = (row.get("folder") or "imported").strip() or "imported"
        notes  = (row.get("notes") or "").strip()
        if item_type == "login":
            uri  = (row.get("login_uri")      or "").strip()
            user = (row.get("login_username") or "").strip()
            pw   = (row.get("login_password") or "").strip()
            totp = (row.get("login_totp")     or "").strip()
            meta = {"totp": totp} if totp else {}
            entries.append(dict(name=name, username=user, password=pw,
                                url=uri, notes=notes, folder=folder,
                                entry_type="login", metadata=meta))
        else:
            entries.append(dict(name=name, username="", password="",
                                url="", notes=notes, folder=folder,
                                entry_type="note", metadata={}))
    return entries


def _parse_1password_1pux(data: dict) -> list[dict]:
    """Parse 1Password 1PUX export (JSON inside a ZIP).

    Handles login and secureNote item types.
    """
    entries = []
    for account in data.get("accounts", []):
        for vault in account.get("vaults", []):
            vault_name = vault.get("attrs", {}).get("name", "imported")
            for item in vault.get("items", []):
                if item.get("trashed"):
                    continue
                cat = item.get("categoryUuid", "")
                overview = item.get("overview", {})
                name = overview.get("title", "").strip()
                if not name:
                    continue
                folder = vault_name
                details = item.get("details", {})
                login_fields = details.get("loginFields", [])
                sections = details.get("sections", [])
                notes = details.get("notesPlain", "")
                urls = overview.get("urls", [])
                url = urls[0].get("url", "") if urls else ""

                username, password = "", ""
                for lf in login_fields:
                    designation = lf.get("designation", "")
                    value = lf.get("value", "")
                    if designation == "username":
                        username = value
                    elif designation == "password":
                        password = value

                if cat == "001":  # login
                    entries.append(dict(name=name, username=username, password=password,
                                        url=url, notes=notes, folder=folder,
                                        entry_type="login", metadata={}))
                elif cat == "003":  # secure note
                    entries.append(dict(name=name, username="", password="",
                                        url="", notes=notes, folder=folder,
                                        entry_type="note", metadata={}))
    return entries


@cli.command("import")
@click.argument("input_file", type=click.Path(exists=True))
@click.option(
    "--format", "fmt",
    type=click.Choice(["chrome", "bitwarden", "1password"]),
    default=None,
    help="Source format (auto-detected from filename if omitted)",
)
@click.option("--folder", "-f", default=None,
              help="Override destination folder for all imported entries")
@click.option("--master", "-m", envvar="PM_MASTER_PASSWORD", default=None, is_eager=True)
def import_entries(input_file, fmt, folder, master):
    """Import passwords from Chrome, Bitwarden, or 1Password exports.

    \b
    Chrome / Edge CSV:
      pm import ~/chrome-passwords.csv --format chrome

    Bitwarden CSV (unencrypted export):
      pm import ~/bitwarden-export.csv --format bitwarden

    1Password 1PUX (export ZIP):
      pm import ~/1password-export.1pux --format 1password

    Format is auto-detected from file extension when possible.
    Use --folder to put all entries into a specific folder.
    """
    input_path = Path(input_file)

    # Auto-detect format from extension
    if fmt is None:
        suffix = input_path.suffix.lower()
        if suffix == ".1pux":
            fmt = "1password"
        elif suffix == ".csv":
            # Peek at headers to distinguish Chrome vs Bitwarden
            text = input_path.read_text(encoding="utf-8", errors="replace")
            first_line = text.splitlines()[0].lower() if text else ""
            if "login_uri" in first_line or "login_username" in first_line:
                fmt = "bitwarden"
            else:
                fmt = "chrome"
        else:
            console.print("[red]Error:[/red] Cannot auto-detect format. Use --format.")
            sys.exit(1)

    # Parse
    try:
        if fmt == "chrome":
            text = input_path.read_text(encoding="utf-8", errors="replace")
            entries = _parse_chrome_csv(text)
        elif fmt == "bitwarden":
            text = input_path.read_text(encoding="utf-8", errors="replace")
            entries = _parse_bitwarden_csv(text)
        elif fmt == "1password":
            import zipfile, json as _json
            with zipfile.ZipFile(input_path) as zf:
                with zf.open("export.data") as f:
                    data = _json.loads(f.read())
            entries = _parse_1password_1pux(data)
    except Exception as exc:
        console.print(f"[red]Error parsing file:[/red] {exc}")
        sys.exit(1)

    if not entries:
        console.print("[yellow]No entries found in file.[/yellow]")
        return

    # Override folder if requested
    if folder:
        for e in entries:
            e["folder"] = folder

    console.print(f"Found [bold]{len(entries)}[/bold] entries to import.\n")

    # Preview table
    table = Table(box=None, padding=(0, 2))
    table.add_column("Name", style="bold")
    table.add_column("Type", style="dim")
    table.add_column("Folder", style="cyan")
    table.add_column("Username")
    for e in entries[:10]:
        table.add_row(e["name"], TYPE_SHORT.get(e["entry_type"], e["entry_type"]),
                      e["folder"], e["username"] or "—")
    if len(entries) > 10:
        table.add_row(f"[dim]… and {len(entries) - 10} more[/dim]", "", "", "")
    console.print(table)
    console.print()

    if not Confirm.ask(f"Import [bold]{len(entries)}[/bold] entries?", default=True):
        console.print("Aborted.")
        return

    vault = _open_vault(master)
    imported, skipped = vault.import_entries(entries)
    console.print(
        f"\n[green]✓[/green] Imported [bold]{imported}[/bold] entries, "
        f"skipped [yellow]{skipped}[/yellow] duplicates."
    )


# ── restore ───────────────────────────────────────────────────────────────────

@cli.command()
@click.argument("input", type=click.Path(exists=True))
@click.option(
    "--format", "fmt",
    type=click.Choice(["encrypted", "json"]),
    default="encrypted", show_default=True,
)
@click.option("--master", "-m", envvar="PM_MASTER_PASSWORD", default=None, is_eager=True)
def restore(input, fmt, master):
    """Restore entries from a backup file.

    Existing entries with the same name are skipped (not overwritten).
    """
    vault = _open_vault(master)
    input_path = Path(input)

    if fmt == "json":
        entries = json.loads(input_path.read_text())
    else:
        backup_password = click.prompt("Backup password", hide_input=True)
        payload = json.loads(input_path.read_text())
        salt = bytes.fromhex(payload["salt"])
        key = derive_key(backup_password, salt)
        try:
            plaintext = crypto_decrypt(bytes.fromhex(payload["data"]), key)
            entries = json.loads(plaintext)
        except Exception:
            console.print("[red]Error:[/red] Wrong backup password or corrupted file.")
            sys.exit(1)

    imported, skipped = vault.import_entries(entries)
    console.print(
        f"[green]✓[/green] Imported [bold]{imported}[/bold] entries, "
        f"skipped [yellow]{skipped}[/yellow] duplicates."
    )


# ── generate ──────────────────────────────────────────────────────────────────

@cli.command()
@click.option("--length", "-l", default=20, show_default=True)
@click.option("--no-symbols", "no_symbols", is_flag=True)
@click.option("--no-numbers", "no_numbers", is_flag=True)
@click.option("--count", "-n", default=1, show_default=True, help="How many to generate")
def generate(length, no_symbols, no_numbers, count):
    """Generate a strong random password (does not save it)."""
    for _ in range(count):
        pw = _generate_password(length, not no_symbols, not no_numbers)
        console.print(f"[bold cyan]{pw}[/bold cyan]")


# ── history ───────────────────────────────────────────────────────────────────

@cli.command()
@click.argument("name", shell_complete=_complete_entry_name)
@click.option("--show", "-s", is_flag=True, help="Reveal historical passwords")
@click.option("--master", "-m", envvar="PM_MASTER_PASSWORD", default=None, is_eager=True)
def history(name, show, master):
    """Show password rotation history for an entry (last 5 rotations).

    Passwords are masked by default. Use --show to reveal them.
    """
    vault = _open_vault(master)
    try:
        vault.get(name)  # confirm entry exists
    except EntryNotFoundError as exc:
        console.print(f"[red]Error:[/red] {exc}")
        sys.exit(1)

    records = vault.get_password_history(name)
    if not records:
        console.print(f"[yellow]No rotation history for [bold]{name}[/bold].[/yellow]")
        console.print("[dim]History is recorded from the next password change.[/dim]")
        return

    table = Table(title=f"Password history — {name}", box=None, padding=(0, 2))
    table.add_column("#", style="dim", width=3, justify="right")
    table.add_column("Rotated At", style="dim")
    table.add_column("Previous Password")

    for i, rec in enumerate(records, 1):
        pw = rec["password"] if show else "••••••••"
        table.add_row(str(i), rec["rotated_at"][:19].replace("T", " "), pw)

    console.print(table)
    if not show:
        console.print("[dim]  Use --show to reveal previous passwords.[/dim]")


# ── duplicate ─────────────────────────────────────────────────────────────────

@cli.command()
@click.argument("source", shell_complete=_complete_entry_name)
@click.argument("new_name")
@click.option("--master", "-m", envvar="PM_MASTER_PASSWORD", default=None, is_eager=True)
def duplicate(source, new_name, master):
    """Clone an entry under a new name.

    All fields including metadata and custom fields are copied.
    The new entry is created with fresh timestamps.

    \b
    Example:
      pm duplicate prod-postgres staging-postgres
    """
    vault = _open_vault(master)
    try:
        entry = vault.get(source)
    except EntryNotFoundError as exc:
        console.print(f"[red]Error:[/red] {exc}")
        sys.exit(1)

    try:
        vault.create(
            name=new_name,
            username=entry["username"],
            password=entry["password"],
            url=entry["url"],
            notes=entry["notes"],
            folder=entry["folder"],
            entry_type=entry["entry_type"],
            metadata=entry["metadata"],
        )
        console.print(
            f"[green]✓[/green] Duplicated [bold]{source}[/bold] → [bold cyan]{new_name}[/bold cyan]"
            f" in folder [cyan]{entry['folder']}[/cyan]."
        )
    except EntryExistsError as exc:
        console.print(f"[red]Error:[/red] {exc}")
        sys.exit(1)


# ── open ──────────────────────────────────────────────────────────────────────

@cli.command("open")
@click.argument("name", shell_complete=_complete_entry_name)
@click.option("--master", "-m", envvar="PM_MASTER_PASSWORD", default=None, is_eager=True)
def open_url(name, master):
    """Open the URL saved for an entry in the default browser.

    \b
    Example:
      pm open github
    """
    import subprocess, platform
    vault = _open_vault(master)
    try:
        entry = vault.get(name)
    except EntryNotFoundError as exc:
        console.print(f"[red]Error:[/red] {exc}")
        sys.exit(1)

    url = entry.get("url", "")
    if not url:
        console.print(f"[yellow]No URL saved for [bold]{name}[/bold].[/yellow]")
        sys.exit(1)

    # Ensure scheme present so the browser opens it properly
    if not url.startswith(("http://", "https://")):
        url = "https://" + url

    system = platform.system()
    try:
        if system == "Darwin":
            subprocess.Popen(["open", url])
        elif system == "Windows":
            subprocess.Popen(["cmd", "/c", "start", "", url])
        else:
            subprocess.Popen(["xdg-open", url])
        console.print(f"[green]✓[/green] Opening [cyan]{url}[/cyan]")
    except Exception as exc:
        console.print(f"[red]Error opening URL:[/red] {exc}")
        sys.exit(1)


# ── rename ────────────────────────────────────────────────────────────────────

@cli.command()
@click.argument("old_name", shell_complete=_complete_entry_name)
@click.argument("new_name")
@click.option("--master", "-m", envvar="PM_MASTER_PASSWORD", default=None, is_eager=True)
def rename(old_name, new_name, master):
    """Rename an entry.

    \b
    Example:
      pm rename github github-personal
    """
    vault = _open_vault(master)
    try:
        vault.rename(old_name, new_name)
        console.print(f"[green]✓[/green] Renamed [bold]{old_name}[/bold] → [bold cyan]{new_name}[/bold cyan].")
    except EntryNotFoundError as exc:
        console.print(f"[red]Error:[/red] {exc}")
        sys.exit(1)
    except EntryExistsError as exc:
        console.print(f"[red]Error:[/red] {exc}")
        sys.exit(1)


# ── unlock / lock ─────────────────────────────────────────────────────────────

@cli.command()
@click.option("--timeout", "-t", default=15, show_default=True,
              help="Session duration in minutes")
@click.option("--master", "-m", envvar="PM_MASTER_PASSWORD", default=None, is_eager=True)
def unlock(timeout, master):
    """Unlock vault and cache session so subsequent commands skip the password prompt.

    \b
    Examples:
      pm unlock               # cache for 15 min (default)
      pm unlock --timeout 60  # cache for 1 hour
    """
    vault_path = get_vault_path()
    vault = Vault(vault_path)
    if not vault.is_initialized():
        console.print("[red]Vault not initialized.[/red] Run: [bold]pm init[/bold]")
        sys.exit(1)
    if master is None:
        master = click.prompt("Master password", hide_input=True)
    try:
        vault.unlock(master)
    except AuthError as exc:
        console.print(f"[red]Error:[/red] {exc}")
        sys.exit(1)
    from .session import save_session
    save_session(vault_path, vault._key, timeout)
    console.print(
        f"[green]✓[/green] Vault unlocked. Session cached for "
        f"[bold]{timeout}[/bold] minute{'s' if timeout != 1 else ''}."
        f"  Run [bold]pm lock[/bold] to clear early."
    )


@cli.command()
def lock():
    """Clear the cached session (next command will prompt for master password)."""
    from .session import clear_session, session_expires_in
    vault_path = get_vault_path()
    remaining = session_expires_in(vault_path)
    clear_session(vault_path)
    if remaining is not None:
        console.print("[green]✓[/green] Session cleared.")
    else:
        console.print("[dim]No active session.[/dim]")


# ── completion ────────────────────────────────────────────────────────────────

@cli.command()
@click.option("--shell", type=click.Choice(["bash", "zsh", "fish"]), default=None,
              help="Shell type (auto-detected when omitted)")
def completion(shell):
    """Show instructions to enable shell tab completion.

    \b
    Bash:
      pm completion --shell bash

    Zsh:
      pm completion --shell zsh

    Fish:
      pm completion --shell fish
    """
    import os as _os
    if shell is None:
        shell_bin = _os.environ.get("SHELL", "")
        if "zsh" in shell_bin:
            shell = "zsh"
        elif "fish" in shell_bin:
            shell = "fish"
        else:
            shell = "bash"

    if shell == "bash":
        console.print("[bold]Bash completion setup:[/bold]")
        console.print('  eval "$(_PM_COMPLETE=bash_source pm)"')
        console.print()
        console.print("[dim]Add to ~/.bashrc to make permanent:[/dim]")
        console.print('  echo \'eval "$(_PM_COMPLETE=bash_source pm)"\' >> ~/.bashrc')
    elif shell == "zsh":
        console.print("[bold]Zsh completion setup:[/bold]")
        console.print('  eval "$(_PM_COMPLETE=zsh_source pm)"')
        console.print()
        console.print("[dim]Add to ~/.zshrc to make permanent:[/dim]")
        console.print('  echo \'eval "$(_PM_COMPLETE=zsh_source pm)"\' >> ~/.zshrc')
    elif shell == "fish":
        console.print("[bold]Fish completion setup:[/bold]")
        console.print("  _PM_COMPLETE=fish_source pm > ~/.config/fish/completions/pm.fish")

    console.print()
    console.print("[dim]After setup, restart your shell or source the config file.[/dim]")
    console.print("[dim]Entry names complete when the vault session is active (pm unlock first).[/dim]")


if __name__ == "__main__":
    cli()
