#!/usr/bin/env python3
"""concealer — local secret manager (thin wrapper over SOPS+age).

Crypto via sops+age. This file: typed secrets (api_key/database/website/custom),
scopes (tenant/project/environment/repo) + tags + metadata (url/notes), full CRUD,
HMAC-chained audit log, professional web UI (SPA) and MCP stdio server.

Store (single encrypted JSON, kept as sops YAML):
  {"secrets":[ {"id","name","type","tenant","project","environment","repo",
                "tags":[],"url","notes","fields":{...},"created","updated"} ]}
Audit: keys/audit.log (JSONL, HMAC-chained, tamper-evident). Keeps only key
NAMES and actions, never secret VALUES.
"""
import os, sys, json, subprocess, secrets as _secrets, http.server, urllib.parse, urllib.request, html, getpass, time, webbrowser
import hashlib, hmac, shlex, re, csv, io, tempfile, base64, shutil, fnmatch, gc
from datetime import datetime, timezone, timedelta

if sys.platform.startswith("win"):
    # Windows: a redirected stdout/stderr (pipe, file, CI) defaults to the ANSI code
    # page (cp1252), so printing "→" or box-drawing glyphs raises UnicodeEncodeError.
    # Force UTF-8 so CLI/MCP output is byte-safe everywhere. No effect on Unix.
    for _s in (sys.stdout, sys.stderr):
        try: _s.reconfigure(encoding="utf-8")
        except Exception: pass

VERSION = "0.9.15"   # advances through 0.x; 1.0 = reserved for the full live/release version
BASE = os.path.dirname(os.path.realpath(__file__))
def _default_home():
    # Repo checkout: vault lives next to the script → keep BASE (don't break existing installs).
    # Brew/package install (BASE read-only) or clean install → per-user ~/.concealer.
    if os.path.exists(os.path.join(BASE, "keys")) or os.path.exists(os.path.join(BASE, "secrets.enc.yaml")):
        return BASE
    return os.path.expanduser("~/.concealer")
HOME = os.environ.get("CONCEALER_HOME", _default_home())
FILE = os.environ.get("CONCEALER_FILE", os.path.join(HOME, "secrets.enc.yaml"))
KEY  = os.environ.get("CONCEALER_KEY",  os.path.join(HOME, "keys", "age-key.txt"))
BACKUP   = os.path.join(HOME, "keys", "age-key.txt.age")
PUBKEY   = os.path.join(HOME, "keys", "pubkey.txt")
MASTER   = os.path.join(HOME, "keys", "master.json")
AUDITKEY = os.path.join(HOME, "keys", "audit.key")
AUDITLOG = os.path.join(HOME, "keys", "audit.log")
AUDITHEAD= os.path.join(HOME, "keys", "audit.head")   # last-line anchor (tail-truncation detection)
RECOVERY = os.path.join(HOME, "keys", "recovery.json")# recovery code hashes + code-wrapped age key
AGENTS   = os.path.join(HOME, "keys", "agents.json")  # unlock token hashes + token-wrapped age key
CONFIG   = os.path.join(HOME, "keys", "config.json")
RATESTATE= os.path.join(HOME, "keys", "ratestate.json")  # per-agent disclosure window (not the value, just name+ts)
BACKUPCFG= os.path.join(HOME, "keys", "backup.json")     # auto-backup config + age-recipient-wrapped backup password (NO plaintext password)
SOPSCFG  = os.path.join(HOME, ".sops.yaml")
WEBUI    = os.path.join(BASE, "webui.html")
DIMS = ["tenant", "project", "environment", "repo"]
FLAGS = {"--tenant": "tenant", "--project": "project", "--env": "environment",
         "--repo": "repo", "--name": "name", "--type": "type"}

# ---- runtime config (keys/config.json; contains no secret) ----
# limits: MCP disclosure quota. per_call = max rows returned in one call;
# window_quota = max DISTINCT secrets disclosable within window_sec (blocks bulk exfiltration);
# window_quota=0 => that agent is fully blocked. agents[label] for per-agent override.
_DEFAULT_LIMITS = {"per_call": 10, "window_quota": 25, "window_sec": 3600}
_DEFAULT_CFG = {"idle": 300, "confirm_ops": ["export", "delete", "settings"],
                "limits": {"default": dict(_DEFAULT_LIMITS), "agents": {}},
                "policies": [],   # user-defined reminder policies (see policy_eval)
                # audit_anchor: push the head hash to an OFF-MACHINE append-only target (full re-forge detection)
                "audit_anchor": {"file": "", "webhook": "", "syslog": False}}
def load_cfg():
    cfg = dict(_DEFAULT_CFG)
    if os.path.exists(CONFIG):
        try: cfg.update(json.load(open(CONFIG)))
        except Exception: pass
    if os.environ.get("CONCEALER_IDLE"): cfg["idle"] = int(os.environ["CONCEALER_IDLE"])  # env override
    return cfg
def save_cfg(cfg):
    os.makedirs(os.path.dirname(CONFIG), exist_ok=True)
    with open(CONFIG, "w") as f: json.dump(cfg, f)
    os.chmod(CONFIG, 0o600)
CFG = load_cfg()
def _cfg_persist():   # called from settings POSTs: persist idle/confirm_ops/limits/policies/hibp
    save_cfg({k: CFG[k] for k in ("idle", "confirm_ops", "limits", "policies", "hibp_key", "audit_anchor") if k in CFG})

def _clean_limit(v):
    """Clamp/sanitize the limit dict coming from the web (per_call>=1, quota>=0 ⇒ 0=block)."""
    out = {}
    for k, lo, hi in (("per_call", 1, 1000), ("window_quota", 0, 100000), ("window_sec", 60, 604800)):
        if k in v:
            try: out[k] = max(lo, min(hi, int(v[k])))
            except (TypeError, ValueError): pass
    return out

def agent_limits(label):
    lm = CFG.get("limits") or {}
    base = dict(_DEFAULT_LIMITS); base.update(lm.get("default") or {})
    base.update((lm.get("agents") or {}).get(label) or {})
    return base

def _load_rate():
    try: return json.load(open(RATESTATE))
    except Exception: return {}
def _save_rate(s):
    try:
        with open(RATESTATE, "w") as f: json.dump(s, f)
        os.chmod(RATESTATE, 0o600)
    except OSError: pass

def rate_gate(label, rows, atomic=False):
    """Disclosure quota. Returns (allowed_rows, note).
    per_call: single-call cap; window_quota: DISTINCT name cap in the rolling window.
    Already-disclosed names are 'free' (re-querying doesn't disclose more).
    atomic=True (run_with_secrets/inject): all-or-nothing — to avoid partial env injection
    (a silently broken command + partial leak). Since the inject value never returns to the model,
    the per_call row cap is not applied; the protection = how many DISTINCT secrets are opened to the child command in the window."""
    lim = agent_limits(label); now = time.time()
    st = _load_rate()
    log = [p for p in st.get(label, []) if now - p[0] < lim["window_sec"]]   # prune old entries
    disclosed = {n for _, n in log}
    if atomic:
        remaining = max(0, lim["window_quota"] - len(disclosed))
        new_names = {e["name"] for e in rows if not (e["name"] in disclosed and lim["window_quota"] > 0)}
        if lim["window_quota"] == 0 or len(new_names) > remaining:   # quota 0 = full block
            return [], ("\n[limit] injection blocked — %d new secret(s) exceed agent '%s' window quota "
                        "(distinct %d, %d left). Narrow the scope (project/repo/env) or raise the limit in Settings."
                        % (len(new_names), label, lim["window_quota"], remaining))
        for e in rows:
            nm = e["name"]
            if not (nm in disclosed and lim["window_quota"] > 0): log.append([now, nm]); disclosed.add(nm)
        st[label] = log; _save_rate(st)
        return rows, ""
    capped = rows[:lim["per_call"]]
    remaining = max(0, lim["window_quota"] - len(disclosed))
    allowed, newn, held = [], 0, 0
    for e in capped:
        nm = e["name"]
        if nm in disclosed and lim["window_quota"] > 0: allowed.append(e)   # already disclosed → free (quota 0=full block)
        elif newn < remaining:
            allowed.append(e); log.append([now, nm]); disclosed.add(nm); newn += 1
        else: held += 1                                             # quota exhausted → hide
    st[label] = log; _save_rate(st)
    hidden = held + max(0, len(rows) - len(capped))
    note = ""
    if hidden:
        note = ("\n[limit] %d secret(s) hidden — agent '%s' (per-call <=%d, window distinct quota %d, %d left). "
                "Narrow the query (project/tag/type) or wait for the window to reset."
                % (hidden, label, lim["per_call"], lim["window_quota"], max(0, remaining - newn)))
    return allowed, note

# ---- secret types: field templates (secret fields are masked) ----
TYPES = {
    # --- developer / infra ---
    "api_key":  [("value", "secret")],
    "access_token": [("token", "secret"), ("refresh_token", "secret"), ("expires", "plain"), ("scopes", "plain")],
    "oauth":    [("client_id", "plain"), ("client_secret", "secret"), ("auth_url", "plain"), ("token_url", "plain"), ("scopes", "plain")],
    "jwt":      [("token", "secret"), ("issuer", "plain"), ("audience", "plain"), ("expires", "plain")],
    "ssh_key":  [("private_key", "secret"), ("public_key", "plain"), ("passphrase", "secret"), ("host", "plain"), ("user", "plain")],
    "certificate": [("certificate", "plain"), ("private_key", "secret"), ("chain", "plain"), ("expires", "plain")],
    "database": [("host", "plain"), ("port", "plain"), ("database", "plain"),
                 ("schema", "plain"), ("username", "plain"), ("password", "secret"),
                 ("auth_type", "plain"), ("jdbc_url", "secret")],   # connection string carries credentials → secret
    "server":   [("host", "plain"), ("port", "plain"), ("username", "plain"), ("password", "secret"), ("ssh_key", "secret")],
    "website":  [("web_url", "plain"), ("username", "plain"), ("password", "secret")],
    "login":    [("web_url", "plain"), ("username", "plain"), ("password", "secret"), ("totp", "secret")],
    # --- everyday / end-user (PII-bearing card/passport/id types INTENTIONALLY omitted) ---
    "pin":         [("pin", "secret"), ("label", "plain")],   # phone pin, door pin…
    "wifi":        [("ssid", "plain"), ("password", "secret"), ("security", "plain")],
    "membership":  [("provider", "plain"), ("member_id", "plain"), ("password", "secret")],
    "secure_note": [("note", "secret")],
    "custom":   [],   # free-form fields
}
_SECRETY = re.compile(r"pass|secret|token|value|key|credential|apikey", re.I)
_URL_CREDS = re.compile(r"://[^\s/@]*:[^\s/@]+@")   # embedded [user]:pass@ (incl. redis://:pw@; excl. host:port/)
def field_is_secret(typ, fname):
    for f, k in TYPES.get(typ, []):
        if f == fname: return k == "secret"
    return bool(_SECRETY.search(fname))
def rec_field_secret(e, fname):
    """Record-aware secrecy: per-record override → type template/name → credential embedded in the value."""
    fm = (e.get("field_meta") or {}).get(fname) or {}
    if isinstance(fm, dict) and "secret" in fm: return bool(fm["secret"])
    if field_is_secret(e.get("type", "custom"), fname): return True
    return bool(_URL_CREDS.search(str(e.get("fields", {}).get(fname, ""))))
def rec_mask(e, fname, v):
    """Masking style: field_meta.mask == 'full' → all stars; otherwise partial (mask())."""
    fm = (e.get("field_meta") or {}).get(fname) or {}
    if isinstance(fm, dict) and fm.get("mask") == "full": return "•" * 8
    return mask(v)

_KEY_CACHE = None   # decrypted age private key (process-lifetime in-memory cache; not written to disk)
def _env():
    e = dict(os.environ)
    k = _unlock_key()               # key text (hardened) or None (legacy: plaintext file)
    if k: e["SOPS_AGE_KEY"] = k     # give sops the key from memory instead of a FILE → no plaintext key on disk
    else: e["SOPS_AGE_KEY_FILE"] = KEY
    return e
def now_iso(): return datetime.now(timezone.utc).isoformat(timespec="seconds")
def mask(v):
    v = str(v); return v[:4] + "…" + v[-2:] if len(v) > 8 else ("•" * len(v) or "—")

# ---------------- store ----------------
def load():
    r = subprocess.run(["sops", "-d", "--output-type", "json", FILE],
                       env=_env(), capture_output=True, text=True)
    if r.returncode: sys.exit(f"decrypt error: {r.stderr.strip()}")
    d = json.loads(r.stdout); d.setdefault("secrets", [])
    for e in d["secrets"]: norm(e)
    return d

def save(d):
    cmd = ["sops", "--encrypt", "--input-type", "json", "--output-type", "yaml",
           "--filename-override", FILE]
    if os.path.exists(SOPSCFG): cmd[1:1] = ["--config", SOPSCFG]
    payload = json.dumps(d)
    if sys.platform.startswith("win"):
        # Windows has no /dev/stdin, so sops must read a real file. Write it inside the
        # ACL-locked keys/ dir (inherits user-only access), then delete right after.
        # Documented caveat (docs/WINDOWS.md): the vault plaintext briefly touches an
        # ACL-restricted temp file here, unlike the /dev/stdin (no-disk) path on Unix.
        import concealer_win
        fd, tmp = tempfile.mkstemp(dir=os.path.dirname(KEY), suffix=".json")
        try:
            os.write(fd, payload.encode()); os.close(fd); concealer_win.secure_file(tmp)
            r = subprocess.run(cmd + [tmp], env=_env(), capture_output=True, text=True)
        finally:
            try: os.remove(tmp)
            except OSError: pass
    else:
        r = subprocess.run(cmd + ["/dev/stdin"], input=payload, env=_env(), capture_output=True, text=True)
    if r.returncode: sys.exit(f"encrypt error: {r.stderr.strip()}")
    with open(FILE, "w") as f: f.write(r.stdout)

def norm(e):
    """Upgrade old/incomplete records to the new schema (id, type, fields...)."""
    for dim in DIMS: e.setdefault(dim, "")
    e.setdefault("tags", []); e.setdefault("url", ""); e.setdefault("notes", "")
    e.setdefault("type", "api_key")
    if "fields" not in e:
        e["fields"] = {"value": e.pop("value")} if "value" in e else {}
    if not e.get("id"):
        e["id"] = hashlib.sha1((e.get("name", "") + "".join(e.get(x, "") for x in DIMS)
                                + now_iso()).encode()).hexdigest()[:12]
    e.setdefault("field_meta", {})   # {fname: {"secret":bool,"mask":"partial"|"full"}} — record-specific secrecy
    e.setdefault("collection", "")   # free-form grouping path ("a/b" nested); independent of scope — see feature-plan
    e.setdefault("rotation", {})     # {"every_days":int,"last":iso,"mode":"generate"|"manual"} — empty = no policy
    e.setdefault("created", now_iso()); e.setdefault("updated", e["created"])
    return e

def secs(d): return d["secrets"]
def by_id(d, sid): return next((e for e in secs(d) if e["id"] == sid), None)
def matches(e, sel): return all(str(e.get(k, "")) in v.split(",") for k, v in sel.items())  # v = single or comma-separated multi-select
def label(e): return "/".join(e.get(k, "") or "*" for k in DIMS)

def filt(d, sel=None, tag=None, term=None, typ=None, coll=None):
    out = []
    for e in secs(d):
        if sel and not matches(e, sel): continue
        if tag and not any(tg in e["tags"] for tg in tag.split(",")): continue   # multi tag: match if any one matches
        if typ and e["type"] not in typ.split(","): continue
        if coll is not None:   # collection filter: exact or sub-path ("a" -> "a", "a/b"). "" -> collection-less
            c = e.get("collection", "")
            if coll == "":
                if c: continue
            elif not (c == coll or c.startswith(coll + "/")): continue
        if term:
            hay = json.dumps({k: e.get(k) for k in DIMS + ["name", "tags", "url", "notes", "type", "collection"]}).lower()
            hay += " " + " ".join(e["fields"].keys()).lower()
            if term.lower() not in hay: continue
        out.append(e)
    return sorted(out, key=lambda e: [e.get(k, "") for k in DIMS + ["name"]])

def entry_public(e, reveal=False):
    fields = {}
    for fn, fv in e["fields"].items():
        if _bad_field_name(fn): continue   # don't expose fields with secret-like (leaked) names to the UI/MCP
        sec = rec_field_secret(e, fn)   # record-aware: override + jdbc/dsn credential heuristic
        fields[fn] = {"secret": sec, "value": fv} if (reveal or not sec) else {"secret": True, "mask": rec_mask(e, fn, fv)}
    pub = {k: e.get(k) for k in ["id", "name", "type"] + DIMS + ["tags", "url", "notes", "created", "updated"]}
    pub["fields"] = fields; pub["field_meta"] = e.get("field_meta") or {}
    pub["collection"] = e.get("collection", ""); pub["rotation"] = e.get("rotation") or {}
    pub["overdue"] = rotation_overdue(e); return pub

def rotation_overdue(e):
    """Is rotation overdue → number of days past due (>=0) or None (no policy)."""
    r = e.get("rotation") or {}
    days = r.get("every_days")
    if not days: return None
    base = r.get("last") or e.get("updated") or e.get("created")
    try: last = datetime.fromisoformat(base)
    except Exception: return None
    if last.tzinfo is None: last = last.replace(tzinfo=timezone.utc)
    over = (datetime.now(timezone.utc) - last).days - int(days)
    return over if over >= 0 else None

# ---------------- audit (HMAC chain) ----------------
def _audit_key():
    if not os.path.exists(AUDITKEY):
        with open(AUDITKEY, "w") as f: f.write(_secrets.token_hex(32))
        os.chmod(AUDITKEY, 0o600)
    return bytes.fromhex(open(AUDITKEY).read().strip())

def _last_hash():
    if not os.path.exists(AUDITLOG): return "genesis"
    last = "genesis"
    with open(AUDITLOG) as f:
        for line in f:
            line = line.strip()
            if line:
                try: last = json.loads(line)["hash"]
                except Exception: pass
    return last

def _last_seq():
    # monotonic seq no: missing (old) lines keep the order, a deleted tail is caught via the anchor
    if not os.path.exists(AUDITLOG): return 0
    last = 0
    with open(AUDITLOG) as f:
        for line in f:
            line = line.strip()
            if line:
                try: last = json.loads(line).get("seq", last)
                except Exception: pass
    return last

def _write_anchor(seq, h):
    with open(AUDITHEAD, "w") as f: json.dump({"seq": seq, "hash": h}, f)
    os.chmod(AUDITHEAD, 0o600)
def _read_anchor():
    try: return json.load(open(AUDITHEAD))
    except Exception: return None

def anchor_push(file=None, webhook=None, syslog=None, persist=False):
    """Push the current audit head (seq+hash) to OFF-MACHINE append-only targets.
    The local audit.head can be rewritten by root; an external copy (remote syslog / a file on
    another machine / webhook) makes a FULL re-forge of the chain detectable. No value/secret leaks —
    only seq + chain hash go out. Intended to be called periodically from cron."""
    anc = _read_anchor()
    if not anc: return None, []   # no audit yet
    cfg = CFG.get("audit_anchor") or {}
    file = cfg.get("file") if file is None else file
    webhook = cfg.get("webhook") if webhook is None else webhook
    syslog = cfg.get("syslog") if syslog is None else syslog
    if persist:
        CFG["audit_anchor"] = {"file": file or "", "webhook": webhook or "", "syslog": bool(syslog)}
        _cfg_persist()
    rec = {"ts": now_iso(), "seq": anc["seq"], "hash": anc["hash"]}
    line = json.dumps(rec); sent = []
    if file:
        try:
            with open(os.path.expanduser(file), "a") as f: f.write(line + "\n")
            sent.append("file")
        except OSError as ex: sys.stderr.write(f"anchor file error: {ex}\n")
    if syslog:
        try: subprocess.run(["logger", "-t", "concealer-audit", line], check=False); sent.append("syslog")
        except OSError as ex: sys.stderr.write(f"anchor syslog error: {ex}\n")
    if webhook:
        try:
            req = urllib.request.Request(webhook, data=line.encode(), headers={"Content-Type": "application/json"})
            urllib.request.urlopen(req, timeout=5); sent.append("webhook")
        except Exception as ex: sys.stderr.write(f"anchor webhook error: {ex}\n")
    audit("audit_anchor", key=f"seq={rec['seq']}", source="cli", detail=",".join(sent))
    return rec, sent

def audit(action, key="", source="cli", detail="", actor=None):
    # actor: the actor performing the action (agent name etc). Comes from CONCEALER_ACTOR env.
    if actor is None: actor = os.environ.get("CONCEALER_ACTOR", "")
    entry = {"ts": now_iso(), "seq": _last_seq() + 1, "action": action, "key": key, "source": source, "detail": detail}
    if actor: entry["actor"] = actor   # new field; if empty stays identical to old lines
    prev = _last_hash()
    h = hmac.new(_audit_key(), (prev + json.dumps(entry, sort_keys=True)).encode(), hashlib.sha256).hexdigest()
    with open(AUDITLOG, "a") as f: f.write(json.dumps({**entry, "prev": prev, "hash": h}) + "\n")
    _write_anchor(entry["seq"], h)   # anchor: catches the last line being deleted (truncation)

def audit_rows():
    rows = []
    if os.path.exists(AUDITLOG):
        with open(AUDITLOG) as f:
            for i, line in enumerate(f):
                line = line.strip()
                if line:
                    r = json.loads(line); r["n"] = i + 1; rows.append(r)
    return rows

def audit_verify():
    # ponytail: audit.key is on disk so an FS-root attacker can rewrite the chain from scratch; this verify
    # catches insert/delete/reorder and TAIL-TRUNCATION (anchor). For full immutability the
    # audit.key/anchor must be off-machine (upgrade path). See same threat model as write_master.
    prev = "genesis"; bad = None; n = 0; last_seq = 0; reason = None
    for i, rec in enumerate(audit_rows()):
        n += 1
        # hash payload = all fields except prev/hash/n → new fields (actor/seq) are backward-compatible
        entry = {k: v for k, v in rec.items() if k not in ("prev", "hash", "n")}
        h = hmac.new(_audit_key(), (prev + json.dumps(entry, sort_keys=True)).encode(), hashlib.sha256).hexdigest()
        if h != rec["hash"] or rec["prev"] != prev: bad = i + 1; reason = "chain"; break
        s = rec.get("seq")
        if s is not None:
            if last_seq and s != last_seq + 1: bad = i + 1; reason = "seq"; break  # skipped/repeated seq no
            last_seq = s
        prev = rec["hash"]
    res = {"ok": bad is None, "count": n, "broken_at": bad}
    if reason: res["reason"] = reason
    anc = _read_anchor()   # anchor: if the last hash/seq mismatches, the tail was deleted
    if bad is None and anc and (anc.get("hash") != prev or (last_seq and anc.get("seq") != last_seq)):
        res.update(ok=False, reason="truncated", broken_at=n + 1)
    # external anchor: the last head in the off-machine file must match the chain's hash at that seq.
    # even if local audit.key/head are rewritten by root, this copy catches a full re-forge.
    ext = (CFG.get("audit_anchor") or {}).get("file")
    if res.get("ok") and ext and os.path.exists(os.path.expanduser(ext)):
        try:
            last_ext = None
            with open(os.path.expanduser(ext)) as f:
                for ln in f:
                    ln = ln.strip()
                    if ln: last_ext = json.loads(ln)
            if last_ext:
                seqmap = {r.get("seq"): r["hash"] for r in audit_rows() if r.get("seq") is not None}
                es = last_ext.get("seq")
                if es not in seqmap or seqmap[es] != last_ext.get("hash"):
                    res.update(ok=False, reason="external_anchor", broken_at=es)
        except Exception: pass   # file unreadable/corrupt → don't block local verification
    return res

_ACCESS_ACTS = {"reveal", "copy", "get", "inject", "create", "update", "rotate", "set_secret"}
def last_access():
    """name -> {ts, source, action, actor}: last access derived from the audit log."""
    acc = {}
    for r in audit_rows():
        if r.get("action") in _ACCESS_ACTS:
            for k in str(r.get("key", "")).split(","):
                k = k.strip()
                if k: acc[k] = {"ts": r["ts"], "source": r.get("source", ""),
                                "action": r["action"], "actor": r.get("actor", "")}
    return acc

def access_stats():
    """name -> {count, last}: usage count + last access derived from the audit log (value-access actions only)."""
    st = {}
    _USE_ACTS = _ACCESS_ACTS - {"create"}   # 'create' is not a use (creation) — drop from the count
    for r in audit_rows():
        if r.get("action") in _USE_ACTS:
            for k in str(r.get("key", "")).split(","):
                k = k.strip()
                if not k: continue
                s = st.setdefault(k, {"count": 0, "last": None})
                s["count"] += 1; s["last"] = r["ts"]   # audit_rows is chronological → last wins
    return st

_EXP_FIELDS = ("expires", "expiry", "expires_at", "expiration", "valid_until", "not_after")
def expiry_days(e, now=None):
    """Days left from an expires-like field (negative = expired) or None (no field/unparseable).
    ponytail: ISO date/datetime + unix epoch(s/ms) heuristic; exotic formats return None."""
    now = now or datetime.now(timezone.utc)
    for fn in _EXP_FIELDS:
        v = e["fields"].get(fn)
        if not v: continue
        v = str(v).strip(); dt = None
        try:
            if re.fullmatch(r"\d{10,13}", v):
                ts = int(v); ts = ts / 1000 if ts > 1e11 else ts
                dt = datetime.fromtimestamp(ts, timezone.utc)
            else:
                dt = datetime.fromisoformat(v.replace("Z", "+00:00"))
        except Exception:
            try: dt = datetime.strptime(v[:10], "%Y-%m-%d").replace(tzinfo=timezone.utc)
            except Exception: dt = None
        if dt:
            if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc)
            return (dt - now).days
    return None

def health_scan(d=None):
    """Risk metrics per record: rotation lag, expiry, usage, reuse (no value leaks)."""
    d = d or load()
    use = access_stats(); now = datetime.now(timezone.utc)
    leak_by_name = {}
    for g in leak_scan(d):
        for u in g["uses"]:
            prev = leak_by_name.get(u["name"])
            if not prev or g["score"] > prev["score"]:
                leak_by_name[u["name"]] = {"score": g["score"], "severity": g["severity"]}
    out = []
    for e in secs(d):
        name = e["name"]; u = use.get(name, {}); lk = leak_by_name.get(name, {})
        rot = e.get("rotation") or {}
        out.append({"id": e["id"], "name": name, "type": e["type"], "scope": label(e),
                    "project": e.get("project", ""), "repo": e.get("repo", ""),
                    "environment": e.get("environment", ""), "tags": e.get("tags", []),
                    "overdue": rotation_overdue(e), "has_policy": bool(rot.get("every_days")),
                    "expires_in": expiry_days(e, now),
                    "uses": u.get("count", 0), "last_used": u.get("last"),
                    "leak_score": lk.get("score"), "leak_sev": lk.get("severity")})
    return out

# ---------------- policies (reminder rules) ----------------
# A policy: {id,name,kind,audience,enabled,notify,match{...},params{...}}. No value leaks — it only
# looks at records' META (rotation/expiry/name/tag) and leak_scan (reuse, value masked).
_POLICY_KINDS = ("rotation", "expiry", "reuse", "naming", "tagging")
_POLICY_AUD = ("all", "user", "agent", "cli", "web", "tui", "mcp")
def _policy_match(e, m):
    """Does the record fall under this policy's target filter (empty field = no restriction)."""
    m = m or {}
    for k in ("project", "environment", "repo", "type", "collection"):
        want = (m.get(k) or "").strip()
        if want and str(e.get(k, "")) != want: return False
    tag = (m.get("tag") or "").strip()
    if tag and tag not in (e.get("tags") or []): return False
    return True
def _as_int(v, dflt=0):
    try: return int(v)
    except (TypeError, ValueError): return dflt
def _policy_violation(p, e, now, reuse_names):
    """Human-readable reason (str) if the record violates the policy, else None."""
    k = p.get("kind"); pr = p.get("params") or {}
    if k == "rotation":
        maxd = _as_int(pr.get("max_days")); rot = e.get("rotation") or {}; ev = rot.get("every_days")
        over = rotation_overdue(e)
        if not ev: return "no rotation policy"
        if maxd and ev > maxd: return f"rotates every {ev}d (> {maxd}d)"
        if over is not None: return f"{over}d overdue"
        return None
    if k == "expiry":
        warn = _as_int(pr.get("warn_days"), 30); ed = expiry_days(e, now)
        if ed is None: return "no expiry set" if pr.get("require") else None
        if ed < 0: return f"expired {-ed}d ago"
        if ed <= warn: return f"expires in {ed}d"
        return None
    if k == "reuse":
        return "value reused across records" if e["name"] in reuse_names else None
    if k == "naming":
        rx = pr.get("regex") or ""
        if not rx: return None
        try: return None if re.search(rx, e["name"]) else f"name must match /{rx}/"
        except re.error: return None
    if k == "tagging":
        req = [t for t in (pr.get("tags") or []) if t]
        miss = [t for t in req if t not in (e.get("tags") or [])]
        return "missing tag(s): " + ", ".join(miss) if miss else None
    return None
def policy_eval(d=None):
    """Violating records per policy. reuse is computed only when needed (leak_scan is expensive)."""
    d = d or load(); pols = CFG.get("policies") or []
    reuse_names = set()
    if any(p.get("kind") == "reuse" and p.get("enabled", True) for p in pols):
        for g in leak_scan(d):
            for u in g["uses"]: reuse_names.add(u["name"])
    now = datetime.now(timezone.utc); out = []
    for p in pols:
        viols = []
        if p.get("enabled", True):
            for e in secs(d):
                if not _policy_match(e, p.get("match")): continue
                r = _policy_violation(p, e, now, reuse_names)
                if r: viols.append({"id": e["id"], "name": e["name"], "scope": label(e), "reason": r})
        out.append({**p, "violations": viols, "count": len(viols)})
    return out
def _policy_clean(pol):
    """Normalize the policy dict coming from web/CLI to the schema (generate/preserve id)."""
    pid = (pol.get("id") or "").strip() or hashlib.sha1((pol.get("name", "") + now_iso()).encode()).hexdigest()[:12]
    match = pol.get("match") or {}
    params = pol.get("params") if isinstance(pol.get("params"), dict) else {}
    return {"id": pid, "name": (pol.get("name") or "policy").strip()[:60],
            "kind": pol.get("kind") if pol.get("kind") in _POLICY_KINDS else "rotation",
            "audience": pol.get("audience") if pol.get("audience") in _POLICY_AUD else "all",
            "enabled": bool(pol.get("enabled", True)), "notify": bool(pol.get("notify", False)),
            "match": {k: str(match.get(k, "") or "").strip() for k in ("project", "environment", "repo", "type", "collection", "tag")},
            "params": params}
def policy_upsert(pol):
    pol = _policy_clean(pol); pols = CFG.get("policies") or []
    for i, x in enumerate(pols):
        if x.get("id") == pol["id"]: pols[i] = pol; break
    else: pols.append(pol)
    CFG["policies"] = pols; _cfg_persist(); return pol
def policy_delete(pid):
    pols = CFG.get("policies") or []; n = len(pols)
    CFG["policies"] = [x for x in pols if x.get("id") != pid]; _cfg_persist()
    return n != len(CFG["policies"])

# ---------------- leak / reuse scan ----------------
def leak_scan(d=None):
    """Find records sharing the same secret VALUE and risk-score them. No value leaks (masked)."""
    d = d or load(); by_val = {}
    for e in secs(d):
        for fn, fv in e["fields"].items():
            if not fv or not rec_field_secret(e, fn): continue   # override + embedded-credential values included too
            by_val.setdefault(str(fv), []).append(
                {"id": e["id"], "name": e["name"], "field": fn, "scope": label(e),
                 "project": e.get("project", ""), "environment": e.get("environment", "")})
    groups = []
    for val, uses in by_val.items():
        if len(uses) < 2: continue
        projects = sorted({u["project"] for u in uses if u["project"]})
        envs = sorted({u["environment"] for u in uses if u["environment"]})
        names = {u["name"] for u in uses}
        # score: use count + project/env spread + distinct name + prod sharing
        score = min(100, len(uses) * 12 + max(0, len(projects) - 1) * 20 +
                    max(0, len(envs) - 1) * 15 + (10 if len(names) > 1 else 0) +
                    (15 if any(x.lower() == "prod" for x in envs) else 0))
        sev = "high" if score >= 70 else "med" if score >= 40 else "low"
        groups.append({"count": len(uses), "projects": projects, "envs": envs,
                       "score": score, "severity": sev, "mask": mask(val), "uses": uses})
    groups.sort(key=lambda g: -g["score"])
    return groups

# ---------------- folder scan / import ----------------
_TOKEN_PATTERNS = [
    ("aws_access_key", re.compile(r"AKIA[0-9A-Z]{16}")),
    ("openai",         re.compile(r"sk-[A-Za-z0-9_-]{20,}")),
    ("github_pat",     re.compile(r"gh[pousr]_[A-Za-z0-9]{20,}")),
    ("slack",          re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}")),
    ("google_api",     re.compile(r"AIza[0-9A-Za-z_-]{35}")),
    ("jwt",            re.compile(r"eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}")),
]
_ENV_LINE = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+?)\s*$")
_SKIP_DIRS = {".git", "node_modules", "venv", ".venv", "__pycache__", "dist", "build", ".next", ".terraform"}
def _unquote(v):
    v = v.strip()
    if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'": v = v[1:-1]
    return v
def _looks_secret(name, val):
    if len(val) < 6 or val.startswith("$"): return False
    if _SECRETY.search(name): return True
    if any(rx.search(val) for _, rx in _TOKEN_PATTERNS): return True
    # high entropy + length (random token)
    return (len(val) >= 20 and len(set(val)) >= 12
            and bool(re.search(r"[0-9]", val)) and bool(re.search(r"[A-Za-z]", val)))

# ---- custom-field NAME validation: a field name should be a short identifier, not a secret VALUE ----
_FIELD_NAME_OK = re.compile(r"^[A-Za-z0-9 _.\-]{1,40}$")   # >40 chars or non-identifier chars like / + : = @ = leaked
def _bad_field_name(name):
    """True => this 'field name' looks like a credential/key/token/URL (a leaked value entered by mistake)."""
    n = (name or "").strip()
    if not n: return True
    if not _FIELD_NAME_OK.match(n): return True                        # long name or URL/base64/non-identifier char
    if any(rx.search(n) for _, rx in _TOKEN_PATTERNS): return True     # AKIA…/sk-…/ghp_…/AIza…/JWT pattern embedded in the name
    return any(len(r) >= 20 and re.search(r"\d", r) and re.search(r"[A-Za-z]", r)
               for r in re.split(r"[ _.\-]+", n))                      # 20+ mixed alphanumerics with no separator = random token
def _clean_fields(fields):
    """(clean_fields, dropped_names) — drops fields whose name is secret-like (a leak)."""
    fields = fields or {}
    bad = [k for k in fields if _bad_field_name(k)]
    return ({k: v for k, v in fields.items() if k not in bad}, bad)
# ---- environment variable / global variable scan (macOS + Linux; Windows later) ----
# live process env + shell profile files (login/interactive) — export/set KEY=VALUE
_ENV_PROFILES = ("~/.bashrc", "~/.bash_profile", "~/.profile", "~/.zshrc", "~/.zshenv",
                 "~/.zprofile", "~/.kshrc", "~/.config/fish/config.fish",
                 "/etc/environment", "/etc/profile")
_ENV_SKIP = {"SOPS_AGE_KEY", "SOPS_AGE_KEY_FILE", "CONCEALER_TOKEN", "PATH", "PWD", "OLDPWD",
             "LS_COLORS", "MANPATH", "INFOPATH", "TERMCAP"}
# terminal/editor/session noise — drop from the scan if the name isn't really a secret (see _SECRETY)
_ENV_NAME_NOISE = re.compile(r"SESSION|ASKPASS|DEBUGPY|ENDPOINTS|TERM_FEATURES|_SOCK$|_PID$|_PATH$|COLORTERM", re.I)
_FISH_SET = re.compile(r"^\s*set\s+(?:-[gxUl]+\s+)*([A-Za-z_][A-Za-z0-9_]*)\s+(.+?)\s*$")
def env_scan():
    """Return secret-like candidates from the local machine's ENV/GLOBAL variables:
    live os.environ + shell profile files (export/set KEY=VALUE). No value goes out (mask).
    macOS + Linux. ponytail: Windows system/user env (registry) later."""
    cands = {}
    def add(name, val, src):
        val = _unquote(str(val))
        if name in _ENV_SKIP or not val or re.search(r"\s", val): return   # has a space = path/sentence, not a token
        if not _SECRETY.search(name):   # if the name doesn't say secret: drop path/session-noise values
            if val.startswith(("/", "~", "./", "../", "http://", "https://")): return
            if _ENV_NAME_NOISE.search(name): return
        if _looks_secret(name, val):
            cands.setdefault((name, val), {"name": name, "value": val, "mask": mask(val), "source": src})
    for k, v in os.environ.items(): add(k, v, "env")
    for pf in _ENV_PROFILES:
        p = os.path.expanduser(pf)
        if not os.path.isfile(p): continue
        try: txt = open(p, errors="ignore").read()
        except Exception: continue
        for line in txt.splitlines():
            if line.lstrip().startswith("#"): continue
            m = _ENV_LINE.match(line)
            if m: add(m.group(1), m.group(2), os.path.basename(p))
            else:
                mf = _FISH_SET.match(line)
                if mf: add(mf.group(1), mf.group(2), os.path.basename(p))
    return sorted(cands.values(), key=lambda c: c["name"])

def scan_folder(root, history=False, env=False):
    """Return secret-like KEY=VALUE pairs as candidates from the folder's .env/.tfvars/.envrc
    + (optional) shell history + (optional) environment variables. No value goes out (listed masked)."""
    root = os.path.expanduser(root or ""); cands = {}
    def add(name, val, src):
        val = _unquote(val)
        if _looks_secret(name, val):
            cands.setdefault((name, val), {"name": name, "value": val, "mask": mask(val), "source": src})
    if os.path.isdir(root):
        for dp, dns, fns in os.walk(root):
            dns[:] = [x for x in dns if x not in _SKIP_DIRS]
            for fn in fns:
                if not (fn.startswith(".env") or fn.endswith(".env") or fn == ".envrc" or fn.endswith(".tfvars")): continue
                fp = os.path.join(dp, fn)
                try: txt = open(fp, errors="ignore").read()
                except Exception: continue
                for line in txt.splitlines():
                    if line.lstrip().startswith("#"): continue
                    m = _ENV_LINE.match(line)
                    if m: add(m.group(1), m.group(2), os.path.relpath(fp, root))
    if history:
        for hf in ("~/.zsh_history", "~/.bash_history"):
            hp = os.path.expanduser(hf)
            if not os.path.exists(hp): continue
            try: txt = open(hp, errors="ignore").read()
            except Exception: continue
            for m in re.finditer(r"(?:export\s+)?([A-Z][A-Z0-9_]{2,})=([^\s;|&'\"]+)", txt):
                add(m.group(1), m.group(2), os.path.basename(hp))
    if env:
        for c in env_scan(): cands.setdefault((c["name"], c["value"]), c)
    return sorted(cands.values(), key=lambda c: c["name"])

# ---------------- shell history leak scan ----------------
_HIST_FILES = ("~/.zsh_history", "~/.bash_history", "~/.histfile", "~/.sh_history", "~/.ksh_history")
# narrow secret-name for CLI KEY=VALUE (unlike the broad _SECRETY: `value|key|id` alone isn't enough)
_HIST_SECRET_NAME = re.compile(r"secret|token|passw|credential|api[_-]?key|access[_-]?key|private[_-]?key|bearer|\bpat\b", re.I)
def _high_entropy(v):
    """Does it look like a real credential/token: long + mixed. Short/dictionary-like project codes, hosts,
    path fragments etc. are DROPPED — they match as substrings everywhere in history and produce false positives."""
    return (len(v) >= 12 and len(set(v)) >= 8
            and bool(re.search(r"[0-9]", v)) and bool(re.search(r"[A-Za-z]", v)))
def _vault_vals(d):
    """HIGH-ENTROPY secret field values in the vault. Empty set if the vault is locked/absent.
    Only genuinely secret-like values; short labels (project code, tenant) are not scanned (see _high_entropy)."""
    vals = set()
    try:
        for e in secs(d if d is not None else load()):
            for fn, fv in e["fields"].items():
                if fv and rec_field_secret(e, fn) and _high_entropy(str(fv)): vals.add(str(fv))
    except SystemExit: pass
    return vals
def _hist_hits(line, vault_vals):
    """Secret fragments in line: (value, reason, severity). No value goes out; the caller masks."""
    hits = []
    for v in vault_vals:
        # match at a full-token boundary (no alphanumeric neighbor) — avoids matching part of a longer token
        if v in line and re.search(r"(?<![A-Za-z0-9])" + re.escape(v) + r"(?![A-Za-z0-9])", line):
            hits.append((v, "vault", "high"))
    for kind, rx in _TOKEN_PATTERNS:
        for m in rx.finditer(line): hits.append((m.group(0), kind, "high"))
    # KEY=VALUE inline: on the command line far noisier than in a .env file (`--file=`, `--tag-value=` etc.).
    # So a STRICT rule here: the name must really look like a secret-name (NOT the broad `value|key|id`) AND
    # the value must be high-entropy. Otherwise file-path/URL/tag values produce false positives.
    for m in re.finditer(r"(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=([^\s;|&'\"]+)", line):
        name, val = m.group(1), _unquote(m.group(2))
        if _HIST_SECRET_NAME.search(name) and _high_entropy(val): hits.append((val, "env:" + name, "med"))
    return hits
def history_scan(d=None):
    """Find credential/secret values in shell history files (vault-match + token pattern).
    No value goes out; the command line is returned masked."""
    vault_vals = _vault_vals(d)
    out = []
    for hf in _HIST_FILES:
        hp = os.path.expanduser(hf)
        if not os.path.exists(hp): continue
        try: lines = open(hp, errors="ignore").read().splitlines()
        except Exception: continue
        for i, line in enumerate(lines):
            hits = _hist_hits(line, vault_vals)
            if not hits: continue
            red = line
            for v in sorted({h[0] for h in hits}, key=len, reverse=True): red = red.replace(v, mask(v))
            red = re.sub(r"^:\s*\d+:\d+;", "", red)   # zsh EXTENDED_HISTORY meta (: <ts>:<dur>;) — no need to show
            out.append({"file": os.path.basename(hp), "path": hp, "line": i + 1,
                        "mask": mask(hits[0][0]), "cmd": red[:240],
                        "reasons": sorted({h[1] for h in hits}),
                        "severity": "high" if any(h[2] == "high" for h in hits) else "med"})
    out.sort(key=lambda x: (x["severity"] != "high", x["file"], x["line"]))
    return out
def history_purge(targets, d=None):
    """targets: [{path, line}] — delete the given lines from history (first a *.concealer.bak backup).
    Safety: skips if the line no longer contains a secret (prevents deleting the wrong line). Returns count deleted.
    ponytail: the line-no index assumes append-only history; concurrent reordering is rare, and the backup undoes it."""
    vault_vals = _vault_vals(d)
    by_file = {}
    for t in targets: by_file.setdefault(os.path.expanduser(t["path"]), set()).add(int(t["line"]))
    removed = 0
    for hp, lns in by_file.items():
        if not os.path.exists(hp): continue
        lines = open(hp, errors="ignore").read().splitlines(keepends=True)
        kept, drop = [], 0
        for i, ln in enumerate(lines):
            if (i + 1) in lns and _hist_hits(ln, vault_vals): drop += 1; continue
            kept.append(ln)
        if drop:
            shutil.copy2(hp, hp + ".concealer.bak")
            with open(hp, "w") as f: f.writelines(kept)
            removed += drop
    return removed

# ================= online leak / exposure =================
# SECURITY: the FULL secret value is NEVER sent over the network. HIBP Pwned Passwords k-anonymity is used:
# only the first 5 hex of the value's SHA-1 are queried; matching happens locally. (see pwned_count)
_PWNED_UA = "concealer-secret-manager"
def pwned_count(value, timeout=8):
    """HIBP Pwned Passwords k-anonymity check. ONLY the SHA-1 prefix (5 hex) is sent; the API returns the
    thousands of suffixes sharing that prefix, and the full-hash comparison is done LOCALLY. The full value/hash never GOES OUT to the network.
    Returns: occurrence count (>=0) or -1 (network error). ponytail: a single provider (HIBP range) — this is the privacy-safe one."""
    h = hashlib.sha1(value.encode("utf-8", "ignore")).hexdigest().upper()
    prefix, suffix = h[:5], h[5:]
    try:
        req = urllib.request.Request("https://api.pwnedpasswords.com/range/" + prefix,
                                     headers={"User-Agent": _PWNED_UA, "Add-Padding": "true"})
        body = urllib.request.urlopen(req, timeout=timeout).read().decode("utf-8", "ignore")
    except Exception: return -1
    for line in body.splitlines():
        sfx, _, cnt = line.partition(":")
        if sfx.strip().upper() == suffix:
            try: return int(cnt.strip().split()[0])
            except (ValueError, IndexError): return 0
    return 0

# CWE references (static; source: cwe.mitre.org) — attached to findings
_CWE = {
    "798": ("CWE-798", "Use of Hard-coded Credentials", "https://cwe.mitre.org/data/definitions/798.html"),
    "259": ("CWE-259", "Use of Hard-coded Password", "https://cwe.mitre.org/data/definitions/259.html"),
    "321": ("CWE-321", "Use of Hard-coded Cryptographic Key", "https://cwe.mitre.org/data/definitions/321.html"),
    "312": ("CWE-312", "Cleartext Storage of Sensitive Information", "https://cwe.mitre.org/data/definitions/312.html"),
    "532": ("CWE-532", "Insertion of Sensitive Information into Log File", "https://cwe.mitre.org/data/definitions/532.html"),
    "540": ("CWE-540", "Inclusion of Sensitive Information in Source Code", "https://cwe.mitre.org/data/definitions/540.html"),
}
def _cwe(ids): return [{"id": _CWE[i][0], "title": _CWE[i][1], "url": _CWE[i][2]} for i in ids if i in _CWE]
# keyhacks-like validation hint: per token type a "is it live / how to check" GUIDE (SENDS no value)
_VALIDATE = [
    (re.compile(r"AKIA[0-9A-Z]{16}"), "AWS key — verify locally: `aws sts get-caller-identity`. keyhacks.md#aws-access-key-id"),
    (re.compile(r"gh[pousr]_[A-Za-z0-9]{20,}"), "GitHub token — scopes via `curl -H 'Authorization: token <T>' https://api.github.com/user` (run locally). keyhacks.md#github"),
    (re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"), "Slack token — test auth.test locally. keyhacks.md#slack-api-token"),
    (re.compile(r"AIza[0-9A-Za-z_-]{35}"), "Google API key — restrict & rotate in Cloud Console. keyhacks.md#google-api-key"),
    (re.compile(r"sk-[A-Za-z0-9_-]{20,}"), "OpenAI/Stripe-style key — rotate at the provider; never paste it into an online checker."),
]
def _validate_hint(value):
    for rx, msg in _VALIDATE:
        if rx.search(value): return msg
    return ""
def exposure_scan(names=None, ids=None, sel=None, online=True, d=None):
    """Run the SECRET field values of the selected secrets through an online leak check (HIBP k-anonymity).
    If ids are given, only those RECORDS (id — exact; does NOT mix up same-named records in different scopes/collections);
    names/sel for backward compatibility. The same value is queried once.
    SECURITY: the full value never GOES OUT to the network (only the SHA-1 prefix). online=False → only static CWE/hints, no network."""
    d = d or load(); out = []; seen = {}
    idset = set(ids) if ids is not None else None
    for e in secs(d):
        if idset is not None and e["id"] not in idset: continue
        if names is not None and e["name"] not in names: continue
        if sel and not matches(e, sel): continue
        for fn, fv in e["fields"].items():
            fv = str(fv or "")
            # the vault's OWN secret values — no false-positive worry; weak passwords must be checked too
            # (HIBP is strongest on weak passwords). The _high_entropy filter is NOT applied here; only trivial short values are dropped.
            if len(fv) < 6 or not rec_field_secret(e, fn): continue
            cnt = None
            if online:
                if fv in seen: cnt = seen[fv]
                else: cnt = pwned_count(fv); seen[fv] = cnt
            ids = ["798"]
            if e.get("type") in ("ssh_key", "certificate"): ids.append("321")
            elif "pass" in fn.lower(): ids.append("259")
            out.append({"id": e["id"], "name": e["name"], "field": fn, "scope": label(e), "mask": mask(fv),
                        "pwned": cnt, "severity": "high" if (cnt and cnt > 0) else ("med" if cnt == -1 else "low"),
                        "cwe": _cwe(ids), "validate": _validate_hint(fv)})
    out.sort(key=lambda x: -((x["pwned"] or 0)))
    return out

def _hibp_key(): return (CFG.get("hibp_key") or os.environ.get("CONCEALER_HIBP_KEY") or "").strip()
def hibp_breaches(email, timeout=8):
    """HIBP account leak: breaches an email appears in. Requires a v3 API key (user-supplied; CFG/env).
    Only the email the user entered is sent. Returns {ok, breaches|error, manual?}."""
    email = (email or "").strip()
    if not email or "@" not in email: return {"ok": False, "error": "bad_email"}
    key = _hibp_key()
    manual = "https://haveibeenpwned.com/account/" + urllib.parse.quote(email)
    if not key: return {"ok": False, "error": "no_key", "manual": manual}
    url = "https://haveibeenpwned.com/api/v3/breachedaccount/" + urllib.parse.quote(email) + "?truncateResponse=false"
    try:
        req = urllib.request.Request(url, headers={"hibp-api-key": key, "User-Agent": _PWNED_UA})
        rows = json.loads(urllib.request.urlopen(req, timeout=timeout).read().decode("utf-8", "ignore"))
    except Exception as ex:
        code = getattr(ex, "code", None)
        if code == 404: return {"ok": True, "breaches": []}       # clean — no leak
        return {"ok": False, "error": ("http_%s" % code) if code else "net", "manual": manual}
    return {"ok": True, "breaches": [{"name": b.get("Name"), "title": b.get("Title"), "date": b.get("BreachDate"),
             "count": b.get("PwnCount"), "data": b.get("DataClasses", [])} for b in rows]}

# ---------------- git history / log / gitignore scan (LOCAL — no value leaves the network) ----------------
def _git(repo, *args, timeout=30):
    try:
        r = subprocess.run(["git", "-C", repo, *args], capture_output=True, text=True, timeout=timeout, errors="ignore")
        return r.stdout if r.returncode == 0 else ""
    except Exception: return ""
def _is_git(repo): return bool(_git(repo, "rev-parse", "--is-inside-work-tree").strip())
_TOKEN_RX_ALL = re.compile("|".join(rx.pattern for _, rx in _TOKEN_PATTERNS))
_SECRET_FILE_RX = re.compile(
    r"(^|/)(\.env(\.[\w.-]+)?|\.envrc|[^/]*\.pem|[^/]*\.key|[^/]*\.p12|[^/]*\.pfx|id_rsa|id_ed25519|"
    r"[^/]*\.tfvars|credentials(\.json)?|\.npmrc|\.pypirc|secrets?\.(ya?ml|json))$", re.I)
_LOG_FILE_RX = re.compile(r"\.log(\.[\w.-]+)?$", re.I)
def gitignore_gaps(repo):
    """Suggest files that may carry secrets (.env/*.pem/id_rsa/...) if git isn't ignoring them.
    .gitignore (git check-ignore) + .claudeignore (fnmatch) are evaluated together."""
    repo = os.path.expanduser(repo or ""); cand = []
    for dp, dns, fns in os.walk(repo):
        dns[:] = [x for x in dns if x not in _SKIP_DIRS and x != ".git"]
        for fn in fns:
            rel = os.path.relpath(os.path.join(dp, fn), repo).replace(os.sep, "/")
            if _SECRET_FILE_RX.search(rel): cand.append(rel)
        if len(cand) > 500: break
    extra = []
    for ig in (".claudeignore", ".cursorignore", ".aiignore"):
        p = os.path.join(repo, ig)
        if os.path.isfile(p):
            extra += [l.strip() for l in open(p, errors="ignore") if l.strip() and not l.startswith("#")]
    gaps = []
    for rel in sorted(set(cand))[:100]:
        if _is_git(repo) and _git(repo, "check-ignore", rel).strip(): continue
        if any(fnmatch.fnmatch(rel, pat) or fnmatch.fnmatch(os.path.basename(rel), pat) for pat in extra): continue
        gaps.append(rel)
    return gaps
def git_scan(repo, d=None):
    """Search for secrets in the repo git history + working tree (LOCAL). Returns:
      committed: whether vault secret values were ever committed (git log -S pickaxe — exact, value not sent)
      tracked:   token pattern in files tracked at HEAD (git grep)
      gitignore_gaps: un-ignored secret files"""
    repo = os.path.expanduser(repo or "")
    if not _is_git(repo): return {"git": False}
    vals = _vault_vals(d); committed = []
    for v in list(vals)[:200]:   # ponytail: pickaxe per value is expensive; 200 cap (multi-thread to extend)
        if v.startswith("-"): continue
        out = _git(repo, "log", "--all", "--oneline", "-S", v, timeout=20)
        commits = [ln.split(" ", 1) for ln in out.splitlines() if ln.strip()][:10]
        if commits:
            committed.append({"mask": mask(v), "commits": [
                {"hash": c[0], "subject": (c[1] if len(c) > 1 else "")[:80]} for c in commits]})
    tracked = []
    for ln in _git(repo, "grep", "-nIE", _TOKEN_RX_ALL.pattern, timeout=20).splitlines()[:200]:
        m = re.match(r"([^:]+):(\d+):(.*)", ln)
        if not m: continue
        hit = _TOKEN_RX_ALL.search(m.group(3))
        tracked.append({"file": m.group(1), "line": int(m.group(2)), "mask": mask(hit.group(0)) if hit else ""})
    return {"git": True, "committed": committed, "tracked": tracked, "gitignore_gaps": gitignore_gaps(repo)}
def log_scan(root, d=None):
    """Search *.log files in the directory/repo (and under logs/) for vault secrets + token patterns. Masked."""
    root = os.path.expanduser(root or ""); vault_vals = _vault_vals(d); out = []
    if not os.path.isdir(root): return out
    for dp, dns, fns in os.walk(root):
        dns[:] = [x for x in dns if x not in _SKIP_DIRS and x != ".git"]
        in_logdir = os.path.basename(dp).lower() in ("logs", "log")
        for fn in fns:
            if not (_LOG_FILE_RX.search(fn) or (in_logdir and not fn.startswith("."))): continue
            fp = os.path.join(dp, fn)
            try:
                if os.path.getsize(fp) > 20 * 1024 * 1024: continue     # 20MB cap
                for i, line in enumerate(open(fp, errors="ignore")):
                    hits = _hist_hits(line, vault_vals)
                    if not hits: continue
                    red = line
                    for v in sorted({h[0] for h in hits}, key=len, reverse=True): red = red.replace(v, mask(v))
                    out.append({"file": os.path.relpath(fp, root), "line": i + 1, "mask": mask(hits[0][0]),
                                "cmd": red.strip()[:240], "reasons": sorted({h[1] for h in hits}),
                                "severity": "high" if any(h[2] == "high" for h in hits) else "med"})
                    if len(out) >= 500: return out
            except Exception: continue
    return out
def git_remediation(repo, files=None, names=None):
    """GUIDE to purging secrets from git history (command doc tailored to repo/file/names).
    SECURITY: concealer NEVER runs these commands; it only produces the text — the user applies it."""
    rname = os.path.basename(os.path.abspath(os.path.expanduser(repo or ""))) or "your-repo"
    files = [f for f in (files or []) if f][:30]; names = [n for n in (names or []) if n][:50]
    fl = files or ["path/to/secret-file"]
    q = lambda s: shlex.quote(s)
    md = [f"# Remediation — purge committed secrets from `{rname}`", "",
          "> ⚠️ concealer never rewrites git history for you. Read each command, then run it yourself.", "",
          "## 1. Rotate first (do this before anything else)", "",
          "A secret is compromised the instant it is pushed — rewriting history does **not** un-leak it. "
          "Rotate/revoke the affected credential at its provider now."]
    if names: md += ["", "Affected secrets:"] + [f"- `{n}`" for n in names]
    md += ["", "## 2. Stop tracking + ignore", "", "```sh"] + \
          [f"git rm --cached {q(f)}" for f in fl] + \
          ["printf '%s\\n' " + " ".join(q(f) for f in fl) + " >> .gitignore",
           "git commit -m 'stop tracking secrets; add to .gitignore'", "```",
           "", "## 3. Purge from history — git filter-repo (recommended)", "",
           "```sh", "# pip install git-filter-repo"] + \
          [f"git filter-repo --path {q(f)} --invert-paths" for f in fl] + \
          ["```", "", "## 4. Alternative — BFG Repo-Cleaner", "", "```sh",
           "# https://rtyley.github.io/bfg-repo-cleaner/",
           f"bfg --delete-files {os.path.basename(fl[0])}",
           "git reflog expire --expire=now --all && git gc --prune=now --aggressive", "```",
           "", "## 5. Force-push + coordinate", "", "```sh", "git push --force --all",
           "git push --force --tags", "```",
           "Teammates must re-clone (old clones still hold the secret). Ask GitHub/GitLab support to expire cached views.",
           "", "## 6. Prevent recurrence", "",
           "Add pre-commit scanning: **gitleaks**, **TruffleHog**, or **detect-secrets**. "
           "Validate whether a leaked key is still live with **keyhacks** (locally, never via an online paster).", ""]
    return "\n".join(md)

def _native_pickdir():
    """Local app: open the OS folder-picker dialog (Finder/Explorer), return the chosen path (cancel=None)."""
    try:
        if sys.platform == "darwin":
            scr = 'try\nPOSIX path of (choose folder with prompt "concealer")\nend try'
            r = subprocess.run(["osascript", "-e", scr], capture_output=True, text=True, timeout=300)
            return (r.stdout.strip() or None)
        if sys.platform.startswith("linux"):
            if shutil.which("zenity"):
                r = subprocess.run(["zenity", "--file-selection", "--directory"], capture_output=True, text=True, timeout=300)
                return (r.stdout.strip() or None)
            if shutil.which("kdialog"):
                r = subprocess.run(["kdialog", "--getexistingdirectory", os.path.expanduser("~")], capture_output=True, text=True, timeout=300)
                return (r.stdout.strip() or None)
            return None
        if sys.platform.startswith("win"):
            ps = ('Add-Type -AssemblyName System.Windows.Forms;'
                  '$d=New-Object System.Windows.Forms.FolderBrowserDialog;'
                  'if($d.ShowDialog() -eq "OK"){[Console]::Out.Write($d.SelectedPath)}')
            r = subprocess.run(["powershell", "-NoProfile", "-Command", ps], capture_output=True, text=True, timeout=300)
            return (r.stdout.strip() or None)
    except Exception:
        return None
    return None
def _import_cands(d, cands, scope):
    n_new = n_skip = 0
    for c in cands:
        ident = dict(scope); ident["name"] = c["name"]
        hit = next((e for e in secs(d) if matches(e, ident)), None)
        if hit and str(hit["fields"].get("value")) == c["value"]: n_skip += 1; continue
        if hit:
            hit["fields"]["value"] = c["value"]; hit["updated"] = now_iso(); n_new += 1
        else:
            origin = "scan-history" if c["source"] in (".zsh_history", ".bash_history") else "scan-folder"
            e = dict(ident); e["type"] = "api_key"; e["fields"] = {"value": c["value"]}
            e["tags"] = ["scan", origin]; e["url"] = ""; e["notes"] = f"scan: {c['source']}"
            secs(d).append(norm(e)); n_new += 1
    return n_new, n_skip

# ---------------- portable export / import (encrypted with age -p) ----------------
def merge_secrets(d, incoming, mode="overwrite"):
    """Merge incoming records into the vault. Conflict (same id, else name+scope) resolution via `mode`:
    overwrite=update the existing one (default), skip=leave existing untouched, duplicate=always add as a new record."""
    n_new = n_upd = n_skip = 0
    for i, raw in enumerate(incoming):
        e = norm(dict(raw)); cur = None
        if mode != "duplicate":   # duplicate: skip match lookup, always add new
            cur = by_id(d, e["id"])
            if not cur:   # a different machine may produce a different id → also try by name+scope
                ident = {dim: e.get(dim, "") for dim in DIMS}; ident["name"] = e["name"]
                cur = next((x for x in secs(d) if matches(x, ident)), None)
        if cur:
            if mode == "skip": n_skip += 1; continue
            for k in ["name", "type"] + DIMS + ["tags", "url", "notes", "fields"]: cur[k] = e[k]
            cur["updated"] = now_iso(); n_upd += 1
        else:
            if by_id(d, e["id"]):   # in duplicate mode id may collide → generate a fresh id
                e["id"] = hashlib.sha1((e["name"] + label(e) + now_iso() + str(i)).encode()).hexdigest()[:12]
            secs(d).append(e); n_new += 1
    return n_new, n_upd, n_skip

def export_bundle(pw):
    """Decrypt the vault to JSON and encrypt with the master password via 'age -p'; returns portable .age bytes."""
    d = load()
    plain = json.dumps({"secrets": secs(d), "exported": now_iso()}).encode()
    # ponytail: age wants a file argument → plaintext briefly in temp (0600), removed in finally
    tin = tempfile.NamedTemporaryFile(delete=False); tout = tin.name + ".age"
    try:
        tin.write(plain); tin.close(); os.chmod(tin.name, 0o600)
        rc, err = _age_pw(["-p", "-o", tout, tin.name], pw, confirm=True)
        if rc: raise RuntimeError(err.strip() or "age encrypt error")
        return open(tout, "rb").read()
    finally:
        for p in (tin.name, tout):
            try: os.remove(p)
            except OSError: pass

def import_bundle(pw, blob, mode="overwrite"):
    """Decrypt an 'age -p'-encrypted bundle and merge into the vault. pw = the bundle's (source machine) password.
    mode: conflict resolution (overwrite|skip|duplicate) → passed to merge_secrets."""
    tin = tempfile.NamedTemporaryFile(delete=False, suffix=".age"); tout = tin.name + ".json"
    try:
        tin.write(blob); tin.close()
        rc, err = _age_pw(["-d", "-o", tout, tin.name], pw)
        if rc: raise RuntimeError("wrong password or corrupt bundle")
        data = json.loads(open(tout, errors="ignore").read())
        d = load(); nn, nu, ns = merge_secrets(d, data.get("secrets", []), mode); save(d)
        return nn, nu, ns
    finally:
        for p in (tin.name, tout):
            try: os.remove(p)
            except OSError: pass

# ---------------- backup (.cerbak) ----------------
# .cerbak = the whole vault encrypted with age -p using the BACKUP PASSWORD (different from master, required).
# Its output is opaque binary age ciphertext → opening the file reveals nothing; restore via import.
# Auto-backup: the backup password is wrapped to the vault's age PUBLIC key (keys/backup.json); only the age
# private key (in memory while unlocked) decrypts it. The plaintext backup password is NEVER written to disk.
CER_EXT = ".cerbak"    # old backups had a ".cer" extension; the import path ignores extension, rotation recognizes both
_DEFAULT_BACKUP = {"enabled": False, "interval_h": 24, "dir": "", "keep": 7, "last": None}

def make_backup(pw):
    """Encrypt the whole vault with the backup password → opaque .cer bytes (same format as export_bundle → import restores it)."""
    return export_bundle(pw)

def _cer_name():
    return "concealer-" + now_iso()[:19].replace(":", "").replace("-", "") + CER_EXT   # concealer-YYYYMMDDTHHMMSS.cerbak

def _recipient():
    return open(PUBKEY).read().strip() if os.path.exists(PUBKEY) else None

def _age_enc_pub(data):
    """Encrypt data to an age recipient (public key) → ciphertext bytes. Passwordless; decrypting needs the age private key."""
    rcpt = _recipient()
    if not rcpt: raise RuntimeError("no pubkey (run init first)")
    tin = tempfile.NamedTemporaryFile(delete=False)
    tin.write(data if isinstance(data, bytes) else data.encode()); tin.close(); tout = tin.name + ".age"
    try:
        r = subprocess.run(["age", "-r", rcpt, "-o", tout, tin.name], capture_output=True, text=True)
        if r.returncode: raise RuntimeError(r.stderr.strip() or "age recipient enc error")
        return open(tout, "rb").read()
    finally:
        for p in (tin.name, tout):
            try: os.remove(p)
            except OSError: pass

def _age_dec_priv(blob):
    """Decrypt a recipient-encrypted blob with the age private key. Hardened vault: key is passed via stdin (-i -), never WRITTEN to disk."""
    key = _unlock_key()                       # hardened: text ; legacy: None (plaintext file KEY)
    tin = tempfile.NamedTemporaryFile(delete=False, suffix=".age"); tin.write(blob); tin.close()
    try:
        if key: r = subprocess.run(["age", "-d", "-i", "-", tin.name], input=key, capture_output=True, text=True)
        else:   r = subprocess.run(["age", "-d", "-i", KEY, tin.name], capture_output=True, text=True)
        if r.returncode: raise RuntimeError(r.stderr.strip() or "age decrypt error")
        return r.stdout
    finally:
        try: os.remove(tin.name)
        except OSError: pass

def _backup_cfg():
    d = dict(_DEFAULT_BACKUP)
    if os.path.exists(BACKUPCFG):
        try: d.update(json.load(open(BACKUPCFG)))
        except Exception: pass
    return d
def _save_backup_cfg(d):
    os.makedirs(os.path.dirname(BACKUPCFG), exist_ok=True)
    with open(BACKUPCFG, "w") as f: json.dump(d, f)
    os.chmod(BACKUPCFG, 0o600)

def _rotate_backups(d, keep):
    """Delete the oldest .cer files in the folder, keep the newest `keep` (names are time-sorted)."""
    if keep <= 0: return
    p = os.path.expanduser(d)
    try: files = sorted(os.path.join(p, n) for n in os.listdir(p) if n.endswith((".cer", ".cerbak")))
    except OSError: return
    for old in files[:-keep]:
        try: os.remove(old)
        except OSError: pass

def _write_cer(pw, dst_dir, keep=7):
    blob = make_backup(pw); p = os.path.expanduser(dst_dir); os.makedirs(p, exist_ok=True)
    path = os.path.join(p, _cer_name())
    with open(path, "wb") as f: f.write(blob)
    os.chmod(path, 0o600); _rotate_backups(dst_dir, keep)
    return path

def run_auto_backup():
    """Write a .cer to the configured folder (unwraps the wrapped password with the age key). Ignores enabled/interval (manual/cron)."""
    c = _backup_cfg()
    if not c.get("dir") or not c.get("pw_wrapped"): raise RuntimeError("auto-backup not configured (folder+password)")
    pw = _age_dec_priv(base64.b64decode(c["pw_wrapped"]))
    path = _write_cer(pw, c["dir"], c.get("keep", 7))
    c["last"] = now_iso(); _save_backup_cfg(c)
    audit("backup_auto", source="auto", detail=os.path.basename(path))
    return path

def _auto_backup_maybe():
    """If auto-backup is on and the interval has elapsed, silently write a .cer (unlock/activity hook; errors swallowed)."""
    c = _backup_cfg()
    if not c.get("enabled") or not c.get("pw_wrapped") or not c.get("dir"): return
    if c.get("last"):
        try:
            if datetime.now(timezone.utc) - datetime.fromisoformat(c["last"]) < timedelta(hours=c.get("interval_h", 24)):
                return
        except Exception: pass
    try: run_auto_backup()
    except Exception: pass

# ---------------- age (expect) / master password ----------------
def _age_pw(args, passphrase, confirm=False):
    if sys.platform.startswith("win"):
        # Windows has no `expect`/`/dev/tty`; drive age through a ConPTY (pywinpty).
        # This branch is never taken on Unix — the expect path below is unchanged.
        import concealer_win
        return concealer_win.age_pw(args, passphrase, confirm)
    # NOTE: age's encrypt prompt is "Enter passphrase (leave empty ...): " → its end is NOT "passphrase:".
    # So match on the "passphrase" substring (present in both the encrypt and decrypt prompts),
    # otherwise every call times out and waits 30s.
    lines = ["set timeout 30", "set pw $env(CONCEALER_PW)",
             "spawn age " + " ".join(shlex.quote(a) for a in args),
             'expect "passphrase"', 'send -- "$pw\\r"']
    if confirm: lines += ['expect "passphrase"', 'send -- "$pw\\r"']
    lines += ["expect eof", "catch wait rc", "exit [lindex $rc 3]"]
    r = subprocess.run(["expect", "-c", "\n".join(lines)],
                       env=dict(os.environ, CONCEALER_PW=passphrase), capture_output=True, text=True)
    return r.returncode, r.stdout + r.stderr

def _ask_new_password():
    while True:
        p1 = getpass.getpass("Set master password: ")
        if not p1: print("cannot be empty."); continue
        if p1 != getpass.getpass("Repeat: "): print("did not match, try again."); continue
        return p1

_SN, _SR, _SP = 16384, 8, 1
def _scrypt(pw, salt, n=_SN, r=_SR, p=_SP):
    return hashlib.scrypt(pw.encode(), salt=salt, n=n, r=r, p=p, dklen=32, maxmem=64 * 1024 * 1024)
def write_master(pw):
    salt = os.urandom(16)
    with open(MASTER, "w") as f:
        json.dump({"salt": salt.hex(), "hash": _scrypt(pw, salt).hex(), "n": _SN, "r": _SR, "p": _SP}, f)
    os.chmod(MASTER, 0o600)
def verify_master(pw):
    if os.path.exists(MASTER):
        m = json.load(open(MASTER))
        return hmac.compare_digest(_scrypt(pw, bytes.fromhex(m["salt"]), m["n"], m["r"], m["p"]),
                                   bytes.fromhex(m["hash"]))
    if os.path.exists(BACKUP):
        rc, _ = _age_pw(["--decrypt", "-o", os.devnull, BACKUP], pw)
        if rc == 0: write_master(pw)
        return rc == 0
    return True

# ---------------- recovery codes ----------------
# Purpose: (a) recover the vault if the master password is forgotten, (b) stop someone who has ONLY
# obtained the master password from changing it and taking over (passwd 2nd factor).
# Code = ~100-bit; each code wraps the age key (age -p) → any one recovers. On disk only the
# scrypt hash + code-wrapped encrypted key are stored, the PLAINTEXT code is NEVER kept.
RECV_N = 8
_RC_ALPHA = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"   # low confusion (no 0/O/1/I); 256%32==0 → no bias
def _gen_codes(n=RECV_N):
    def one():
        raw = "".join(_RC_ALPHA[b % 32] for b in os.urandom(20))   # 20*5 = 100 bit
        return "-".join(raw[i:i+4] for i in range(0, 20, 4))
    return [one() for _ in range(n)]

def _age_unwrap(blob, pw):
    """Decrypt the code-wrapped key ciphertext, return the age key text."""
    tin = tempfile.NamedTemporaryFile(delete=False, suffix=".age"); tout = tin.name + ".key"
    try:
        tin.write(blob); tin.close()
        rc, err = _age_pw(["-d", "-o", tout, tin.name], pw)
        if rc: raise RuntimeError("wrong or corrupt code")
        return open(tout, "rb").read()
    finally:
        for p in (tin.name, tout):
            try: os.remove(p)
            except OSError: pass

def write_recovery(codes, keytext):
    salt = os.urandom(16)
    entries = [{"h": _scrypt(c, salt).hex(), "blob": base64.b64encode(_age_enc_bytes(keytext, c)).decode()} for c in codes]
    with open(RECOVERY, "w") as f: json.dump({"salt": salt.hex(), "codes": entries}, f)
    os.chmod(RECOVERY, 0o600)

def _recovery_data():
    return json.load(open(RECOVERY)) if os.path.exists(RECOVERY) else None
def recovery_index(code):
    d = _recovery_data()
    if not d: return None
    h = _scrypt(code.strip(), bytes.fromhex(d["salt"])).hex()
    for i, e in enumerate(d["codes"]):
        if hmac.compare_digest(e["h"], h): return i
    return None
def consume_recovery(i):
    d = _recovery_data(); d["codes"].pop(i)
    with open(RECOVERY, "w") as f: json.dump(d, f)
    os.chmod(RECOVERY, 0o600)

def _print_codes(codes):
    bar = "=" * 58
    print("\n" + bar)
    print("  RECOVERY CODES — THIS SCREEN IS SHOWN ONCE")
    print("  Save these SOMEWHERE ELSE (password manager/vault/paper).")
    print("  Not stored in PLAINTEXT on disk; if you do not copy them now they are gone.")
    print("   - if you FORGET the master password:  concealer recover")
    print("   - CHANGING the master password also needs a code (passwd)")
    print(bar)
    for i, c in enumerate(codes, 1): print(f"   {i}.  {c}")
    print(bar + "\n")

# ---------------- key-at-rest + unlock tokens ----------------
# the age private key is not kept on disk as PLAINTEXT: only the BACKUP (master-pw encrypted) and
# optional token/code-wrapped copies. Given to sops from memory via SOPS_AGE_KEY.
# Token = long-lived (agent) or TTL'd (human/cli); its VALUE lives only in the client env
# (CONCEALER_TOKEN), on disk only the scrypt hash + token-wrapped key are stored.
TOKEN_TTL = 8 * 3600   # default lifetime for human/cli token (agent token never expires)
def _age_enc_bytes(data, pw):
    """Encrypt arbitrary bytes with pw via age -p, return ciphertext."""
    tin = tempfile.NamedTemporaryFile(delete=False)
    tin.write(data if isinstance(data, bytes) else data.encode()); tin.close()
    tout = tin.name + ".age"
    try:
        rc, err = _age_pw(["-p", "-o", tout, tin.name], pw, confirm=True)
        if rc: raise RuntimeError(err.strip() or "age enc error")
        return open(tout, "rb").read()
    finally:
        for p in (tin.name, tout):
            try: os.remove(p)
            except OSError: pass

def key_from_master(pw):
    """Decrypt BACKUP with the master pw → age key text (None on wrong pw). Both verifies and yields the key."""
    if not os.path.exists(BACKUP): return None
    tout = tempfile.NamedTemporaryFile(delete=False, suffix=".key").name
    try:
        rc, _ = _age_pw(["-d", "-o", tout, BACKUP], pw)
        return open(tout).read() if rc == 0 else None
    finally:
        try: os.remove(tout)
        except OSError: pass

def _write_backup(keytext, pw):
    """Encrypt keytext with pw and write to BACKUP (needs no plaintext key file)."""
    tk = tempfile.NamedTemporaryFile(delete=False, suffix=".key")
    tk.write(keytext.encode() if isinstance(keytext, str) else keytext); tk.close()
    tmp = BACKUP + ".new"
    try:
        if os.path.exists(tmp): os.remove(tmp)
        rc, err = _age_pw(["-p", "-o", tmp, tk.name], pw, confirm=True)
        if rc: raise RuntimeError(err.strip() or "backup could not be created")
        os.replace(tmp, BACKUP)
    finally:
        try: os.remove(tk.name)
        except OSError: pass

def _agents_data():
    return json.load(open(AGENTS)) if os.path.exists(AGENTS) else None
def _save_agents(d):
    with open(AGENTS, "w") as f: json.dump(d, f)
    os.chmod(AGENTS, 0o600)
def _agent_labels():
    d = _agents_data()
    return [t["label"] for t in (d["tokens"] if d else []) if t.get("source") == "agent" and not t.get("revoked")]

def mint_token(keytext, label, source="agent", ttl=None):
    """Mint a new unlock token: store hash + token-wrapped key, return the token VALUE (once)."""
    d = _agents_data() or {"salt": os.urandom(16).hex(), "tokens": []}
    tok = _secrets.token_urlsafe(24)
    kh = _scrypt(tok, bytes.fromhex(d["salt"])).hex()
    blob = base64.b64encode(_age_enc_bytes(keytext, tok)).decode()
    exp = (datetime.now(timezone.utc) + timedelta(seconds=ttl)).isoformat(timespec="seconds") if ttl else None
    d["tokens"] = [t for t in d["tokens"] if t["label"] != label]   # same label → replace
    d["tokens"].append({"label": label, "source": source, "kh": kh, "blob": blob,
                        "created": now_iso(), "expires": exp, "revoked": False})
    _save_agents(d); return tok

def _find_token(tok):
    """Find a valid/unexpired token record (including label/source); None if absent."""
    d = _agents_data()
    if not d or not tok: return None
    kh = _scrypt(tok.strip(), bytes.fromhex(d["salt"])).hex()
    now = now_iso()
    for e in d["tokens"]:
        if e.get("revoked"): continue
        if e.get("expires") and now > e["expires"]: continue
        if hmac.compare_digest(e["kh"], kh): return e
    return None

def resolve_token(tok):
    """Resolve a valid/unexpired token → age key text; None if absent."""
    e = _find_token(tok)
    if not e: return None
    try: return _age_unwrap(base64.b64decode(e["blob"]), tok.strip()).decode()
    except Exception: return None

def _mcp_agent():
    """Resolve CONCEALER_TOKEN and return (label, source); (None, None) if not registered."""
    e = _find_token(os.environ.get("CONCEALER_TOKEN", ""))
    return (e["label"], e.get("source")) if e else (None, None)

def _cli_actor():
    """CLI rate_gate key = the valid token's label (else 'cli'). AI agents using the
    CLI are subject to the same disclosure quota as via MCP; a human uses web/TUI without limit."""
    label, _ = _mcp_agent()
    return label or "cli"

def revoke_token(label):
    d = _agents_data()
    if not d: return 0
    n = len(d["tokens"]); d["tokens"] = [] if label == "all" else [t for t in d["tokens"] if t["label"] != label]
    _save_agents(d); return n - len(d["tokens"])

def _unlock_key():
    """Resolve the age private key text. In order: memory cache → CONCEALER_TOKEN →
    (plaintext file in an old vault=None) → master pw on the TTY. Error if not found."""
    global _KEY_CACHE
    if _KEY_CACHE: return _KEY_CACHE
    tok = os.environ.get("CONCEALER_TOKEN")
    if tok:
        k = resolve_token(tok)
        if k: _KEY_CACHE = k; return k
        sys.exit("CONCEALER_TOKEN invalid/expired/revoked. New token: concealer unlock")
    if os.path.exists(KEY): return None   # legacy (non-hardened) vault: sops reads from the file
    if sys.stdin.isatty():                # interactive: unlock with master pw (cache for a single process)
        k = key_from_master(getpass.getpass("Master password (unlock): "))
        if not k: sys.exit("wrong password.")
        _KEY_CACHE = k; return k
    sys.exit("locked: no CONCEALER_TOKEN. Human: 'concealer unlock', agent: 'concealer agent register <name>'.")

def detect(dim):
    if dim in ("repo", "project"):
        r = subprocess.run(["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True)
        return os.path.basename(r.stdout.strip()) if r.returncode == 0 else os.path.basename(os.getcwd())
    return ""

# ---------------- setup / password ----------------
def _tty(): return sys.stdout.isatty()
def _c(s, code):   # ANSI color only on a TTY; plain text when piped/redirected
    return f"\033[{code}m{s}\033[0m" if _tty() else s
def _banner():
    # init/onboarding header: UTF-8 block logo (accent amber), otherwise plain. "er" brand accent:
    # the whole logo is printed in the accent color (coloring a single letter in block art isn't practical).
    utf8 = (sys.stdout.encoding or "").lower().startswith("utf")
    art = _LOGO if utf8 else ["concealer"]
    for ln in art: print(_c(ln, "38;5;203;1"))   # amber, bold
    print(_c("  local-only secret manager  ·  SOPS + age  ·  v" + VERSION, "38;5;203"))
    print()

def init(force=False):
    if os.path.exists(KEY) and not force:
        sys.exit(f"already set up: {KEY}\nto reinstall: concealer init --force")
    _banner()
    print(_c("Setting up vault…", "1") + f"  ({HOME})\n")
    print(_c("Step 1/2", "38;5;203;1") + " — set a master password (this opens the vault; if lost you need a recovery code).")
    os.makedirs(os.path.dirname(KEY), exist_ok=True)
    if sys.platform.startswith("win"):
        import concealer_win; concealer_win.secure_dir(os.path.dirname(KEY))  # user-only ACL (chmod 0600 is a no-op on Windows)
    for f in (KEY, PUBKEY, BACKUP, MASTER):
        if os.path.exists(f): os.remove(f)
    r = subprocess.run(["age-keygen", "-o", KEY], capture_output=True, text=True)
    if r.returncode: sys.exit(f"age-keygen error: {r.stderr.strip()}")
    os.chmod(KEY, 0o600)
    pub = next(w for w in (r.stderr + r.stdout).split() if w.startswith("age1"))
    with open(PUBKEY, "w") as f: f.write(pub + "\n")
    keytext = open(KEY).read()
    pw = _ask_new_password()
    rc, err = _age_pw(["-p", "-o", BACKUP, KEY], pw, confirm=True)
    if rc: sys.exit(f"password-protected backup could not be created: {err.strip()}")
    write_master(pw); _audit_key()
    for f in (RECOVERY, AUDITHEAD, AGENTS):   # on a force reinstall clean up old leftovers
        if os.path.exists(f): os.remove(f)
    codes = _gen_codes(); write_recovery(codes, keytext)
    with open(SOPSCFG, "w") as f:
        f.write("creation_rules:\n  - path_regex: \\.enc\\.(yaml|yml|json)$\n" f'    age: "{pub}"\n')
    save({"secrets": []}); audit("init", source="cli")
    with open(os.path.join(HOME, ".gitignore"), "w") as f:
        f.write("keys/age-key.txt\nkeys/audit.key\nkeys/audit.head\nkeys/recovery.json\nkeys/agents.json\nkeys/ratestate.json\nkeys/backup.json\n")
    print("\n" + _c("Step 2/2", "38;5;203;1") + " — save the recovery codes (below, shown ONLY once).")
    _print_codes(codes)
    # key-at-rest: remove the age private key from disk (only the encrypted BACKUP remains).
    os.remove(KEY); audit("harden", source="cli")
    tok = mint_token(keytext, "cli", "cli", ttl=TOKEN_TTL)
    print("\n" + _c("✓ Done.", "38;5;40;1") + f"  KEY-AT-REST on — the age private key is no longer plaintext on disk.")
    print(f"  public key: {pub}\n")
    print(_c("Next steps:", "1"))
    print("  1) Add the CLI token to your shell (~%dh valid, refresh: eval \"$(concealer unlock)\"):" % (TOKEN_TTL // 3600))
    print("     " + _c(f"export CONCEALER_TOKEN={tok}", "38;5;203"))
    print("  2) Web UI (opens with the master password):   " + _c("concealer web", "38;5;203"))
    print("  3) Add your first secret:   " + _c('concealer set --name GITHUB_TOKEN --project myapp ghp_...', "38;5;203"))
    print("  4) Grant an AI agent access (MCP):   " + _c("concealer agent register <name>", "38;5;203"))
    print("  5) Set up automatic backups:   " + _c("concealer backup --dir <folder>", "38;5;203") + "  (details: web → Settings)")

def passwd():
    if not os.path.exists(BACKUP): sys.exit("run 'concealer init' first.")
    # 1st factor: current master password (decrypt both verifies and yields the key).
    keytext = key_from_master(getpass.getpass("Current master password: "))
    if not keytext: sys.exit("current password is wrong.")
    # 2nd factor: a valid recovery code (consumed) — master pw alone must not take over.
    d = _recovery_data()
    if not d or not d["codes"]:
        sys.exit("a recovery code is required but none are left/available.\n"
                 "  first generate a new set:  concealer recovery   (asks for the master password)")
    i = recovery_index(getpass.getpass("Recovery code (2nd factor): "))
    if i is None: sys.exit("invalid recovery code.")
    pw = _ask_new_password()
    _write_backup(keytext, pw); write_master(pw); consume_recovery(i); audit("passwd", source="cli")
    left = len(_recovery_data()["codes"])
    print(f"master password updated. recovery codes left: {left}"
          + ("\n  UYARI: kod kalmadi → 'concealer recovery' ile yenile." if left == 0 else ""))

def recover():
    """Master password forgotten: decrypt the age key with a recovery code + set a new password."""
    if not os.path.exists(RECOVERY): sys.exit("no recovery.json; this vault was not set up with recovery codes.")
    code = getpass.getpass("Recovery code: ")
    i = recovery_index(code)
    if i is None: sys.exit("invalid recovery code.")
    blob = base64.b64decode(_recovery_data()["codes"][i]["blob"])
    try: keytext = _age_unwrap(blob, code.strip()).decode()
    except Exception as ex: sys.exit(f"decrypt error: {ex}")
    if os.path.exists(KEY):                       # legacy vault: write the missing key file back
        with open(KEY, "w") as f: f.write(keytext)
        os.chmod(KEY, 0o600)
    pw = _ask_new_password()
    _write_backup(keytext, pw); write_master(pw); consume_recovery(i); audit("recover", source="cli")
    left = len(_recovery_data()["codes"])
    print(f"recovered; new master password set. recovery codes left: {left}"
          + ("\n  UYARI: kod kalmadi → 'concealer recovery' ile yenile." if left == 0 else ""))
    if not os.path.exists(KEY):
        global _KEY_CACHE; _KEY_CACHE = keytext
        print("  export CONCEALER_TOKEN=%s" % mint_token(keytext, "cli", "cli", ttl=TOKEN_TTL))

def recovery_regen():
    """Generate a new recovery code set (old ones become invalid). Requires the master password."""
    if not os.path.exists(BACKUP): sys.exit("run 'concealer init' first.")
    keytext = key_from_master(getpass.getpass("Master password: "))
    if not keytext: sys.exit("wrong password.")
    codes = _gen_codes(); write_recovery(codes, keytext); audit("recovery_regen", source="cli")
    _print_codes(codes)

def unlock():
    """Human: mint a TTL'd CLI token with the master pw. stdout=ONLY the export line (for eval)."""
    if not os.path.exists(BACKUP): sys.exit("run 'concealer init' first.")
    keytext = key_from_master(getpass.getpass("Master password: "))
    if not keytext: sys.exit("wrong password.")
    tok = mint_token(keytext, "cli", "cli", ttl=TOKEN_TTL); audit("unlock", source="cli")
    sys.stderr.write(f"CLI token generated (~{TOKEN_TTL // 3600}h). Run:  eval \"$(concealer unlock)\"\n")
    print(f"export CONCEALER_TOKEN={tok}")

def agent_cmd(a):
    """Agent tokens: register/list/revoke. An agent registers once, never asked for a password again."""
    sub = a[0] if a else "list"
    if sub == "register":
        if len(a) < 2: sys.exit("usage: concealer agent register <name>")
        keytext = key_from_master(getpass.getpass("Master password: "))
        if not keytext: sys.exit("wrong password.")
        tok = mint_token(keytext, a[1], "agent", ttl=None); audit("agent_register", key=a[1], source="cli")
        print(f"agent '{a[1]}' registered (long-lived, revocable token).")
        print("Add to the MCP server env (e.g. .mcp.json / claude config):")
        print(f'  "env": {{ "CONCEALER_TOKEN": "{tok}" }}')
        print(f"  (revoke: concealer agent revoke {a[1]})")
    elif sub == "revoke":
        if len(a) < 2: sys.exit("usage: concealer agent revoke <name|all>")
        n = revoke_token(a[1]); audit("agent_revoke", key=a[1], source="cli"); print(f"revoked tokens: {n}")
    else:
        d = _agents_data()
        if not d or not d["tokens"]: print("no tokens."); return
        for t in d["tokens"]:
            st = "revoked" if t["revoked"] else (("expires " + t["expires"]) if t["expires"] else "suresiz")
            print(f"  {t['label']:16} {t['source']:6} {st:30} created {t['created']}")

def harden():
    """Migrate an old (plaintext-key) vault to key-at-rest: remove age-key.txt, mint a CLI token."""
    if not os.path.exists(BACKUP): sys.exit("run 'concealer init' first.")
    if not os.path.exists(KEY): print("already hardened (no plaintext key on disk)."); return
    keytext = key_from_master(getpass.getpass("Master password: "))
    if not keytext: sys.exit("wrong password.")
    os.remove(KEY); audit("harden", source="cli")
    gi = os.path.join(HOME, ".gitignore")           # an old init may not have written the agents.json line
    try:
        cur = open(gi).read() if os.path.exists(gi) else ""
        miss = [ln for ln in ("keys/agents.json", "keys/recovery.json", "keys/audit.head", "keys/ratestate.json", "keys/backup.json") if ln not in cur]
        if miss:
            with open(gi, "a") as f: f.write("\n".join(miss) + "\n")
    except OSError: pass
    tok = mint_token(keytext, "cli", "cli", ttl=TOKEN_TTL)
    print("hardened: the age private key was removed from disk (only the encrypted backup remains).")
    print(f"  export CONCEALER_TOKEN={tok}   (~{TOKEN_TTL // 3600}h; agent: concealer agent register <name>)")

# ---------------- injection ----------------
def collect_secrets(target, d=None, names=None):
    """Collect secrets matching the target scopes as {ENV_KEY: value} (most specific wins).
    names: if given (set/list of secret names), inject ONLY those — least-privilege, so a
    command pulls just the secret it needs instead of the whole scope."""
    d = d or load(); chosen = {}
    names = set(names) if names else None
    for e in secs(d):
        if names is not None and e["name"] not in names: continue
        spec = 0; ok = True
        for dim in DIMS:
            ev = e[dim]
            if ev == "": continue
            if target.get(dim, "") == ev: spec += 1
            else: ok = False; break
        if not ok: continue
        if e["name"] not in chosen or spec > chosen[e["name"]][0]:
            chosen[e["name"]] = (spec, e)
    kv = {}
    for name, (sp, e) in chosen.items():
        if e["type"] == "api_key": kv[name] = str(e["fields"].get("value", ""))
        else:
            for fn, fv in e["fields"].items(): kv[f"{name}_{fn.upper()}"] = str(fv)
    return kv, d, [e for sp, e in chosen.values()]

def inject_env(target, source="cli", actor=None, command=None, gate=None, names=None):
    """If gate=<agent label> is given, injection passes through rate_gate(atomic) — if the quota is exceeded
    it returns (None, d, note) and NO secret is injected. If command is given, the executed command
    (redacted) is added to the audit detail. names: restrict injection to these secret names."""
    kv, d, recs = collect_secrets(target, names=names)
    if gate is not None:
        allowed, note = rate_gate(gate, recs, atomic=True)
        if recs and not allowed:
            audit("inject_denied", key=",".join(sorted(e["name"] for e in recs)), source=source, detail=label(target), actor=actor)
            return None, d, note
    env = dict(os.environ); env.update(kv)
    if kv:
        det = label(target)
        if command is not None: det = redact(f"{det} ↦ {command}", d)   # command goes to audit; the secret value doesn't leak
        audit("inject", key=",".join(sorted(kv)), source=source, detail=det, actor=actor)
    return env, d, note if gate is not None else None

# ---------------- deploy / render to target formats ----------------
DEPLOY_TARGETS = ["dotenv", "export", "docker", "json", "k8s", "aws-secrets", "aws-ssm", "github"]
def deploy_render(scope, target):
    """Render secrets matching the scope to text for the selected deploy target (produces commands/manifest)."""
    kv, d, _ = collect_secrets(scope)
    stem = "-".join(scope.get(x) for x in DIMS if scope.get(x)) or "concealer"
    return _render_target(kv, stem, target)

def deploy_render_one(e, target):
    """Render a single secret for the selected deploy target (row-level Deploy)."""
    if e["type"] == "api_key":
        kv = {e["name"]: str(e["fields"].get("value", ""))}
    else:
        kv = {f'{e["name"]}_{fn.upper()}': str(fv) for fn, fv in e["fields"].items()}
    stem = "-".join(e.get(x) for x in DIMS if e.get(x)) or e["name"]
    return _render_target(kv, stem, target)

def _render_target(kv, stem, target):
    if target == "dotenv": return "\n".join(f"{k}={v}" for k, v in kv.items())
    if target == "export": return "\n".join(f"export {k}={shlex.quote(v)}" for k, v in kv.items())
    if target == "docker": return " ".join(f"-e {k}={shlex.quote(v)}" for k, v in kv.items())
    if target == "json":   return json.dumps(kv, indent=2)
    if target == "k8s":
        body = "\n".join(f"  {k}: {base64.b64encode(v.encode()).decode()}" for k, v in kv.items())
        return f"apiVersion: v1\nkind: Secret\nmetadata:\n  name: {stem}\ntype: Opaque\ndata:\n{body}"
    if target == "aws-secrets":
        return "\n".join(
            f"aws secretsmanager put-secret-value --secret-id {stem}/{k} --secret-string {shlex.quote(v)} \\\n"
            f"  || aws secretsmanager create-secret --name {stem}/{k} --secret-string {shlex.quote(v)}"
            for k, v in kv.items())
    if target == "aws-ssm":
        return "\n".join(f"aws ssm put-parameter --name /{stem}/{k} --type SecureString --value {shlex.quote(v)} --overwrite"
                         for k, v in kv.items())
    if target == "github":
        return "\n".join(f"gh secret set {k} --body {shlex.quote(v)}" for k, v in kv.items())
    return "unknown target"

def redact(text, d):
    for e in secs(d):
        for fn, fv in e["fields"].items():
            if fv and rec_field_secret(e, fn):   # record-aware: redact override + embedded-credential values too
                text = text.replace(str(fv), "***REDACTED***")
    return text

# ---------------- TUI (curses) ----------------
# ANSI-Shadow "CONCEALER" banner (big) + a compact fallback for narrow terminals.
_LOGO = [
    " ██████╗ ██████╗ ███╗   ██╗ ██████╗███████╗ █████╗ ██╗     ███████╗██████╗ ",
    "██╔════╝██╔═══██╗████╗  ██║██╔════╝██╔════╝██╔══██╗██║     ██╔════╝██╔══██╗",
    "██║     ██║   ██║██╔██╗ ██║██║     █████╗  ███████║██║     █████╗  ██████╔╝",
    "██║     ██║   ██║██║╚██╗██║██║     ██╔══╝  ██╔══██║██║     ██╔══╝  ██╔══██╗",
    "╚██████╗╚██████╔╝██║ ╚████║╚██████╗███████╗██║  ██║███████╗███████╗██║  ██║",
    " ╚═════╝ ╚═════╝ ╚═╝  ╚═══╝ ╚═════╝╚══════╝╚═╝  ╚═╝╚══════╝╚══════╝╚═╝  ╚═╝",
]

_TUI_UTF8 = True   # are curses UTF-8 box-drawing chars safe (by locale, tui() sets this)

def tui():
    try: import curses, locale  # noqa: F401  (curses is Unix/macOS only)
    except ImportError: sys.exit("TUI needs the 'curses' module (Unix/macOS). Windows: pip install windows-curses.")
    # Try a UTF-8 locale for text (so chars like value/… encode correctly).
    utf8_locale = False
    for loc in ("", "en_US.UTF-8", "C.UTF-8", "UTF-8", "en_US.utf8"):
        try: locale.setlocale(locale.LC_ALL, loc)
        except locale.Error: continue
        try: enc = locale.nl_langinfo(locale.CODESET) or ""
        except Exception: enc = ""
        if enc.upper().replace("-", "") == "UTF8": utf8_locale = True; break
    # Use UTF-8 for box-drawing (┌│─) — BUT VS Code's integrated terminal (xterm.js) drops these
    # glyphs; draw ASCII (+-|) there. Force on/off manually with CONCEALER_TUI_ASCII=1/0.
    global _TUI_UTF8
    ascii_env = os.environ.get("CONCEALER_TUI_ASCII")
    if ascii_env in ("1", "true", "yes"): _TUI_UTF8 = False
    elif ascii_env in ("0", "false", "no"): _TUI_UTF8 = True
    else: _TUI_UTF8 = utf8_locale and os.environ.get("TERM_PROGRAM") != "vscode"
    d = load()                       # TTY unlock (master pw prompt) happens here, before curses grabs the screen
    try:
        curses.wrapper(_tui_main, d)
    finally:
        # Defense-in-depth: curses already uses alternate-screen (?1049), but with iTerm2's
        # "Save lines to scrollback in alternate screen mode" setting on, TUI frames get
        # copied into the real scrollback → on exit secrets stay visible when scrolled up.
        # Not trusting the terminal setting, WE clear scrollback + screen on exit.
        # \033[3J = clear scrollback (xterm/iTerm2), \033[2J = screen, \033[H = cursor home.
        try:
            with open("/dev/tty", "w") as tty:
                tty.write("\033[H\033[2J\033[3J"); tty.flush()
        except Exception:
            sys.stdout.write("\033[H\033[2J\033[3J"); sys.stdout.flush()

def _tui_glyphs(u8):
    if u8:
        return {"tl": "┌", "tr": "┐", "bl": "└", "br": "┘", "h": "─", "v": "│",
                "lt": "├", "rt": "┤", "cur": "▸ ", "on": "◉ ", "off": "○ ", "ud": "↕"}
    return {"tl": "+", "tr": "+", "bl": "+", "br": "+", "h": "-", "v": "|",
            "lt": "+", "rt": "+", "cur": "> ", "on": "* ", "off": ". ", "ud": "^"}

# In ASCII mode (VS Code etc.) fold every drawn string down to ASCII in one place — no Unicode escapes.
_ASCII_MAP = str.maketrans({
    "┌": "+", "┐": "+", "└": "+", "┘": "+", "├": "+", "┤": "+", "─": "-", "│": "|",
    "·": "-", "…": "..", "•": "*", "▸": ">", "◉": "*", "○": ".", "↕": "^", "▏": "|",
    "→": "->", "←": "<-", "↑": "^", "↓": "v", "—": "-", "–": "-", "’": "'", "“": '"', "”": '"'})

def _tui_palette(curses):
    """Color palette — web 'Covert' theme (redaction-red accent). For bars/selection
    A_REVERSE|acc is used: theme-independently it gives a red background + dark text
    (plain COLOR_BLACK stayed invisible on acc in some terminals)."""
    if curses.has_colors():
        curses.start_color(); curses.use_default_colors()
        acc_c = 203 if curses.COLORS >= 256 else curses.COLOR_RED   # redaction red (was amber 214)
        gray  = 245 if curses.COLORS >= 256 else curses.COLOR_WHITE
        curses.init_pair(1, acc_c, -1); curses.init_pair(2, gray, -1)
        curses.init_pair(3, curses.COLOR_GREEN, -1); curses.init_pair(4, curses.COLOR_RED, -1)
        curses.init_pair(5, curses.COLOR_CYAN, -1)
        curses.init_pair(6, gray, curses.COLOR_BLACK)   # OPAQUE black background for the modal
        acc, mut, ok, bad, typ = (curses.color_pair(i) for i in range(1, 6))
        fill = curses.color_pair(6)
    else:
        acc = curses.A_BOLD; mut = curses.A_DIM; ok = 0; bad = curses.A_BOLD; typ = 0; fill = 0
    return {"acc": acc, "mut": mut, "ok": ok, "bad": bad, "typ": typ, "fill": fill,
            "bar": acc | curses.A_REVERSE, "sel": acc | curses.A_REVERSE, "norm": 0}

_TYP_COLOR = {"api_key": "typ", "access_token": "typ", "jwt": "typ", "oauth": "typ",
              "database": "acc", "server": "acc", "ssh_key": "acc", "certificate": "acc",
              "website": "ok", "login": "ok", "wifi": "ok", "membership": "ok"}
CLIP_CLEAR = 45   # seconds — the copied value is cleared from the clipboard after this (same behavior as web)

# ---- TUI language table: EN source → TR translation (separate from web I18N, curses-specific).
# L() returns the en key unchanged if not in the map → "en" is the identity language, only "tr" is kept.
_TUI_TR = {
    "terminal too small": "terminal cok kucuk", "[masked]": "[maskeli]", "[REVEALED]": "[ACIK]",
    "%d/%d secrets   %s ": "%d/%d sir   %s ", "Search: ": "Arama: ",
    "type s to search": "aramak icin s", "  filters: %d": "  filtre: %d",
    "1·Filters": "1·Filtreler", "2·Secrets": "2·Sirlar", "3·Details": "3·Detaylar",
    "NAME": "AD", "TYPE": "TIP", "(no matches)": "(eslesme yok)", "no secret selected": "sir secili degil",
    "j/k pick · Enter/m reveal · c copy": "j/k sec · Enter/m goster · c kopyala",
    "m: reveal all": "m: tumunu goster", "m: hide all": "m: tumunu gizle",
    "Tab panels · s search · m reveal · a add · e edit · d del · r rotate · c copy · ? help · L lang · q quit":
        "Tab paneller · s ara · m goster · a ekle · e duzenle · d sil · r dondur · c kopyala · ? yardim · L dil · q cikis",
    # messages
    "hid %s": "%s gizlendi", "revealed %s": "%s gosterildi", "hidden": "gizlendi",
    "revealed all": "tumu gosterildi", "filters cleared": "filtreler temizlendi",
    "nothing to copy": "kopyalanacak sey yok", "no %s field": "%s alani yok", "reloaded": "yeniden yuklendi",
    "copied %s → clipboard (clears in %ds)": "%s panoya kopyalandi (%dsn sonra silinir)",
    "no clipboard tool found (pbcopy / xclip / wl-copy)": "pano araci yok (pbcopy / xclip / wl-copy)",
    "no secret field to rotate": "dondurulecek gizli alan yok", "rotate cancelled": "dondurme iptal edildi",
    "rotated: %s": "donduruldu: %s", "Rotate '%s' → random value?": "'%s' rastgele degere dondurulsun mu?",
    "confirm": "onay", "language: %s": "dil: %s",
    # add/edit/delete
    "New secret — name": "Yeni sir — ad", "add cancelled": "ekleme iptal edildi", "Type": "Tip",
    "bad field name (looks like a secret value)": "gecersiz alan adi (bir secret degeri gibi gorunuyor)",
    "field name  (blank = done)": "alan adi  (bos = bitti)", "%s value": "%s degeri",
    "project (optional)": "proje (istege bagli)", "env (optional)": "ortam (istege bagli)",
    "added: %s": "eklendi: %s", "edit cancelled (not saved)": "duzenleme iptal (kaydedilmedi)",
    "updated: %s": "guncellendi: %s", "new field name": "yeni alan adi", "Edit: %s": "Duzenle: %s",
    "+ add field": "+ alan ekle", "» save & close": "» kaydet & kapat", "edit value": "degeri duzenle",
    "set visibility": "gorunurluk ayarla", "cancel": "iptal", "Delete '%s'  (%s) ?": "'%s' silinsin mi?  (%s)",
    "delete cancelled": "silme iptal edildi", "deleted: %s": "silindi: %s",
    "masked — partial  (sk-D…xy)": "maskeli — kismi  (sk-D…xy)",
    "masked — full  (••••••••)": "maskeli — tam  (••••••••)", "visible — plain text": "gorunur — duz metin",
    "'%s' visibility": "'%s' gorunurluk",
    # help window
    "conceal·er  —  keyboard": "conceal·er  —  klavye",
    "cycle panels   (1 / 2 / 3 jump to panel)": "panelleri degistir   (1 / 2 / 3 panele atla)",
    "move focus between panels": "paneller arasi odak tasi",
    "move   ·   g/G top/bottom   ·   PgUp/Dn, Ctrl-D/U page": "gez   ·   g/G bas/son   ·   PgUp/Dn, Ctrl-D/U sayfa",
    "search (live)   ·   Esc clears / exits": "ara (canli)   ·   Esc temizler / cikar",
    "Filters: toggle a facet   ·   x: clear all filters": "Filtreler: bir oge sec   ·   x: tum filtreleri temizle",
    "in Details panel": "Detaylar panelinde",
    "↑↓/j k pick a field   ·   Enter/m reveal that field": "↑↓/j k alan sec   ·   Enter/m o alani goster",
    "Secrets panel: reveal / hide ALL fields of the record": "Sirlar paneli: kaydin TUM alanlarini goster / gizle",
    "copy value · username · url  (clipboard clears in 45s)": "kopyala deger · kullanici · url  (pano 45sn'de silinir)",
    "add · edit · delete · rotate (random)": "ekle · duzenle · sil · dondur (rastgele)",
    "reload vault   ·   force redraw": "kasayi yukle   ·   yeniden ciz",
    "toggle language (TR / EN)": "dili degistir (TR / EN)",
    "this help   ·   q / Ctrl-Q quit": "bu yardim   ·   q / Ctrl-Q cikis",
}

def _tui_main(scr, data):
    import curses
    curses.curs_set(0); scr.keypad(True); scr.timeout(-1)
    try: curses.set_escdelay(25)
    except Exception: pass
    C = _tui_palette(curses)
    G = _tui_glyphs(_TUI_UTF8)   # ASCII box-drawing if no UTF-8 (e.g. VS Code terminal with C locale)
    st = {"d": data, "rows": [], "sel": 0, "top": 0, "term": "",
          "mode": "list", "focus": "list", "fsel": 0, "ftop": 0, "dtop": 0,
          "shown": set(), "dsel": 0,   # shown: field names revealed in this record · dsel: detail field cursor
          "active": {}, "fitems": [], "msg": "", "quit": False,
          "show_filters": True, "show_detail": True,
          # lang: saved preference → else OS locale (LANG=tr* → tr) → else en. Changes with 'L'.
          "lang": CFG.get("lang") or ("tr" if os.environ.get("LANG", "").lower().startswith("tr") else "en")}
    def L(s, *a):   # translate the EN source to the active language; fill %s/%d templates
        if st["lang"] == "tr": s = _TUI_TR.get(s, s)
        return (s % a) if a else s
    def FOCUS():   # focus ring by visible panels (in a narrow terminal don't focus a hidden panel)
        r = ["list"]
        if st["show_filters"]: r.insert(0, "filters")
        if st["show_detail"]: r.append("detail")
        return r

    # ---- draw helpers ----
    def put(y, x, s, attr=0, maxw=None):
        if y < 0 or x < 0: return
        s = str(s)
        if not _TUI_UTF8:   # ASCII mode (VS Code etc.): fold all Unicode in one place
            s = s.translate(_ASCII_MAP).encode("ascii", "replace").decode("ascii")
        if maxw is not None:
            if maxw <= 0: return
            s = s[:maxw]
        try: scr.addstr(y, x, s, attr)
        except (curses.error, UnicodeError, ValueError): pass   # edge/last-cell + non-UTF8 locale glyph

    def box(y, x, h, w, attr=0, title="", tattr=None):
        if h < 2 or w < 2: return
        put(y, x, G["tl"] + G["h"] * (w - 2) + G["tr"], attr)
        put(y + h - 1, x, G["bl"] + G["h"] * (w - 2) + G["br"], attr)
        for i in range(1, h - 1):
            put(y + i, x, G["v"], attr); put(y + i, x + w - 1, G["v"], attr)
        if title:
            put(y, x + 2, (G["rt"] + " " + title + " " + G["lt"])[:w - 4], tattr if tattr is not None else attr)

    # ---- filter / data ----
    def facets():
        d = st["d"]; out = []
        for dim, lbl in [("type", "Type"), ("collection", "Collection"), ("project", "Project"),
                         ("environment", "Env"), ("tenant", "Tenant"), ("repo", "Repo"), ("tags", "Tags")]:
            vals = {}
            for e in secs(d):
                if dim == "tags":
                    for tg in e.get("tags", []): vals[tg] = vals.get(tg, 0) + 1
                elif e.get(dim): vals[e[dim]] = vals.get(e[dim], 0) + 1
            if not vals: continue
            out.append(("hdr", dim, lbl, 0))
            for v in sorted(vals): out.append(("item", dim, v, vals[v]))
        return out

    def apply():
        d = st["d"]; sel = {}
        for dim in DIMS:
            if st["active"].get(dim): sel[dim] = ",".join(sorted(st["active"][dim]))
        typ = ",".join(sorted(st["active"].get("type", []))) or None
        tag = ",".join(sorted(st["active"].get("tags", []))) or None
        st["rows"] = filt(d, sel or None, tag, st["term"] or None, typ)
        cs = st["active"].get("collection")   # collection facet multi-select: OR incl. sub-path
        if cs: st["rows"] = [e for e in st["rows"]
                             if any(e.get("collection", "") == c or e.get("collection", "").startswith(c + "/") for c in cs)]
        st["sel"] = min(st["sel"], max(0, len(st["rows"]) - 1)); st["top"] = 0
        reset_detail()
        st["fitems"] = facets()   # always keep current (so toggle works in a narrow terminal too)
        st["fsel"] = min(st["fsel"], max(0, sum(1 for f in st["fitems"] if f[0] == "item") - 1))

    def reset_detail():   # on selection change: everything re-masked, cursor/scroll to top
        st["shown"] = set(); st["dsel"] = 0; st["dtop"] = 0

    def reload_(msg=""):
        try: st["d"] = load()
        except SystemExit as ex: st["msg"] = str(ex); return
        apply(); st["msg"] = msg

    def cur():
        return st["rows"][st["sel"]] if st["rows"] else None

    def nfilters():
        return sum(len(v) for v in st["active"].values())

    # ---- clipboard ----
    def clip(val, what):
        if sys.platform.startswith("win"):
            try:
                subprocess.run(["clip"], input=str(val), text=True, check=True)  # clip.exe reads stdin
                # auto-clear after CLIP_CLEAR seconds via a detached, hidden PowerShell
                subprocess.Popen(["powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command",
                                  f"Start-Sleep {CLIP_CLEAR}; Set-Clipboard -Value ' '"],
                                 stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
                e = cur()
                if e: audit("copy", key=e["name"], source="tui", detail=what)
                st["msg"] = L("copied %s → clipboard (clears in %ds)", what, CLIP_CLEAR)
            except Exception:
                st["msg"] = L("no clipboard tool found (pbcopy / xclip / wl-copy)")
            return
        for tool in (["pbcopy"], ["xclip", "-selection", "clipboard"], ["wl-copy"]):
            if shutil.which(tool[0]):
                try:
                    subprocess.run(tool, input=str(val), text=True, check=True)
                    subprocess.Popen(["sh", "-c", f"sleep {CLIP_CLEAR}; printf '' | {' '.join(tool)}"],
                                     stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
                    e = cur()
                    if e: audit("copy", key=e["name"], source="tui", detail=what)
                    st["msg"] = L("copied %s → clipboard (clears in %ds)", what, CLIP_CLEAR); return
                except Exception: pass
        st["msg"] = L("no clipboard tool found (pbcopy / xclip / wl-copy)")

    def secret_field(e):
        return next((f for f in e["fields"] if rec_field_secret(e, f)), None)

    def copy_value():
        e = cur()
        if not e: return
        if st["focus"] == "detail" and e["fields"]:   # in the detail panel: copy the selected field
            fn = list(e["fields"].keys())[max(0, min(st["dsel"], len(e["fields"]) - 1))]
            clip(e["fields"][fn], fn); return
        f = secret_field(e) or next(iter(e["fields"]), None)
        if f: clip(e["fields"][f], f)
        else: st["msg"] = L("nothing to copy")

    def copy_field(names, what):
        e = cur()
        if not e: return
        f = next((k for k in e["fields"] if k in names), None)
        if f: clip(e["fields"][f], f)
        else: st["msg"] = L("no %s field", what)

    # ---- modals ----
    def fill(y, x, h, w, attr):   # opaque background (so the area behind the modal isn't transparent)
        for r in range(h): put(y + r, x, " " * w, attr)

    def prompt(msg, default="", secret=False):
        curses.curs_set(1); H, W = scr.getmaxyx()
        bw = min(72, W - 4); by = H // 2 - 1; bx = (W - bw) // 2; buf = default
        while True:
            fill(by, bx, 3, bw, C["fill"]); box(by, bx, 3, bw, C["acc"], msg, C["acc"] | curses.A_BOLD)
            put(by + 1, bx + 1, " " * (bw - 2), C["fill"])
            shown = ("•" * len(buf)) if secret else buf
            put(by + 1, bx + 2, shown[-(bw - 4):], maxw=bw - 4)
            scr.refresh(); c = scr.getch()
            if c in (10, 13): curses.curs_set(0); return buf
            if c == 27: curses.curs_set(0); return None
            if c in (curses.KEY_BACKSPACE, 127, 8): buf = buf[:-1]
            elif 32 <= c < 127: buf += chr(c)

    def confirm(msg, danger=True):
        H, W = scr.getmaxyx(); bw = min(72, W - 4); a = C["bad"] if danger else C["acc"]
        fill(H // 2 - 1, (W - bw) // 2, 3, bw, C["fill"])
        box(H // 2 - 1, (W - bw) // 2, 3, bw, a, L("confirm"), a | curses.A_BOLD)
        put(H // 2, (W - bw) // 2 + 2, (msg + "  (y/N)")[:bw - 4], a | curses.A_BOLD); scr.refresh()
        return scr.getch() in (ord("y"), ord("Y"))

    def pick(title, labels, start=0):
        if not labels: return None
        i = min(start, len(labels) - 1)
        while True:
            H, W = scr.getmaxyx()
            bw = min(max(len(title) + 6, max(len(x) for x in labels) + 6), W - 4)
            bh = min(len(labels) + 2, H - 4); y = (H - bh) // 2; x = (W - bw) // 2
            fill(y, x, bh, bw, C["fill"]); box(y, x, bh, bw, C["acc"], title, C["acc"] | curses.A_BOLD)
            vis = bh - 2; topo = 0
            if len(labels) > vis: topo = max(0, min(i - vis // 2, len(labels) - vis))
            for r in range(vis):
                idx = topo + r
                if idx >= len(labels): break
                put(y + 1 + r, x + 1, (" " + labels[idx]).ljust(bw - 2),
                    C["sel"] if idx == i else C["fill"], bw - 2)
            scr.refresh(); c = scr.getch()
            if c in (27, ord("q")): return None
            if c in (10, 13): return i
            if c in (curses.KEY_DOWN, ord("j")): i = (i + 1) % len(labels)
            elif c in (curses.KEY_UP, ord("k")): i = (i - 1) % len(labels)
            elif c == ord("g"): i = 0
            elif c == ord("G"): i = len(labels) - 1

    def helpwin():
        rows = [
            ("Tab / Shift-Tab", L("cycle panels   (1 / 2 / 3 jump to panel)")),
            ("← → h l", L("move focus between panels")),
            ("↑ ↓ j k", L("move   ·   g/G top/bottom   ·   PgUp/Dn, Ctrl-D/U page")),
            ("s  /", L("search (live)   ·   Esc clears / exits")),
            ("Space / Enter", L("Filters: toggle a facet   ·   x: clear all filters")),
            ("", ""),
            (L("in Details panel"), L("↑↓/j k pick a field   ·   Enter/m reveal that field")),
            ("m", L("Secrets panel: reveal / hide ALL fields of the record")),
            ("c  u  w", L("copy value · username · url  (clipboard clears in 45s)")),
            ("a  e  d  r", L("add · edit · delete · rotate (random)")),
            ("M  C", L("move to collection · duplicate (copy)")),
            ("", ""),
            ("R  Ctrl-L", L("reload vault   ·   force redraw")),
            ("L", L("toggle language (TR / EN)")),
            ("?", L("this help   ·   q / Ctrl-Q quit")),
        ]
        H, W = scr.getmaxyx(); bw = min(74, W - 4); bh = min(len(rows) + 4, H - 2)
        y = (H - bh) // 2; x = (W - bw) // 2
        fill(0, 0, H, W, C["fill"])   # fill the whole screen with opaque black (so behind isn't transparent)
        box(y, x, bh, bw, C["acc"], L("conceal·er  —  keyboard"), C["acc"] | curses.A_BOLD)
        for r, (k, v) in enumerate(rows):
            if y + 2 + r >= y + bh - 1: break
            put(y + 2 + r, x + 3, k.ljust(18), C["acc"] | curses.A_BOLD, 18)
            put(y + 2 + r, x + 22, v, C["fill"] if not k and not v else (C["mut"]), bw - 24)
        scr.refresh(); scr.getch()

    def ask_visibility(fname, default_secret):
        # each field added/edited: choose hidden or visible + masking style
        opts = [L("masked — partial  (sk-D…xy)"), L("masked — full  (••••••••)"), L("visible — plain text")]
        idx = pick(L("'%s' visibility", fname), opts, 0 if default_secret else 2)
        if idx is None: return None
        if idx == 2: return {"secret": False}
        return {"secret": True, "mask": "full" if idx == 1 else "partial"}

    # ---- CRUD ----
    def add_flow():
        name = prompt(L("New secret — name"))
        if not name: st["msg"] = L("add cancelled"); return
        tkeys = sorted(TYPES.keys()); ti = pick(L("Type"), tkeys, tkeys.index("api_key"))
        if ti is None: st["msg"] = L("add cancelled"); return
        typ = tkeys[ti]
        e = {dim: "" for dim in DIMS}; e["name"] = name; e["type"] = typ; e["tags"] = []
        e["fields"] = {}; e["field_meta"] = {}
        tmpl = TYPES.get(typ)
        if tmpl:                                   # typed: template fields, with default secrecy
            for fn, kind in tmpl:
                v = prompt(f"{name} · {fn}", secret=(kind == "secret"))
                if v is None: st["msg"] = L("add cancelled"); return
                if v != "": e["fields"][fn] = v
        else:                                      # custom: free-form fields, ask visibility for each
            while True:
                fn = prompt(L("field name  (blank = done)"))
                if fn is None: st["msg"] = L("add cancelled"); return
                if not fn.strip(): break
                if _bad_field_name(fn): st["msg"] = L("bad field name (looks like a secret value)"); continue
                fm = ask_visibility(fn, field_is_secret("custom", fn))
                if fm is None: continue
                v = prompt(L("%s value", fn), secret=fm.get("secret", True))
                if v is None: continue
                e["fields"][fn] = v; e["field_meta"][fn] = fm
        proj = prompt(L("project (optional)")); env = prompt(L("env (optional)"))
        if proj: e["project"] = proj
        if env: e["environment"] = env
        secs(st["d"]).append(norm(e)); save(st["d"])
        audit("create", key=name, source="tui", detail=label(e)); reload_(L("added: %s", name))

    def edit_flow():
        e0 = cur()
        if not e0: return
        tgt = by_id(st["d"], e0["id"]); tgt.setdefault("field_meta", {}); i = 0
        while True:
            meta = [("name", tgt["name"]), ("type", tgt["type"]),
                    ("tags", ",".join(tgt["tags"])), ("collection", tgt.get("collection", "")),
                    ("project", tgt.get("project", "")),
                    ("environment", tgt.get("environment", "")), ("tenant", tgt.get("tenant", "")),
                    ("repo", tgt.get("repo", "")), ("url", tgt.get("url", "")),
                    ("notes", tgt.get("notes", ""))]
            fields = list(tgt["fields"].items())
            rows = meta + fields
            labels = []
            for j, (k, v) in enumerate(rows):
                if j >= len(meta) and rec_field_secret(tgt, k):
                    labels.append(f"{k:<14} • {rec_mask(tgt, k, v)}"[:62])
                else:
                    labels.append(f"{k:<14}   {v}"[:62])
            labels += [L("+ add field"), L("» save & close")]
            idx = pick(L("Edit: %s", tgt["name"]), labels, i)
            if idx is None: st["msg"] = L("edit cancelled (not saved)"); return
            i = idx
            if idx == len(labels) - 1:   # save
                tgt["updated"] = now_iso(); save(st["d"])
                audit("update", key=tgt["name"], source="tui"); reload_(L("updated: %s", tgt["name"])); return
            if idx == len(labels) - 2:   # add field
                fn = prompt(L("new field name"))
                if fn and fn.strip():
                    fm = ask_visibility(fn, field_is_secret(tgt["type"], fn))
                    if fm is not None:
                        tgt["fields"][fn] = prompt(L("%s value", fn), secret=fm.get("secret", True)) or ""
                        tgt["field_meta"][fn] = fm
                continue
            k = rows[idx][0]
            if idx < len(meta):   # split by section (not by name): a field name may collide with meta
                nv = prompt(f"{k}", tgt.get(k, "") if k != "tags" else ",".join(tgt["tags"]))
                if nv is None: continue
                if k == "tags": tgt["tags"] = [t for t in nv.split(",") if t.strip()]
                elif k == "type": tgt["type"] = nv if nv in TYPES else tgt["type"]
                elif k == "collection": tgt["collection"] = nv.strip().strip("/")
                else: tgt[k] = nv
            else:                 # field row: edit the value or the visibility
                act = pick(k, [L("edit value"), L("set visibility"), L("cancel")], 0)
                if act == 0:
                    nv = prompt(f"{k}", secret=rec_field_secret(tgt, k))
                    if nv is not None: tgt["fields"][k] = nv
                elif act == 1:
                    fm = ask_visibility(k, rec_field_secret(tgt, k))
                    if fm is not None: tgt["field_meta"][k] = fm

    def delete_flow():
        e = cur()
        if not e: return
        if not confirm(L("Delete '%s'  (%s) ?", e["name"], label(e))): st["msg"] = L("delete cancelled"); return
        tgt = by_id(st["d"], e["id"])
        if tgt: secs(st["d"]).remove(tgt); save(st["d"]); audit("delete", key=e["name"], source="tui")
        reload_(L("deleted: %s", e["name"]))

    def rotate_flow():
        e = cur()
        if not e: return
        f = secret_field(e)
        if not f: st["msg"] = L("no secret field to rotate"); return
        if not confirm(L("Rotate '%s' → random value?", e["name"])): st["msg"] = L("rotate cancelled"); return
        tgt = by_id(st["d"], e["id"]); tgt["fields"][f] = _secrets.token_urlsafe(32)
        tgt["updated"] = now_iso()
        if tgt.get("rotation", {}).get("every_days"): tgt["rotation"]["last"] = tgt["updated"]  # reset the clock
        save(st["d"]); audit("rotate", key=e["name"], source="tui")
        reload_(L("rotated: %s", e["name"]))

    def pick_collection(cur=""):
        # TUI equivalent of the web picker: existing collections + '(none)' + 'new…'. Returns: str path, or None (cancel).
        colls = sorted({e.get("collection", "") for e in secs(st["d"]) if e.get("collection")})
        opts = [L("(none)")] + colls + [L("+ new collection…")]
        start = colls.index(cur) + 1 if cur in colls else 0
        idx = pick(L("Collection"), opts, start)
        if idx is None: return None                       # cancel
        if idx == 0: return ""                            # (none)
        if idx == len(opts) - 1:                          # new one
            nv = prompt(L("new collection"), cur)
            return None if nv is None else nv.strip().strip("/")
        return opts[idx]                                  # existing selected

    def move_flow():   # move to collection (pick existing or type new; empty = out to root)
        e = cur()
        if not e: return
        nv = pick_collection(e.get("collection", ""))
        if nv is None: st["msg"] = L("move cancelled"); return
        tgt = by_id(st["d"], e["id"]); tgt["collection"] = nv; tgt["updated"] = now_iso()
        save(st["d"]); audit("update", key=e["name"], source="tui", detail=f"collection={tgt['collection'] or '-'}")
        reload_(L("moved: %s", e["name"]))

    def copy_flow():   # duplicate: a copy of the record (new id + new name + target collection)
        e = cur()
        if not e: return
        nn = prompt(L("Duplicate — new name"), e["name"] + "-copy")
        if not nn: st["msg"] = L("duplicate cancelled"); return
        nc = pick_collection(e.get("collection", ""))
        if nc is None: st["msg"] = L("duplicate cancelled"); return
        src = by_id(st["d"], e["id"])
        ne = norm({k: (list(v) if isinstance(v, list) else dict(v) if isinstance(v, dict) else v)
                   for k, v in src.items() if k != "id"})
        ne["name"] = nn; ne["collection"] = nc; ne["created"] = ne["updated"] = now_iso()
        secs(st["d"]).append(ne); save(st["d"])
        audit("create", key=nn, source="tui", detail=f"duplicate collection={ne['collection'] or '-'}")
        reload_(L("duplicated: %s", nn))

    # ---- screen drawing ----
    def draw():
        scr.erase(); H, W = scr.getmaxyx()
        if H < 8 or W < 30:
            put(0, 0, L("terminal too small"), C["bad"]); scr.refresh(); return
        # top bar (header)
        put(0, 0, " " * W, C["bar"])
        put(0, 1, "conceal", C["bar"] | curses.A_BOLD); put(0, 8, "er", C["bar"] | curses.A_BOLD | curses.A_UNDERLINE)
        home = HOME.replace(os.path.expanduser("~"), "~", 1)
        lockst = L("[REVEALED]") if st["shown"] else L("[masked]")
        right = L("%d/%d secrets   %s ", len(st["rows"]), len(secs(st["d"])), lockst)
        put(0, 11, f"· {home}", C["bar"], W - 12 - len(right))
        put(0, max(11, W - len(right)), right, C["bar"])
        # search row
        active_search = st["mode"] == "search"
        sattr = C["acc"] if active_search else C["mut"]
        fcount = L("  filters: %d", nfilters()) if nfilters() else ""
        put(1, 1, L("Search: "), C["acc"] if active_search else C["mut"])
        term = st["term"] + ("▏" if active_search else "")
        put(1, 9, term or (L("type s to search") if not active_search else ""),
            C["norm"] if st["term"] else C["mut"], W - 10 - len(fcount))
        put(1, W - len(fcount) - 1, fcount, C["mut"])
        # panel geometry
        by, bh = 2, H - 3
        show_filters = W >= 92
        show_detail = W >= 62
        st["show_filters"], st["show_detail"] = show_filters, show_detail
        if st["focus"] not in FOCUS(): st["focus"] = "list"   # reclaim focus from a hidden panel
        fw = 26 if show_filters else 0
        dw = max(30, (W - fw) * 2 // 5) if show_detail else 0
        lw = W - fw - dw
        fx, lx, dx = 0, fw, fw + lw
        draw_filters(fx, by, fw, bh) if show_filters else None
        draw_list(lx, by, lw, bh)
        draw_detail(dx, by, dw, bh) if show_detail else None
        draw_help(H, W)
        scr.refresh()

    def draw_filters(x, y, w, h):
        foc = st["focus"] == "filters"
        box(y, x, h, w, C["acc"] if foc else C["mut"], L("1·Filters"), (C["acc"] | curses.A_BOLD) if foc else C["mut"])
        items_idx = [i for i, f in enumerate(st["fitems"]) if f[0] == "item"]
        sel_line = items_idx[st["fsel"]] if items_idx and st["fsel"] < len(items_idx) else -1
        vis = h - 2
        if sel_line >= 0:
            if sel_line < st["ftop"]: st["ftop"] = sel_line
            if sel_line >= st["ftop"] + vis: st["ftop"] = sel_line - vis + 1
        for r in range(vis):
            li = st["ftop"] + r
            if li >= len(st["fitems"]): break
            kind, dim, val, cnt = st["fitems"][li]
            yy = y + 1 + r
            if kind == "hdr":
                put(yy, x + 1, val.upper()[:w - 2], C["acc"] | curses.A_BOLD, w - 2)
            else:
                on = val in st["active"].get(dim, set())
                mark = G["on"] if on else G["off"]
                text = f"{mark}{val}"
                cnts = str(cnt)
                if li == sel_line and foc:
                    put(yy, x + 1, " " * (w - 2), C["sel"])
                    put(yy, x + 1, text[:w - 3 - len(cnts)], C["sel"])
                    put(yy, x + w - 1 - len(cnts), cnts, C["sel"])
                else:
                    put(yy, x + 1, mark, C["acc"] if on else C["mut"])
                    put(yy, x + 3, val, (C["acc"] | curses.A_BOLD) if on else C["norm"], w - 4 - len(cnts))
                    put(yy, x + w - 1 - len(cnts), cnts, C["mut"])

    def draw_list(x, y, w, h):
        foc = st["focus"] == "list"
        box(y, x, h, w, C["acc"] if foc else C["mut"], L("2·Secrets"), (C["acc"] | curses.A_BOLD) if foc else C["mut"])
        rows = st["rows"]; iw = w - 2
        tcol = min(11, max(8, iw // 4)); scol = 0
        namew = max(1, iw - tcol - 1)   # a negative width raises ValueError in the format-spec; guard it
        put(y, x + 2 + 11, "", 0)
        # column headers
        put(y + 0, x + 2, "", 0)
        hdr = f"{L('NAME'):<{namew}} {L('TYPE'):<{tcol}}"
        # header on the first row, not inside the top line:
        vis = h - 3
        put(y + 1, x + 1, hdr[:iw], C["mut"] | curses.A_UNDERLINE)
        if not rows:
            put(y + 3, x + 2, L("(no matches)"), C["mut"])
        if st["sel"] < st["top"]: st["top"] = st["sel"]
        if st["sel"] >= st["top"] + (vis - 1): st["top"] = st["sel"] - (vis - 1) + 1
        for r in range(vis - 1):
            idx = st["top"] + r
            if idx >= len(rows): break
            e = rows[idx]; yy = y + 2 + r; sel = idx == st["sel"]
            nm = e["name"][:namew]; tp = e["type"][:tcol]
            if sel:
                put(yy, x + 1, " " * iw, C["sel"])
                put(yy, x + 1, (G["cur"] + nm)[:namew + 1], C["sel"] | curses.A_BOLD)
                put(yy, x + 2 + namew, tp, C["sel"])
            else:
                put(yy, x + 1, "  " + nm, C["norm"], namew + 1)
                tattr = C.get(_TYP_COLOR.get(e["type"], "mut"), C["mut"])
                put(yy, x + 2 + namew, tp, tattr, tcol)
        # scroll indicator
        if len(rows) > vis - 1:
            put(y + h - 1, x + w - 12, f"{G['rt']} {st['sel']+1}/{len(rows)} {G['lt']}", C["mut"])

    def draw_detail(x, y, w, h):
        foc = st["focus"] == "detail"
        box(y, x, h, w, C["acc"] if foc else C["mut"], L("3·Details"), (C["acc"] | curses.A_BOLD) if foc else C["mut"])
        e = cur(); iw = w - 3
        if not e:
            put(y + 2, x + 2, L("no secret selected"), C["mut"]); return
        # meta rows (non-selectable) — (key, value, color, fno=None)
        lines = [("name", e["name"], C["acc"] | curses.A_BOLD, None),
                 ("type", e["type"], C["typ"], None),
                 ("scope", label(e), C["norm"], None),
                 ("tags", ", ".join(e["tags"]) or "—", C["mut"], None)]
        if e.get("url"): lines.append(("url", e["url"], C["mut"], None))
        if e.get("notes"): lines.append(("notes", e["notes"], C["mut"], None))
        lines.append(("updated", e.get("updated", "")[:19].replace("T", " "), C["mut"], None))
        lines.append((None, None, None, None))   # separator
        # field rows (fno = which field; the detail cursor moves among these)
        fnames = list(e["fields"].keys())
        st["dsel"] = max(0, min(st["dsel"], len(fnames) - 1)) if fnames else 0
        fline = {}
        for fno, fn in enumerate(fnames):
            fv = e["fields"][fn]; sec = rec_field_secret(e, fn)
            revealed = (fn in st["shown"]) or not sec
            val = fv if revealed else rec_mask(e, fn, fv)
            col = C["ok"] if (revealed and sec) else (C["mut"] if sec else C["norm"])
            fline[fno] = len(lines); lines.append((fn, str(val), col, fno))
        vis = h - 3
        if foc and fnames:   # keep the selected field visible
            t = fline.get(st["dsel"], 0)
            if t < st["dtop"]: st["dtop"] = t
            if t >= st["dtop"] + vis: st["dtop"] = t - vis + 1
        st["dtop"] = max(0, min(st["dtop"], max(0, len(lines) - vis)))
        for r in range(vis):
            li = st["dtop"] + r
            if li >= len(lines): break
            k, val, col, fno = lines[li]; yy = y + 1 + r
            if k is None:
                put(yy, x + 1, G["h"] * iw, C["mut"]); continue
            if foc and fno is not None and fno == st["dsel"]:   # selected field row
                put(yy, x + 1, " " * iw, C["sel"])
                put(yy, x + 1, (G["cur"] + k)[:14], C["sel"] | curses.A_BOLD)
                put(yy, x + 1 + 14, val, C["sel"], iw - 14)
            else:
                put(yy, x + 1, (("  " if fno is not None else "") + k + "  ")[:14], C["acc"])
                put(yy, x + 1 + 14, val, col, iw - 14)
        hint = L("j/k pick · Enter/m reveal · c copy") if foc else (L("m: reveal all") if not st["shown"] else L("m: hide all"))
        foot = G["rt"] + " " + hint + " " + G["lt"]
        if len(lines) > vis: foot += f" {G['ud']}{st['dtop']+1}-{min(st['dtop']+vis, len(lines))}/{len(lines)}"
        put(y + h - 1, x + 2, foot, C["mut"], w - 4)

    def draw_help(H, W):
        y = H - 1
        put(y, 0, " " * W, C["bar"])
        hints = L("Tab panels · s search · m reveal · a add · e edit · d del · r rotate · c copy · ? help · L lang · q quit")
        put(y, 1, hints, C["bar"], W - 2)
        if st["msg"]:
            m = " " + st["msg"] + " "
            put(y, max(1, W - len(m) - 1), m[:W - 2], C["ok"] | curses.A_REVERSE)

    # ---- event loop ----
    def move_list(delta):   # on selection change everything masked + detail cursor/scroll to top
        st["sel"] = max(0, min(st["sel"] + delta, len(st["rows"]) - 1)); reset_detail()

    def reveal_field(e, fno):   # toggle a single field; if a hidden value is revealed drop to audit
        fnames = list(e["fields"].keys())
        if not fnames: return
        fn = fnames[max(0, min(fno, len(fnames) - 1))]
        if fn in st["shown"]: st["shown"].discard(fn); st["msg"] = L("hid %s", fn)
        else:
            st["shown"].add(fn); st["msg"] = L("revealed %s", fn)
            if rec_field_secret(e, fn): audit("get", key=e["name"], source="tui", detail=fn)

    def reveal_all(e):   # toggle all fields in the record
        if st["shown"]: st["shown"] = set(); st["msg"] = L("hidden")
        else:
            st["shown"] = set(e["fields"].keys()); st["msg"] = L("revealed all")
            if any(rec_field_secret(e, f) for f in e["fields"]):
                audit("get", key=e["name"], source="tui", detail="reveal all")

    def move_filter(delta):
        n = sum(1 for f in st["fitems"] if f[0] == "item")
        if n: st["fsel"] = max(0, min(st["fsel"] + delta, n - 1))

    def cycle_focus(step):
        ring = FOCUS()
        i = ring.index(st["focus"]) if st["focus"] in ring else ring.index("list")
        st["focus"] = ring[(i + step) % len(ring)]

    _splash(scr, C, curses)
    scr.clear()          # fully clear after the splash (no logo leftover)
    apply()
    while True:
        draw(); c = scr.getch()
        if c == curses.KEY_RESIZE:   # when a terminal like VS Code resizes: refresh ncurses + full redraw
            try: curses.update_lines_cols()
            except Exception: pass
            scr.clearok(True); continue
        H, W = scr.getmaxyx()
        # --- search mode ---
        if st["mode"] == "search":
            if c in (10, 13, curses.KEY_DOWN): st["mode"] = "list"; st["focus"] = "list"
            elif c == 27: st["mode"] = "list"; st["term"] = ""; apply()
            elif c in (curses.KEY_BACKSPACE, 127, 8): st["term"] = st["term"][:-1]; apply()
            elif c == 21: st["term"] = ""; apply()          # Ctrl-U
            elif 32 <= c < 127: st["term"] += chr(c); apply()
            continue
        st["msg"] = ""
        # --- global ---
        if c in (ord("q"), 17): break                       # q / Ctrl-Q
        elif c == ord("?"): helpwin()
        elif c in (ord("s"), ord("/")): st["mode"] = "search"   # s (TR keyboard friendly) or /
        elif c == ord("L"):                                  # change language (TR/EN) + persist
            st["lang"] = "tr" if st["lang"] == "en" else "en"
            CFG["lang"] = st["lang"]
            try: save_cfg(CFG)
            except Exception: pass
            st["msg"] = L("language: %s", st["lang"])
        elif c == 12: scr.clearok(True)                      # Ctrl-L: force full redraw
        elif c == 9: cycle_focus(1)                          # Tab
        elif c == curses.KEY_BTAB: cycle_focus(-1)           # Shift-Tab
        elif c == ord("1"): st["focus"] = "filters" if st["show_filters"] else st["focus"]
        elif c == ord("2"): st["focus"] = "list"
        elif c == ord("3"): st["focus"] = "detail" if st["show_detail"] else st["focus"]
        elif c in (curses.KEY_LEFT, ord("h")): cycle_focus(-1)
        elif c in (curses.KEY_RIGHT, ord("l")): cycle_focus(1)
        elif c == ord("m"):
            e = cur()
            if e: reveal_field(e, st["dsel"]) if st["focus"] == "detail" else reveal_all(e)
        elif c == ord("a"): add_flow()
        elif c == ord("e"): edit_flow()
        elif c == ord("d"): delete_flow()
        elif c == ord("r"): rotate_flow()
        elif c == ord("M"): move_flow()
        elif c == ord("C"): copy_flow()
        elif c == ord("c"): copy_value()
        elif c == ord("u"): copy_field({"username", "user", "member_id"}, "username")
        elif c == ord("w"): copy_field({"url", "web_url", "auth_url"}, "url")
        elif c == ord("R"): reload_(L("reloaded"))
        elif c == ord("x"):
            st["active"] = {}; apply(); st["msg"] = L("filters cleared")
        # --- focus-dependent ---
        elif st["focus"] == "filters":
            if c in (curses.KEY_DOWN, ord("j")): move_filter(1)
            elif c in (curses.KEY_UP, ord("k")): move_filter(-1)
            elif c == ord("g"): st["fsel"] = 0
            elif c == ord("G"): move_filter(10 ** 6)
            elif c in (10, 13, ord(" ")):
                items = [f for f in st["fitems"] if f[0] == "item"]
                if items and st["fsel"] < len(items):
                    _, dim, val, _ = items[st["fsel"]]
                    s = st["active"].setdefault(dim, set())
                    s.discard(val) if val in s else s.add(val)
                    if not s: st["active"].pop(dim, None)
                    apply()
        elif st["focus"] == "detail":   # in the detail panel j/k move between fields; Enter reveals
            e = cur(); nf = len(e["fields"]) if e else 0
            if c in (curses.KEY_DOWN, ord("j")): st["dsel"] = min(st["dsel"] + 1, max(0, nf - 1))
            elif c in (curses.KEY_UP, ord("k")): st["dsel"] = max(0, st["dsel"] - 1)
            elif c in (curses.KEY_NPAGE, 4): st["dsel"] = min(st["dsel"] + 5, max(0, nf - 1))
            elif c in (curses.KEY_PPAGE, 21): st["dsel"] = max(0, st["dsel"] - 5)
            elif c in (ord("g"), curses.KEY_HOME): st["dsel"] = 0
            elif c in (ord("G"), curses.KEY_END): st["dsel"] = max(0, nf - 1)
            elif c in (10, 13) and e: reveal_field(e, st["dsel"])
        else:   # list
            if c in (curses.KEY_DOWN, ord("j")): move_list(1)
            elif c in (curses.KEY_UP, ord("k")): move_list(-1)
            elif c == ord("g"): st["sel"] = 0; reset_detail()
            elif c == ord("G"): move_list(10 ** 6)
            elif c in (curses.KEY_NPAGE, 4): move_list((H - 6) or 5)     # PgDn / Ctrl-D
            elif c in (curses.KEY_PPAGE, 21): move_list(-((H - 6) or 5)) # PgUp / Ctrl-U
            elif c == curses.KEY_HOME: st["sel"] = 0; reset_detail()
            elif c == curses.KEY_END: move_list(10 ** 6)
            elif c in (10, 13): st["focus"] = "detail" if st["show_detail"] else "list"

def _splash(scr, C, curses):
    scr.erase(); H, W = scr.getmaxyx()
    if _TUI_UTF8 and W >= len(_LOGO[0]) + 2: art = _LOGO   # UTF-8 block logo
    else: art = ["c o n c e a l e r"]                      # ASCII fallback (non-UTF8 terminal)
    y0 = max(0, H // 2 - len(art) // 2 - 2)
    for i, ln in enumerate(art):
        x = max(0, (W - len(ln)) // 2)
        try: scr.addstr(y0 + i, x, ln[:W - 1], C["acc"] | curses.A_BOLD)
        except (curses.error, UnicodeError, ValueError): pass
    dot = "  ·  " if _TUI_UTF8 else "  -  "
    for txt, dy, at in [(f"local-only secret manager{dot}SOPS + age", len(art) + 1, C["mut"]),
                        (f"v{VERSION}", len(art) + 2, C["mut"]),
                        (f"press any key{dot}? for keyboard shortcuts", len(art) + 4, C["acc"])]:
        try: scr.addstr(y0 + dy, max(0, (W - len(txt)) // 2), txt, at)
        except (curses.error, UnicodeError, ValueError): pass
    scr.refresh(); scr.timeout(2500); scr.getch(); scr.timeout(-1)


# ---------------- CLI ----------------
def _flag(a, name):
    if name in a: i = a.index(name); v = a[i + 1]; del a[i:i + 2]; return v
    return None
def _sel(a):
    sel = {}
    for fl, dim in FLAGS.items():
        v = _flag(a, fl)
        if v is not None: sel[dim] = v
    return sel
def _print(rows):
    print(f"{'name':22} {'type':9} {'tenant':8} {'project':12} {'env':8} {'repo':14} tags")
    for e in rows:
        over = rotation_overdue(e); od = f"  ⟳{over}g gecikmis" if over is not None else ""
        col = f"  📁{e['collection']}" if e.get("collection") else ""
        print(f"{e['name']:22} {e['type']:9} {e['tenant']:8} {e['project']:12} "
              f"{e['environment']:8} {e['repo']:14} {','.join(e['tags'])}{col}{od}")

def _mkhelp():
    R = lambda syn, desc: f"  {syn:<46}{desc}"
    lines = [
        f"concealer {VERSION} — local-only secret manager (SOPS + age)   ·   alias: cer",
        "",
        "USAGE",
        "  concealer <command> [options]                 # short: cer <command>",
        "  most commands accept a scope: --tenant T  --project P  --env E  --repo R",
        "",
        "VAULT / KEY",
        R("init [--force]", "set up a new vault; prints master password, 8 recovery codes, CLI token"),
        R("unlock", "get a time-limited (TTL) token for a human, via the master password"),
        R("harden", "harden an old vault (remove the plaintext age key from disk)"),
        R("passwd", "change the master password (requires a recovery code)"),
        R("recover", "access the vault with a recovery code (forgotten password)"),
        R("recovery", "regenerate the recovery-code set"),
        R("agent register|list|revoke <name>", "long-lived, revocable token for agents"),
        "",
        "SECRETS",
        R("list [term] [scope] [--tag X] [--type T]", "list records (masked)"),
        R("search <term>", "search across all fields"),
        R("get --name N [scope]", "print the secret value (must match exactly one)"),
        R("set --name N [--type T] <value | k=v ...>", "create/update (add --tags a,b for tags)"),
        R("rm --name N [scope]", "delete a record"),
        R("rotate --name N [new-value]", "rotate the value (random if none given)"),
        R("rotate --due [--dry]", "rotate every record whose rotation policy is overdue (cron-friendly)"),
        R("mv --name N [scope] --to-collection C", "move a record into a collection"),
        R("cp --name N [scope] --to-collection C [--as NEW]", "copy a record into a collection (new id)"),
        R("dims", "show the scope (tenant/project/env/repo) values in use"),
        R("leaks", "find reused (shared) secret values"),
        R("history [--purge]", "find (and optionally delete) secrets left in shell history"),
        "",
        "SCAN / DEPLOY",
        R("scan <folder> [--import] [--history] [--envvars] [scope]", "extract secrets from .env/files/history/env vars"),
        R("policy [list|check]", "list reminder policies / report violations (check: exit 1 if any — cron-friendly)"),
        R("expose [scope] [--offline]", "check secret values against known breaches (HIBP k-anonymity — value never sent)"),
        R("gitscan <repo>", "find secrets in git history / tracked files / logs + .gitignore gaps (local, read-only)"),
        R("deploy --target <t> [scope]", "render: dotenv|export|docker|json|k8s|aws-secrets|aws-ssm|github"),
        R("run [scope] <cmd...>", "inject secrets into env and run a command (no values leak)"),
        "",
        "TRANSFER / AUDIT / INTERFACE",
        R("export [file]", "export a password-protected .age bundle"),
        R("import <bundle.age|.cerbak> [--mode=overwrite|skip|duplicate]", "import a bundle / restore a .cerbak backup (conflict handling)"),
        R("backup [--dir D]", "write a .cerbak vault backup (uses the Settings backup password; for cron/launchd)"),
        R("audit [verify]", "HMAC-chained audit log (verify: integrity check)"),
        R("audit anchor [--file F][--syslog][--webhook U]", "push head hash to an off-machine sink (tamper-evidence)"),
        R("config [key [val]]", "runtime settings (idle, confirm_ops)"),
        R("tui", "interactive terminal UI (arrow keys, search, add/del/reveal)"),
        R("web [port]", "serve the web UI + JSON API (default 8787)"),
        R("mcp", "MCP stdio server (for agents; needs CONCEALER_TOKEN)"),
        R("version | help", "version / this help"),
        "",
        "TYPES",
        "  api_key (default) · database · website · login · oauth · jwt · ssh_key · wifi · custom · …",
        "",
        "EXAMPLES",
        "  concealer init                                # run the 'export CONCEALER_TOKEN=…' line it prints",
        "  cer set --name openai --project web --env prod sk-DUMMY-123",
        "  cer get --name openai --project web --env prod",
        "  cer set --name pg --type database --project web \\",
        "      host=db.local port=5432 database=app username=app password=sk-DUMMY-pw",
        "  cer scan ./myrepo --history --import --project myrepo --env dev",
        "  cer run --project web --env prod npm run deploy",
        "  cer deploy --target dotenv --project web --env prod > .env",
        "  cer tui                                       # browse/search secrets in the terminal",
        "  cer web 8787                                  # http://localhost:8787 (unlock with master pw)",
        "  cer agent register my-agent                   # prints CONCEALER_TOKEN=…",
        "  CONCEALER_TOKEN=… cer mcp",
        "",
        "ENVIRONMENT",
        R("CONCEALER_HOME", "vault directory (default: ~/.concealer; repo checkout: next to the script)"),
        R("CONCEALER_TOKEN", "CLI/MCP unlock token (init/unlock/agent register produce it)"),
        R("CONCEALER_IDLE", "web session idle-lock timeout (seconds)"),
        "",
        "Docs: https://github.com/fxerkan/concealer",
    ]
    return "\n".join(lines)
HELP = _mkhelp()

# external dependencies; the brew formula installs these automatically, but give a clear error on manual install.
_DEPS = {"sops": "sops", "age": "age", "age-keygen": "age", "expect": "expect"}
def _preflight():
    _win = sys.platform.startswith("win")
    deps = {b: p for b, p in _DEPS.items() if not (_win and b == "expect")}  # Windows: pywinpty replaces expect
    missing = sorted({deps[b] for b in deps if not shutil.which(b)})
    if _win:
        try: import winpty  # noqa: F401 — the ConPTY driver for age (see concealer_win.py)
        except ImportError: missing.append("pywinpty (pip install pywinpty)")
    if not missing: return
    if _win:
        hint = "scoop install sops age  (and: pip install pywinpty)"
    else:
        hint = ("brew install " + " ".join(missing)) if shutil.which("brew") else \
               ("install with apt/dnf: " + ", ".join(missing))
    sys.exit(f"missing dependency: {', '.join(missing)}\ninstall: {hint}\n"
             "(with homebrew, 'brew install fxerkan/tap/concealer' installs everything together)")

def cli(argv):
    cmd = argv[0] if argv else "help"; a = argv[1:]
    if cmd in ("version", "--version", "-v"): return print("concealer " + VERSION)
    if cmd in ("help", "--help", "-h", ""): return print(HELP)
    _preflight()
    if cmd == "init": return init("--force" in a)
    if cmd == "passwd": return passwd()
    if cmd == "recover": return recover()
    if cmd == "recovery": return recovery_regen()
    if cmd == "unlock": return unlock()
    if cmd == "agent": return agent_cmd(a)
    if cmd == "harden": return harden()
    if cmd == "backup":
        # write a .cer using the (age-wrapped) password + folder from the auto-backup config — for cron/launchd.
        # Key access: CONCEALER_TOKEN (else master pw on a TTY). --dir temporarily overrides the folder.
        d = _flag(a, "--dir")
        if d: c = _backup_cfg(); c["dir"] = d; _save_backup_cfg(c)
        try: path = run_auto_backup()
        except Exception as ex: sys.exit(f"backup error: {ex}")
        return print(f"backup: {path}")
    if cmd == "tui": return tui()
    if cmd == "list":
        tag = _flag(a, "--tag"); coll = _flag(a, "--collection"); sel = _sel(a); typ = sel.pop("type", None)
        actor = _cli_actor()
        rows, note = rate_gate(actor, filt(load(), sel, tag, a[0] if a else None, typ, coll))
        audit("list", source="cli", detail=json.dumps(sel), actor=actor)
        _print(rows)
        if note: sys.stderr.write(note.lstrip("\n") + "\n")
    elif cmd == "search":
        actor = _cli_actor()
        rows, note = rate_gate(actor, filt(load(), term=a[0]))
        audit("search", source="cli", detail=a[0] if a else "", actor=actor)
        _print(rows)
        if note: sys.stderr.write(note.lstrip("\n") + "\n")
    elif cmd == "dims":
        d = load()
        for dim in DIMS:
            vals = sorted({e.get(dim, "") for e in secs(d) if e.get(dim)})
            print(f"{dim:12}: {', '.join(vals) or '-'}")
        colls = sorted({e.get("collection", "") for e in secs(d) if e.get("collection")})
        print(f"{'collection':12}: {', '.join(colls) or '-'}")
    elif cmd == "audit":
        if a and a[0] == "verify": print(audit_verify()); return
        if a and a[0] == "anchor":
            aa = a[1:]; persist = any(f in aa for f in ("--file", "--webhook", "--syslog"))
            file = _flag(aa, "--file"); webhook = _flag(aa, "--webhook")
            syslog = True if "--syslog" in aa else None
            rec, sent = anchor_push(file=file, webhook=webhook, syslog=syslog, persist=persist)
            if not rec: return print("audit log is still empty — no anchor.")
            print(f"anchor: seq={rec['seq']} hash={rec['hash'][:16]}…  sent: {','.join(sent) or '(no target)'}")
            if not sent: print("  hint: give a target → concealer audit anchor --file ~/anchors.log [--syslog] [--webhook URL]")
            return
        for r in audit_rows()[-40:]:
            who = f"[{r['actor']}] " if r.get("actor") else ""
            print(f"{r['ts']:20} {r['source']:6} {r['action']:10} {r['key']:24} {who}{r.get('detail','')}")
    elif cmd == "get":
        hits = filt(load(), _sel(a))
        if len(hits) != 1: sys.exit(f"{len(hits)} records matched, narrow it down.")
        e = hits[0]; actor = _cli_actor()
        allowed, note = rate_gate(actor, [e])   # reading a value also counts toward the distinct-name quota (blocks get-loops)
        if not allowed:
            audit("get_denied", key=e["name"], source="cli", detail=label(e), actor=actor)
            sys.exit(note.lstrip("\n") or "limit: access quota exhausted.")
        audit("get", key=e["name"], source="cli", detail=label(e), actor=actor)
        if e["type"] == "api_key": print(e["fields"].get("value", ""))
        else:
            for fn, fv in e["fields"].items(): print(f"{fn}={fv}")
    elif cmd in ("set", "add"):
        tags = _flag(a, "--tags"); coll = _flag(a, "--collection")
        rdays = _flag(a, "--rotate-days"); rmode = _flag(a, "--rotate-mode")
        sel = _sel(a); typ = sel.pop("type", "api_key")
        rot = None
        if rdays is not None:
            try: rot = {"every_days": max(0, int(rdays)), "mode": rmode or "generate", "last": now_iso()}
            except ValueError: sys.exit("--rotate-days must be a number")
        if "name" not in sel: sys.exit("--name required")
        if not a: sys.exit("value required (for api_key) or field=val pairs")
        d = load(); ident = {dim: sel.get(dim, "") for dim in DIMS}; ident["name"] = sel["name"]
        hit = next((e for e in secs(d) if matches(e, ident)), None)
        fields = {}
        if "=" in a[0]:
            for kv in a:
                k, _, v = kv.partition("="); fields[k] = v
        else:
            fields = {"value": a[0]}
        fields, bad = _clean_fields(fields)   # reject fields with secret-like (leaked) names
        if bad: sys.exit("invalid field name (looks like a secret value): " + ", ".join(mask(b) for b in bad))
        if hit:
            hit["fields"].update(fields); hit["updated"] = now_iso()
            if tags is not None: hit["tags"] = [t for t in tags.split(",") if t]
            if coll is not None: hit["collection"] = coll.strip().strip("/")
            if rot is not None: hit["rotation"] = rot
            act = "update"
        else:
            e = dict(ident); e["type"] = typ; e["fields"] = fields
            e["tags"] = [t for t in (tags or "").split(",") if t]
            if coll is not None: e["collection"] = coll.strip().strip("/")
            if rot is not None: e["rotation"] = rot
            secs(d).append(norm(e)); act = "create"
        save(d); audit(act, key=sel["name"], source="cli", detail=label(ident))
        print(f"{act}: {sel['name']} @ {label(ident)}")
    elif cmd == "rotate":
        if "--due" in a:   # bulk: records past their policy due date (cron friendly). generate → new value, manual → warn only
            dry = "--dry" in a; d = load(); did = 0; flagged = 0
            for e in secs(d):
                over = rotation_overdue(e)
                if over is None: continue
                mode = (e.get("rotation") or {}).get("mode", "generate")
                if mode == "generate" and "value" in e.get("fields", {}):
                    if not dry:
                        e["fields"]["value"] = _secrets.token_urlsafe(32)
                        e["rotation"]["last"] = e["updated"] = now_iso()
                        audit("rotate", key=e["name"], source="cli", detail="auto")
                    did += 1; print(f"{'[dry] ' if dry else ''}rotate: {e['name']} @ {label(e)} ({over}d overdue)")
                else:   # manual or no 'value' field (multi-field credential) → warn without breaking the value
                    flagged += 1; print(f"WARN: {e['name']} @ {label(e)} ({over}d overdue) — rotate manually ({mode})")
            if did and not dry: save(d)
            return print(f"\n{did} rotated, {flagged} awaiting manual rotation." + (" (dry-run: nothing written)" if dry else ""))
        d = load(); hits = [e for e in secs(d) if matches(e, _sel(list(a)))]
        if len(hits) != 1: sys.exit(f"{len(hits)} records matched, narrow it down.")
        val = a[0] if a and not a[0].startswith("--") else _secrets.token_urlsafe(32)
        hits[0]["fields"]["value"] = val; hits[0]["updated"] = now_iso()
        if hits[0].get("rotation", {}).get("every_days"): hits[0]["rotation"]["last"] = hits[0]["updated"]  # reset the clock
        save(d)
        audit("rotate", key=hits[0]["name"], source="cli"); print(f"rotate: {hits[0]['name']} -> {mask(val)}")
    elif cmd == "rm":
        d = load(); hits = [e for e in secs(d) if matches(e, _sel(a))]
        if len(hits) != 1: sys.exit(f"{len(hits)} records matched, narrow it down.")
        secs(d).remove(hits[0]); save(d)
        audit("delete", key=hits[0]["name"], source="cli"); print(f"rm: {hits[0]['name']}")
    elif cmd in ("mv", "cp"):   # move to collection (mv: update the field) / copy (cp: clone + new id)
        to = _flag(a, "--to-collection"); newname = _flag(a, "--as")
        if to is None: sys.exit("--to-collection required")
        to = to.strip().strip("/")
        d = load(); hits = [e for e in secs(d) if matches(e, _sel(list(a)))]
        if len(hits) != 1: sys.exit(f"{len(hits)} records matched, narrow it down.")
        e = hits[0]
        if cmd == "mv":
            e["collection"] = to; e["updated"] = now_iso(); save(d)
            audit("update", key=e["name"], source="cli", detail=f"collection={to or '-'}")
            print(f"mv: {e['name']} -> 📁{to or '(root)'}")
        else:
            ne = norm({k: (list(v) if isinstance(v, list) else dict(v) if isinstance(v, dict) else v)
                       for k, v in e.items() if k != "id"})   # deep-ish copy, drop id → norm generates a new id
            ne["collection"] = to
            if newname: ne["name"] = newname
            ne["created"] = ne["updated"] = now_iso()
            secs(d).append(ne); save(d)
            audit("create", key=ne["name"], source="cli", detail=f"cp collection={to or '-'}")
            print(f"cp: {e['name']} -> {ne['name']} @ 📁{to or '(root)'} (id {ne['id']})")
    elif cmd == "run":
        sel = _sel(a); names = [sel.pop("name")] if "name" in sel else None   # --name X → inject only X
        for dim in ("repo", "project"): sel.setdefault(dim, detect(dim))
        env, _, _ = inject_env(sel, source="cli", actor=_cli_actor(), command=" ".join(a), names=names)
        sys.stderr.write(f"[concealer] {label(sel)}\n")
        os.execvpe(a[0], a, env)
    elif cmd == "leaks":
        gs = leak_scan(); audit("leak_scan", source="cli")
        if not gs: print("no shared (reused) secret values."); return
        for g in gs:
            print(f"[{g['severity']:4}] score {g['score']:3}  x{g['count']}  {g['mask']:14}  "
                  f"projects={','.join(g['projects']) or '-'}  envs={','.join(g['envs']) or '-'}")
            for u in g["uses"]: print(f"        - {u['name']} @ {u['scope']} ({u['field']})")
    elif cmd == "history":
        purge = "--purge" in a
        hits = history_scan(); audit("history_scan", key=f"{len(hits)} hits", source="cli")
        if not hits: print("no secrets found in shell history."); return
        for h in hits:
            print(f"[{h['severity']:4}] {h['file']}:{h['line']:<5} {h['mask']:14} ({','.join(h['reasons'])})")
            print(f"        {h['cmd']}")
        if purge:
            n = history_purge(hits); audit("history_purge", key=f"{n} lines", source="cli")
            print(f"\nhistory cleaned: {n} lines deleted (backup: *.concealer.bak)")
            print("note: open shell sessions may rewrite the in-memory history — run 'history -c'.")
        else:
            print(f"\n{len(hits)} findings. to delete from history: concealer history --purge")
    elif cmd == "scan":
        scan_env = "--envvars" in a
        if not a or (a[0].startswith("--") and not scan_env):
            sys.exit("usage: concealer scan <folder> [--import] [--history] [--envvars] [--project P --env E ..]")
        root = "" if a[0].startswith("--") else a.pop(0)
        do_import = "--import" in a; history = "--history" in a
        a = [x for x in a if x not in ("--import", "--history", "--envvars")]
        sel = _sel(a); cands = scan_folder(root, history, scan_env)
        if not cands: print("no candidate secrets found."); return
        print(f"{len(cands)} candidates:")
        for c in cands: print(f"  {c['name']:28} {c['mask']:14} <- {c['source']}")
        if not do_import: print("\n(dry run) add --import to import them"); return
        d = load(); scope = {dim: sel.get(dim, "") for dim in DIMS}
        if not scope["project"] and root: scope["project"] = os.path.basename(os.path.abspath(os.path.expanduser(root)))
        n_new, n_skip = _import_cands(d, cands, scope); save(d)
        audit("scan_import", key=f"{n_new} secrets", source="scan", detail=root)
        print(f"\nimported: {n_new}, skipped (existing): {n_skip}  @ {label(scope)}")
    elif cmd == "policy":
        sub = a[0] if a else "list"
        if sub == "check":   # cron friendly: exit code 1 if there are violations
            ev = policy_eval(); tot = 0
            for p in ev:
                if not p.get("enabled", True) or not p["count"]: continue
                tot += p["count"]
                print(f"[{p['name']}] ({p['kind']}, {p['audience']}) — {p['count']} violation(s):")
                for v in p["violations"]: print(f"  - {v['name']} @ {v['scope']}: {v['reason']}")
            print(f"\n{tot} total violation(s).")
            if tot: sys.exit(1)
        else:   # list
            pols = CFG.get("policies") or []
            if not pols: return print("no policies. add them in the web UI (Policy tab).")
            for p in pols:
                print(f"{p['id']}  {p['name']:24} kind={p['kind']:9} audience={p['audience']:6} "
                      f"{'on' if p.get('enabled', True) else 'off'}{'  🔔' if p.get('notify') else ''}")
    elif cmd == "expose":
        offline = "--offline" in a; a = [x for x in a if x != "--offline"]
        sel = _sel(a) or None
        if not offline: print("checking online (HIBP k-anonymity: only a SHA-1 prefix leaves this machine)…")
        res = exposure_scan(sel=sel, online=not offline)
        audit("exposure_scan", key=f"{len(res)} checked", source="cli", detail="offline" if offline else "hibp-range")
        if not res: return print("no high-entropy secret values to check.")
        for r in res:
            pw = r["pwned"]
            tag = "NET-ERR" if pw == -1 else (f"PWNED x{pw}" if pw and pw > 0 else ("clean" if pw == 0 else "—"))
            print(f"[{r['severity']:4}] {r['name']:24} {r['field']:12} {r['mask']:14} {tag}")
            if r["validate"]: print(f"        ↳ {r['validate']}")
        bad = sum(1 for r in res if (r["pwned"] or 0) > 0)
        print(f"\n{len(res)} checked, {bad} found in known breaches. CWE-798 applies to any hard-coded credential.")
        if bad: sys.exit(2)
    elif cmd == "gitscan":
        if not a or a[0].startswith("--"): sys.exit("usage: concealer gitscan <repo>")
        repo = a[0]; res = git_scan(repo); logs = log_scan(repo)
        audit("git_scan", source="cli", detail=repo)
        if not res.get("git"): print("not a git repo (still scanning logs)…")
        for c in res.get("committed", []):
            print(f"COMMITTED  {c['mask']:14} in {len(c['commits'])} commit(s): " + ", ".join(x['hash'] for x in c['commits']))
        for t in res.get("tracked", []):
            print(f"TRACKED    {t['file']}:{t['line']}  {t['mask']}")
        for g in res.get("gitignore_gaps", []):
            print(f"NOT-IGNORED  {g}  (add to .gitignore)")
        for l in logs:
            print(f"LOG [{l['severity']:4}] {l['file']}:{l['line']}  {l['mask']}")
        tot = len(res.get("committed", [])) + len(res.get("tracked", [])) + len(logs)
        print(f"\n{tot} finding(s), {len(res.get('gitignore_gaps', []))} ignore gap(s).")
        if res.get("committed") or res.get("tracked"):
            print("history cleanup guide: run `concealer web` → Risks → Exposure → 'Show cleanup guide' "
                  "(concealer NEVER rewrites history for you).")
    elif cmd == "export":
        out = a[0] if a and not a[0].startswith("--") else f"concealer-export-{now_iso()[:10]}.age"
        pw = getpass.getpass("Master password (bundle encryption): ")
        if not verify_master(pw): sys.exit("wrong password.")
        blob = export_bundle(pw); open(out, "wb").write(blob)
        audit("export", source="cli", detail=out); print(f"export: {out} ({len(blob)} bytes)")
    elif cmd == "import":
        mode = "overwrite"; files = []
        for x in a:
            if x.startswith("--mode="): mode = x.split("=", 1)[1]
            else: files.append(x)
        if not files: sys.exit("usage: concealer import <bundle.age|.cerbak> [--mode=overwrite|skip|duplicate]")
        if mode not in ("overwrite", "skip", "duplicate"): sys.exit("mode must be overwrite|skip|duplicate")
        pw = getpass.getpass("Bundle password: ")
        try: nn, nu, ns = import_bundle(pw, open(files[0], "rb").read(), mode)
        except Exception as ex: sys.exit(f"import error: {ex}")
        audit("import", source="cli", detail=f"{files[0]} mode={mode}")
        print(f"import ({mode}): +{nn} new, ~{nu} updated, ={ns} skipped")
    elif cmd == "config":
        if not a:
            for k, v in CFG.items(): print(f"{k} = {v}")
        elif len(a) == 1: print(CFG.get(a[0]))
        else:
            k, v = a[0], a[1]
            CFG[k] = int(v) if k == "idle" else ([x for x in v.split(",") if x] if k == "confirm_ops" else v)
            save_cfg({"idle": CFG["idle"], "confirm_ops": CFG["confirm_ops"]}); print(f"{k} = {CFG[k]}")
    elif cmd == "deploy":
        target = _flag(a, "--target") or "dotenv"
        scope = _sel(a); scope.pop("name", None); scope.pop("type", None)
        out = deploy_render(scope, target)
        audit("deploy", source="cli", detail=f"{target} {label(scope)}")
        print(out or "(no match)")
    elif cmd == "web": serve_web(int(a[0]) if a else 8787)
    elif cmd == "mcp": mcp_stdio()
    else:
        print(HELP)

# ---------------- Web (SPA + JSON API) ----------------
_SESSIONS = {}   # token -> last_activity_epoch
_SESS_KEY = {}   # token -> decrypted age key text (on a hardened vault; in memory, not written to disk)

def _lock_clear(tok=None):
    """Drop the in-memory key/session, then gc.collect() to reclaim freed copies.
    ponytail: BEST-EFFORT ONLY — CPython does not zeroize memory. Plaintext key/secret
    bytes may still linger in the process heap (and potentially swap or a core dump)
    until overwritten. This only shrinks the reachable-copy window; it is NOT secure
    erasure. See docs/security.md 'Known limitations: in-memory secrets'."""
    global _KEY_CACHE
    if tok is not None:
        _SESSIONS.pop(tok, None); _SESS_KEY.pop(tok, None)
    _KEY_CACHE = None
    gc.collect()

def serve_web(port):
    import time
    def valid(tok):
        global _KEY_CACHE
        t = _SESSIONS.get(tok)
        if not t: return False
        if time.time() - t > CFG["idle"]:   # idle auto-lock: drop session + in-memory key, then gc
            _lock_clear(tok); return False
        _KEY_CACHE = _SESS_KEY.get(tok)     # cache the key for this request (sops reads from memory)
        return True   # fixed lifetime: activity does not extend the TTL (hard auto-lock)
    def remaining(tok):
        t = _SESSIONS.get(tok)
        return max(0, int(CFG["idle"] - (time.time() - t))) if t else 0

    class H(http.server.BaseHTTPRequestHandler):
        server_version = "concealer"
        def log_message(self, *a): pass
        def handle(self):
            try: super().handle()
            except (BrokenPipeError, ConnectionResetError): pass
        def _tok(self):
            c = self.headers.get("Cookie", "")
            for part in c.split(";"):
                if part.strip().startswith("tok="): return part.strip()[4:]
            return None
        def _json(self, obj, code=200, cookie=None):
            b = json.dumps(obj).encode()
            self.send_response(code); self.send_header("Content-Type", "application/json")
            if cookie: self.send_header("Set-Cookie", cookie)
            self.end_headers(); self.wfile.write(b)
        def _body(self):
            ln = int(self.headers.get("Content-Length", 0))
            return json.loads(self.rfile.read(ln) or "{}")

        def do_GET(self):
            u = urllib.parse.urlparse(self.path); path = u.path
            q = {k: v[0] for k, v in urllib.parse.parse_qs(u.query).items()}
            if path == "/" or path == "/index.html":
                try: page = open(WEBUI, "rb").read().replace(b"__IDLE__", str(CFG["idle"]).encode()).replace(b"__VERSION__", VERSION.encode())
                except FileNotFoundError: page = b"<h1>webui.html bulunamadi</h1>"
                self.send_response(200); self.send_header("Content-Type", "text/html; charset=utf-8")
                self.end_headers(); self.wfile.write(page); return
            if path == "/api/session":
                tok = self._tok(); up = bool(tok and valid(tok))
                return self._json({"unlocked": up, "idle": CFG["idle"], "remaining": remaining(tok or "")})
            if path == "/api/types":
                return self._json({t: [{"name": f, "secret": k == "secret"} for f, k in fs] for t, fs in TYPES.items()})
            if not valid(self._tok() or ""): return self._json({"error": "locked"}, 401)
            if path == "/api/secrets":
                d = load(); sel = {dim: q[dim] for dim in DIMS if q.get(dim)}
                rows = filt(d, sel, q.get("tag"), q.get("q"), q.get("type"), q.get("collection"))
                acc = last_access()
                out = []
                for e in rows:
                    pe = entry_public(e); pe["access"] = acc.get(e["name"]); out.append(pe)
                return self._json(out)
            if path.startswith("/api/secret/"):
                sid = path.rsplit("/", 1)[-1]; d = load(); e = by_id(d, sid)
                if not e: return self._json({"error": "not found"}, 404)
                reveal = q.get("reveal") == "1"
                if reveal:
                    audit("reveal", key=e["name"], source="web", detail=q.get("intent", "view"))
                return self._json(entry_public(e, reveal))
            if path == "/api/audit":
                rows = list(reversed(audit_rows()))
                for f in ("action", "source", "key"):
                    if q.get(f): rows = [r for r in rows if q[f].lower() in str(r.get(f, "")).lower()]
                if q.get("from"): rows = [r for r in rows if r["ts"] >= q["from"]]
                if q.get("to"): rows = [r for r in rows if r["ts"] <= q["to"]]
                page = int(q.get("page", 1)); size = int(q.get("size", 25))
                total = len(rows); s = (page - 1) * size
                return self._json({"rows": rows[s:s + size], "total": total, "page": page,
                                   "pages": max(1, (total + size - 1) // size)})
            if path == "/api/settings":
                return self._json({"idle": CFG["idle"], "confirm_ops": CFG["confirm_ops"],
                                   "limits": CFG.get("limits") or {"default": dict(_DEFAULT_LIMITS), "agents": {}},
                                   "agents": _agent_labels(), "hardened": not os.path.exists(KEY),
                                   "hibp_key_set": bool(_hibp_key())})   # the key ITSELF is not returned
            if path == "/api/backup":      # auto-backup status (password NOT returned)
                c = _backup_cfg()
                return self._json({"enabled": c["enabled"], "interval_h": c["interval_h"], "dir": c["dir"],
                                   "keep": c["keep"], "last": c["last"], "has_pw": bool(c.get("pw_wrapped"))})
            if path == "/api/leaks":
                audit("leak_scan", source="web"); return self._json(leak_scan())
            if path == "/api/health":
                audit("health_scan", source="web"); return self._json(health_scan())
            if path == "/api/policies":
                return self._json({"policies": policy_eval(), "kinds": list(_POLICY_KINDS), "audiences": list(_POLICY_AUD)})
            if path == "/api/history":
                hits = history_scan(); audit("history_scan", key=f"{len(hits)} hits", source="web")
                return self._json(hits)
            if path == "/api/browse":      # server-side folder browser for scan-folder (local app)
                raw = q.get("path") or os.path.expanduser("~")
                p = os.path.abspath(os.path.expanduser(raw))
                if not os.path.isdir(p): p = os.path.expanduser("~")
                try: dirs = sorted((n for n in os.listdir(p) if not n.startswith(".") and os.path.isdir(os.path.join(p, n))), key=str.lower)
                except Exception: dirs = []
                parent = os.path.dirname(p)
                return self._json({"path": p, "parent": parent if parent != p else None, "dirs": dirs})
            if path == "/api/pickdir":     # OS-native folder picker dialog (Finder/Explorer)
                sup = sys.platform == "darwin" or sys.platform.startswith("win") or bool(shutil.which("zenity") or shutil.which("kdialog"))
                return self._json({"path": _native_pickdir() if sup else None, "supported": sup})
            if path == "/api/audit/verify": return self._json(audit_verify())
            if path == "/api/audit/export":
                rows = audit_rows()
                if q.get("format") == "csv":
                    buf = io.StringIO(); w = csv.writer(buf)
                    w.writerow(["ts", "source", "action", "key", "detail", "hash"])
                    for r in rows: w.writerow([r["ts"], r["source"], r["action"], r["key"], r.get("detail", ""), r["hash"]])
                    data = buf.getvalue().encode(); ct = "text/csv"; fn = "audit.csv"
                else:
                    data = json.dumps(rows, indent=2).encode(); ct = "application/json"; fn = "audit.json"
                self.send_response(200); self.send_header("Content-Type", ct)
                self.send_header("Content-Disposition", f"attachment; filename={fn}")
                self.end_headers(); self.wfile.write(data); return
            self._json({"error": "not found"}, 404)

        def do_POST(self):
            global _KEY_CACHE
            path = urllib.parse.urlparse(self.path).path
            if path == "/api/unlock":
                pw = self._body().get("pw", "")
                keytext = key_from_master(pw)          # decrypt both verifies and yields the key
                if keytext or verify_master(pw):       # 2nd condition: legacy (plaintext-key) vault
                    tok = _secrets.token_urlsafe(18); _SESSIONS[tok] = time.time()
                    if keytext: _SESS_KEY[tok] = keytext; _KEY_CACHE = keytext   # hardened: bind the key to the session in memory
                    audit("unlock", source="web")
                    _auto_backup_maybe()                   # if the interval elapsed, write a .cer at unlock (key in memory)
                    return self._json({"ok": True}, cookie=f"tok={tok}; HttpOnly; Path=/; SameSite=Strict")
                audit("unlock_fail", source="web"); return self._json({"ok": False}, 403)
            tok = self._tok()
            if path == "/api/lock":
                if tok:
                    _lock_clear(tok); audit("lock", source="web")
                return self._json({"ok": True})
            if not valid(tok or ""): return self._json({"error": "locked"}, 401)
            if path == "/api/secrets":     # create
                b = self._body(); d = load(); e = norm(_entry_from_body(b))
                secs(d).append(e); save(d)
                audit("create", key=e["name"], source="web", detail=label(e))
                return self._json({"ok": True, "id": e["id"]})
            if path == "/api/copy":        # clipboard copy (audit)
                b = self._body(); audit("copy", key=b.get("key", ""), source="web", detail=b.get("field", ""))
                return self._json({"ok": True})
            if path == "/api/scan":        # dry scan (returns no value, masked)
                b = self._body(); cands = scan_folder(b.get("path", ""), b.get("history", False), b.get("env", False))
                audit("scan", source="web", detail=b.get("path", "") or ("env" if b.get("env") else ""))
                return self._json({"cands": [{"name": c["name"], "mask": c["mask"], "source": c["source"]} for c in cands]})
            if path == "/api/policies":    # create/update (session is enough; doesn't reveal a value)
                b = self._body(); pol = policy_upsert(b)
                audit("policy_set", key=pol["name"], source="web", detail=pol["kind"])
                return self._json({"ok": True, "policy": pol})
            if path == "/api/exposure":    # online leak check (HIBP k-anonymity; FULL VALUE DOESN'T LEAVE)
                b = self._body(); names = b.get("names"); ids = b.get("ids"); sel = b.get("sel") or None
                res = exposure_scan(ids=ids if isinstance(ids, list) else None,
                                    names=names if isinstance(names, list) else None, sel=sel, online=b.get("online", True))
                audit("exposure_scan", key=f"{len(res)} checked", source="web", detail="hibp-range")
                return self._json({"results": res})
            if path == "/api/breach":      # email breach query (HIBP; only the email is sent)
                b = self._body(); res = hibp_breaches(b.get("email", ""))
                audit("breach_check", key=b.get("email", ""), source="web")
                return self._json(res)
            if path == "/api/gitscan":     # git history + tracked files + log + gitignore (LOCAL)
                b = self._body(); root = b.get("path", "")
                res = git_scan(root); res["logs"] = log_scan(root)
                audit("git_scan", source="web", detail=root)
                return self._json(res)
            if path == "/api/gitremedy":   # remediation GUIDE text (NEVER executed)
                b = self._body()
                return self._json({"text": git_remediation(b.get("path", ""), b.get("files"), b.get("names"))})
            if path == "/api/settings":    # verify_master required (sensitive)
                b = self._body()
                if not verify_master(b.get("pw", "")):
                    audit("settings_fail", source="web"); return self._json({"error": "bad_pw"}, 403)
                if "idle" in b:
                    try: CFG["idle"] = max(30, min(86400, int(b["idle"])))
                    except Exception: pass
                if isinstance(b.get("confirm_ops"), list):
                    CFG["confirm_ops"] = [x for x in b["confirm_ops"] if x in ("export", "delete", "settings", "create", "update")]
                if isinstance(b.get("limits"), dict):
                    cur = CFG.get("limits") or {"default": dict(_DEFAULT_LIMITS), "agents": {}}
                    if isinstance(b["limits"].get("default"), dict):
                        cur["default"] = {**dict(_DEFAULT_LIMITS), **_clean_limit(b["limits"]["default"])}
                    if isinstance(b["limits"].get("agents"), dict):
                        cur["agents"] = {k: _clean_limit(v) for k, v in b["limits"]["agents"].items()
                                         if k and isinstance(v, dict) and _clean_limit(v)}
                    CFG["limits"] = cur
                if "hibp_key" in b:   # HIBP API key (user's own); empty string = clear
                    CFG["hibp_key"] = str(b["hibp_key"]).strip()
                _cfg_persist()
                audit("settings", source="web", detail=f"idle={CFG['idle']}")
                return self._json({"ok": True, "idle": CFG["idle"], "confirm_ops": CFG["confirm_ops"],
                                   "limits": CFG.get("limits"), "agents": _agent_labels(), "hibp_key_set": bool(_hibp_key())})
            if path == "/api/export":      # bundle encrypted with age -p using the master password
                b = self._body()
                if not verify_master(b.get("pw", "")):
                    audit("export_fail", source="web"); return self._json({"error": "bad_pw"}, 403)
                try: blob = export_bundle(b.get("pw", ""))
                except Exception as ex: return self._json({"error": str(ex)}, 500)
                audit("export", source="web", detail=f"{len(secs(load()))} secrets")
                return self._json({"ok": True, "filename": f"concealer-export-{now_iso()[:10]}.age",
                                   "b64": base64.b64encode(blob).decode()})
            if path == "/api/import":      # pw = the bundle's password (may not be the local master)
                b = self._body()
                try: blob = base64.b64decode(b.get("b64", ""))
                except Exception: return self._json({"error": "corrupt"}, 400)   # code → localized in webui (imp_corrupt)
                mode = b.get("mode", "overwrite")
                if mode not in ("overwrite", "skip", "duplicate"): mode = "overwrite"
                try: nn, nu, ns = import_bundle(b.get("pw", ""), blob, mode)
                except Exception as ex: return self._json({"error": str(ex)}, 400)
                audit("import", source="web", detail=f"mode={mode} +{nn} ~{nu} ={ns}")
                return self._json({"ok": True, "imported": nn, "updated": nu, "skipped": ns})
            if path == "/api/backup":      # manual .cer download — BACKUP password (different from master, required)
                b = self._body(); pw = b.get("pw", "")
                if len(pw) < 8: return self._json({"error": "backup_pw_short"}, 400)
                if pw != b.get("pw2", ""): return self._json({"error": "backup_pw_mismatch"}, 400)
                if verify_master(pw): return self._json({"error": "backup_pw_same_as_master"}, 400)
                try: blob = make_backup(pw)
                except Exception as ex: return self._json({"error": str(ex)}, 500)
                audit("backup", source="web", detail=f"{len(secs(load()))} secrets")
                return self._json({"ok": True, "filename": _cer_name(), "b64": base64.b64encode(blob).decode()})
            if path == "/api/backup/config":   # auto-backup config — master pw required (sensitive)
                b = self._body()
                if not verify_master(b.get("pw", "")):
                    audit("backup_cfg_fail", source="web"); return self._json({"error": "bad_pw"}, 403)
                c = _backup_cfg()
                c["enabled"] = bool(b.get("enabled"))
                try: c["interval_h"] = max(1, min(8760, int(b.get("interval_h", c["interval_h"]))))
                except Exception: pass
                try: c["keep"] = max(1, min(365, int(b.get("keep", c["keep"]))))
                except Exception: pass
                if isinstance(b.get("dir"), str): c["dir"] = b["dir"].strip()
                bp = b.get("backup_pw", "")
                if bp:                                          # set a new backup password → wrap to the age pubkey
                    if len(bp) < 8: return self._json({"error": "backup_pw_short"}, 400)
                    if verify_master(bp): return self._json({"error": "backup_pw_same_as_master"}, 400)
                    try: c["pw_wrapped"] = base64.b64encode(_age_enc_pub(bp)).decode()
                    except Exception as ex: return self._json({"error": str(ex)}, 500)
                if c["enabled"] and not c.get("pw_wrapped"):
                    return self._json({"error": "backup_pw_required"}, 400)
                _save_backup_cfg(c)
                audit("backup_cfg", source="web", detail=f"enabled={c['enabled']} every={c['interval_h']}h keep={c['keep']}")
                return self._json({"ok": True, "enabled": c["enabled"], "interval_h": c["interval_h"], "dir": c["dir"],
                                   "keep": c["keep"], "last": c["last"], "has_pw": bool(c.get("pw_wrapped"))})
            if path == "/api/backup/run":      # write the auto-backup to disk now (test/manual trigger)
                try: p = run_auto_backup()
                except Exception as ex: return self._json({"error": str(ex)}, 400)
                return self._json({"ok": True, "path": p})
            if path == "/api/deploy":
                b = self._body(); target = b.get("target", "dotenv")
                if b.get("id"):                       # single secret (row-level deploy)
                    e = by_id(load(), b["id"])
                    if not e: return self._json({"error": "not found"}, 404)
                    audit("deploy", key=e["name"], source="web", detail=f"{target} {label(e)}")
                    return self._json({"text": deploy_render_one(e, target), "targets": DEPLOY_TARGETS})
                scope = {dim: b.get(dim, "") for dim in DIMS if b.get(dim)}
                audit("deploy", source="web", detail=f"{target} {label(scope)}")
                return self._json({"text": deploy_render(scope, target), "targets": DEPLOY_TARGETS})
            if path == "/api/scan/import":
                b = self._body(); root = b.get("path", ""); names = set(b.get("names") or [])
                cands = [c for c in scan_folder(root, b.get("history", False), b.get("env", False)) if c["name"] in names]
                d = load(); scope = {dim: b.get(dim, "") for dim in DIMS}
                if not scope["project"] and root:
                    scope["project"] = os.path.basename(os.path.abspath(os.path.expanduser(root)))
                n_new, n_skip = _import_cands(d, cands, scope); save(d)
                audit("scan_import", key=f"{n_new} secrets", source="scan", detail=root)
                return self._json({"imported": n_new, "skipped": n_skip})
            if path == "/api/history/purge":
                b = self._body(); targets = b.get("targets") or []
                n = history_purge(targets); audit("history_purge", key=f"{n} lines", source="web")
                return self._json({"removed": n})
            self._json({"error": "not found"}, 404)

        def do_PUT(self):
            path = urllib.parse.urlparse(self.path).path
            if not valid(self._tok() or ""): return self._json({"error": "locked"}, 401)
            if path.startswith("/api/secret/"):
                sid = path.rsplit("/", 1)[-1]; d = load(); e = by_id(d, sid)
                if not e: return self._json({"error": "not found"}, 404)
                b = self._body(); ne = _entry_from_body(b)
                for k in ["name", "type"] + DIMS + ["tags", "url", "notes", "fields", "collection", "rotation"]: e[k] = ne[k]
                if "field_meta" in ne: e["field_meta"] = ne["field_meta"]   # write only if sent (preserves TUI overrides)
                e["updated"] = now_iso(); save(d)
                audit("update", key=e["name"], source="web", detail=label(e))
                return self._json({"ok": True})
            self._json({"error": "not found"}, 404)

        def do_DELETE(self):
            path = urllib.parse.urlparse(self.path).path
            if not valid(self._tok() or ""): return self._json({"error": "locked"}, 401)
            if path.startswith("/api/policies/"):
                pid = path.rsplit("/", 1)[-1]
                ok = policy_delete(pid); audit("policy_delete", key=pid, source="web")
                return self._json({"ok": ok})
            if path.startswith("/api/secret/"):
                sid = path.rsplit("/", 1)[-1]; d = load(); e = by_id(d, sid)
                if not e: return self._json({"error": "not found"}, 404)
                if "delete" in CFG["confirm_ops"] and not verify_master(self._body().get("pw", "")):
                    return self._json({"error": "bad_pw"}, 403)
                secs(d).remove(e); save(d)
                audit("delete", key=e["name"], source="web", detail=label(e))
                return self._json({"ok": True})
            self._json({"error": "not found"}, 404)

    srv = http.server.ThreadingHTTPServer(("127.0.0.1", port), H)
    print(f"concealer web: http://127.0.0.1:{port}  (idle-lock {CFG['idle']}s, Ctrl+C to stop)")
    if os.environ.get("CONCEALER_NO_OPEN") != "1":  # ponytail: skip in headless/SSH via env
        try: webbrowser.open(f"http://127.0.0.1:{port}")
        except Exception: pass
    try: srv.serve_forever()
    except KeyboardInterrupt: pass

def _entry_from_body(b):
    e = {"name": b.get("name", ""), "type": b.get("type", "api_key"),
         "tags": [t.strip() for t in (b.get("tags") or []) if (t or "").strip()] if isinstance(b.get("tags"), list)
                 else [t.strip() for t in str(b.get("tags", "")).split(",") if t.strip()],
         "url": b.get("url", ""), "notes": b.get("notes", ""), "fields": _clean_fields(b.get("fields", {}))[0],
         "collection": (b.get("collection") or "").strip().strip("/"),
         "rotation": b.get("rotation") if isinstance(b.get("rotation"), dict) else {}}
    for dim in DIMS: e[dim] = b.get(dim, "")
    if isinstance(b.get("field_meta"), dict): e["field_meta"] = b["field_meta"]   # record-specific secrecy overrides
    if "id" in b: e["id"] = b["id"]
    return e

# ---------------- MCP ----------------
# Delivered to EVERY MCP client on `initialize` (not just Claude Code). This is the managed,
# server-side secrets policy — it travels with the connection, unlike a per-agent local file.
# Advisory by protocol; the non-bypassable guards are code: registered-agent gate, rate_gate, redaction.
MCP_INSTRUCTIONS = (
    "concealer is a local-only secret manager. Never print, echo, cat, or write secret VALUES — "
    "they are injected into a child env and redacted from output. "
    "LEAST-PRIVILEGE (required): when calling run_with_secrets, pass `names` with ONLY the secrets the "
    "command needs; do NOT inject a whole scope. Discover names with list_secrets/search_secrets first. "
    "Scope every call as narrowly as you can (tenant/project/environment/repo). "
    "Bulk enumeration is rate-limited and audited; injecting more than you use will be flagged."
)
_DIMPROPS = {d: {"type": "string"} for d in DIMS}
MCP_TOOLS = [
    {"name": "list_secrets", "description": "List secrets by type+scope+tag+collection (no values returned).",
     "inputSchema": {"type": "object", "properties": {**_DIMPROPS, "tag": {"type": "string"}, "type": {"type": "string"},
        "collection": {"type": "string", "description": "narrow by collection path (sub-paths included)"}}}},
    {"name": "search_secrets", "description": "Search across name/scope/tag/url/notes (no values returned).",
     "inputSchema": {"type": "object", "properties": {"term": {"type": "string"}}, "required": ["term"]}},
    {"name": "run_with_secrets", "description": "Run a command with scope-matching secrets injected into env. No values leak; output is masked. Pass 'names' to inject ONLY the secrets you need (least-privilege) instead of the whole scope.",
     "inputSchema": {"type": "object", "properties": {"command": {"type": "string"},
        "names": {"type": "array", "items": {"type": "string"}, "description": "restrict injection to these secret names (default: all matching the scope)"},
        **_DIMPROPS}, "required": ["command"]}},
    {"name": "set_secret", "description": "Create/update a secret (WRITES the value, never returns it). Updates if the same name+scope exists. Identify yourself with 'actor'; audit source=mcp.",
     "inputSchema": {"type": "object", "properties": {
        "name": {"type": "string"}, "type": {"type": "string", "description": "api_key|database|website|custom"},
        "value": {"type": "string", "description": "single value for api_key"},
        "fields": {"type": "object", "description": "{field: value} for multi-field types"},
        "tags": {"type": "array", "items": {"type": "string"}}, "url": {"type": "string"},
        "notes": {"type": "string"}, "actor": {"type": "string", "description": "identity of the acting agent"},
        "collection": {"type": "string", "description": "free-form grouping path (optional)"},
        **_DIMPROPS}, "required": ["name"]}},
]
def _fmt(rows): return "\n".join(f"{e['name']:22} {e['type']:9} {label(e):22} [{','.join(e['tags'])}]" for e in rows) or "(empty)"
def _mcp_call(name, args):
    # Registration required: only 'agent' source tokens can access secrets from MCP (unregistered agent = fail-closed).
    agent, source = _mcp_agent()
    if source != "agent":
        return ("error: access denied — a registered agent token is required. "
                "First, the vault owner: `concealer agent register <name>` and put the generated CONCEALER_TOKEN in the MCP env.")
    if name == "list_secrets":
        sel = {d: args[d] for d in DIMS if args.get(d)}
        rows = filt(load(), sel, args.get("tag"), None, args.get("type"), args.get("collection"))
        allowed, note = rate_gate(agent, rows)
        audit("list", source="mcp", detail=json.dumps(sel), actor=agent)
        return _fmt(allowed) + note
    if name == "search_secrets":
        rows = filt(load(), term=args.get("term"))
        allowed, note = rate_gate(agent, rows)
        audit("search", source="mcp", detail=args.get("term", ""), actor=agent)
        return _fmt(allowed) + note
    if name == "run_with_secrets":
        target = {d: args[d] for d in DIMS if args.get(d)}
        for dim in ("repo", "project"): target.setdefault(dim, detect(dim))
        env, d, note = inject_env(target, source="mcp", actor=agent, command=args["command"], gate=agent, names=args.get("names"))
        if env is None: return "error: injection blocked — rate limit reached." + (note or "")
        r = subprocess.run(["/bin/sh", "-c", args["command"]], env=env, capture_output=True, text=True)
        return redact(f"[{label(target)}]\n" + r.stdout + r.stderr, d) or "(no output)"
    if name == "set_secret":
        actor = args.get("actor") or agent or os.environ.get("CONCEALER_ACTOR", "")
        d = load(); ident = {dim: args.get(dim, "") for dim in DIMS}; ident["name"] = args["name"]
        hit = next((e for e in secs(d) if matches(e, ident)), None)
        fields = dict(args.get("fields") or {})
        if args.get("value") is not None and "value" not in fields: fields["value"] = args["value"]
        fields, bad = _clean_fields(fields)   # reject fields with secret-like (leaked) names
        if bad: return "error: invalid field name (looks like a secret value): " + ", ".join(mask(b) for b in bad)
        if hit:
            hit["fields"].update(fields); hit["updated"] = now_iso()
            if "tags" in args: hit["tags"] = [t for t in (args["tags"] or []) if t]
            if "url" in args: hit["url"] = args["url"]
            if "notes" in args: hit["notes"] = args["notes"]
            if "collection" in args: hit["collection"] = (args["collection"] or "").strip().strip("/")
            act = "update"
        else:
            e = dict(ident); e["type"] = args.get("type", "api_key"); e["fields"] = fields
            e["tags"] = [t for t in (args.get("tags") or []) if t]
            e["url"] = args.get("url", ""); e["notes"] = args.get("notes", "")
            e["collection"] = (args.get("collection") or "").strip().strip("/")
            secs(d).append(norm(e)); act = "create"
        save(d); audit(act, key=args["name"], source="mcp", detail=label(ident), actor=actor)
        return f"{act}: {args['name']} @ {label(ident)}"
    return f"unknown tool: {name}"
def mcp_stdio():
    def send(o): sys.stdout.write(json.dumps(o) + "\n"); sys.stdout.flush()
    for line in sys.stdin:
        line = line.strip()
        if not line: continue
        req = json.loads(line); mid = req.get("id"); m = req.get("method")
        if m == "initialize":
            send({"jsonrpc": "2.0", "id": mid, "result": {"protocolVersion": "2024-11-05",
                  "capabilities": {"tools": {}}, "serverInfo": {"name": "concealer", "version": VERSION},
                  "instructions": MCP_INSTRUCTIONS}})
        elif m == "notifications/initialized": pass
        elif m == "tools/list": send({"jsonrpc": "2.0", "id": mid, "result": {"tools": MCP_TOOLS}})
        elif m == "tools/call":
            p = req.get("params", {})
            try: text = _mcp_call(p.get("name"), p.get("arguments", {}))
            except Exception as e: text = f"error: {e}"
            send({"jsonrpc": "2.0", "id": mid, "result": {"content": [{"type": "text", "text": text}]}})
        elif mid is not None:
            send({"jsonrpc": "2.0", "id": mid, "error": {"code": -32601, "message": f"method not found: {m}"}})

def _run():   # console_scripts entry point (pipx/PyPI); flat `./concealer` still uses the guard below
    cli(sys.argv[1:])

if __name__ == "__main__":
    cli(sys.argv[1:])
