#!/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.14"   # 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):
    """Collect secrets matching the target scopes as {ENV_KEY: value} (most specific wins)."""
    d = d or load(); chosen = {}
    for e in secs(d):
        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):
    """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."""
    kv, d, recs = collect_secrets(target)
    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)
        for dim in ("repo", "project"): sel.setdefault(dim, detect(dim))
        env, _, _ = inject_env(sel, source="cli", actor=_cli_actor(), command=" ".join(a))
        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 ----------------
_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.",
     "inputSchema": {"type": "object", "properties": {"command": {"type": "string"}, **_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)
        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}}})
        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:])
