#!/usr/bin/env python3
"""bw-secrets — Agent-friendly Bitwarden/Vaultwarden secrets helper.

Pure Python 3 (stdlib only). No jq, no pexpect, no session file on disk.

Design rules:
  * Secrets ALWAYS live in Bitwarden. This tool only reads them at runtime.
  * Master password file (chmod 600) — the ONLY secret on disk. Never write
    the session token to disk; re-unlock per call.
  * ALWAYS `bw sync` before reads (the CLI serves a local cache).
  * Look items up by id, never by name (em-dash silent-failure bug).
  * Agent-friendly: exit 0 = found, 1 = not found, 2 = ambiguous match.
  * ORG-FIRST: get/list/env search the default org (BW_ORG_ID) by default.
    Personal-vault items are excluded unless --personal; --all searches
    everything. Collections matched case-insensitively.

Config resolution (highest wins): environment variable > config file
(~/.config/bw/config.env) > built-in default.

  BW_SERVER            Vaultwarden URL            (required — no default)
  BW_ORG_ID            Default org id             (required for org-first lookups)
  BW_MASTER_PW_FILE    Master password file      (default ~/.config/bw/master_pw)
  BW_BIN               bw CLI path                (default bw on PATH)

Usage:
  bw-secrets get <name> [field] [--collection <name>] [--personal|--all] [--json]
  bw-secrets list [search] [--collection <name>] [--personal|--all]
  bw-secrets env <name> [field] [--collection <name>] [--personal|--all]  # prints KEY=value for sourcing
  bw-secrets sync | status | unlock
  bw-secrets org list
  bw-secrets org collections [org-id]
  bw-secrets org items <collection> [search]
  bw-secrets org get <collection> <name> [field] [--json]

Fields: username, password (default), notes, totp, url, custom:<field>, json,
or a dotted path into the item (e.g. login.uris.0.uri).
"""
import json
import os
import re
import subprocess
import sys
from pathlib import Path

# --------------------------------------------------------------------------
# Config
# --------------------------------------------------------------------------

DEFAULTS = {
    "BW_SERVER": "",
    "BW_ORG_ID": "",
    "BW_MASTER_PW_FILE": str(Path.home() / ".config" / "bw" / "master_pw"),
    "BW_BIN": "bw",
}

CONFIG_FILE = Path.home() / ".config" / "bw" / "config.env"


def load_config() -> dict:
    cfg = dict(DEFAULTS)
    if CONFIG_FILE.exists():
        for line in CONFIG_FILE.read_text().splitlines():
            line = line.strip()
            if line and not line.startswith("#") and "=" in line:
                k, v = line.split("=", 1)
                cfg[k.strip()] = v.strip()
    for k in DEFAULTS:
        if os.environ.get(k):
            cfg[k] = os.environ[k]
    return cfg


CFG = load_config()


def require(key: str, hint: str) -> str:
    val = CFG.get(key, "")
    if not val:
        sys.stderr.write(
            f"ERROR: {key} is not set. {hint}\n"
            f"Set it in {CONFIG_FILE} or export it as an environment variable.\n"
        )
        sys.exit(1)
    return val


# --------------------------------------------------------------------------
# bw plumbing
# --------------------------------------------------------------------------


def _env() -> dict:
    env = os.environ.copy()
    if CFG.get("BW_SERVER"):
        env["BW_SERVER"] = CFG["BW_SERVER"]
    return env


def _bw(args, session=None, check=True):
    """Run bw with the given args. Returns (returncode, stdout)."""
    cmd = [CFG["BW_BIN"]] + args
    if session:
        cmd += ["--session", session]
    proc = subprocess.run(cmd, capture_output=True, text=True, env=_env(), timeout=60)
    if check and proc.returncode != 0:
        err = proc.stderr.strip() or proc.stdout.strip()
        sys.stderr.write(f"bw error: {err}\n")
        sys.exit(1)
    return proc.returncode, proc.stdout


def unlock() -> str:
    """Unlock the vault, return session token. Never persists the token."""
    pw_file = Path(require("BW_MASTER_PW_FILE",
                          "Point BW_MASTER_PW_FILE at your master password file."))
    if not pw_file.exists():
        sys.stderr.write(f"ERROR: master password file not found: {pw_file}\n")
        sys.exit(1)
    proc = subprocess.run(
        [CFG["BW_BIN"], "unlock", "--passwordfile", str(pw_file), "--raw"],
        capture_output=True, text=True, env=_env(), timeout=60,
    )
    session = proc.stdout.strip()
    if not session or proc.returncode != 0:
        sys.stderr.write("ERROR: bw unlock failed\n")
        sys.exit(1)
    return session


def sync(session: str) -> None:
    _bw(["sync"], session=session, check=False)


def _list_json(session: str, what: str, retries: int = 3) -> list:
    """bw list <what> with retry. The CLI intermittently returns empty stdout
    (verified 2026-09-10: `bw list items` returned 0 bytes on ~5% of runs).
    Retry with a fresh session before giving up."""
    for attempt in range(1, retries + 1):
        _, out = _bw(["list", what], session=session, check=False)
        try:
            return json.loads(out)
        except json.JSONDecodeError:
            if attempt < retries:
                session = unlock()  # fresh session — stale token can cause empty output
                continue
            sys.stderr.write(f"ERROR: could not parse bw list {what} output "
                             f"(empty={len(out) == 0})\n")
            sys.exit(1)
    return []  # unreachable


def list_items(session: str) -> list:
    """Unfiltered item list (catches collection-only items the org filter misses)."""
    return _list_json(session, "items")


def list_collections(session: str) -> list:
    return _list_json(session, "collections")


def list_orgs(session: str) -> list:
    return _list_json(session, "organizations")


# --------------------------------------------------------------------------
# Lookup helpers
# --------------------------------------------------------------------------

def find_collection_id(collections: list, name: str, org_id: str = None) -> str:
    """Case-insensitive collection lookup, preferring the target org."""
    name_l = name.lower()
    candidates = [c for c in collections if c.get("name", "").lower() == name_l]
    if not candidates:
        return None
    if org_id:
        for c in candidates:
            if c.get("organizationId") == org_id:
                return c["id"]
    return candidates[0]["id"]


def match_items(items: list, name: str, collection_id: str = None) -> list:
    """Case-insensitive substring match on item name, optional collection filter."""
    name_l = name.lower()
    hits = [it for it in items if name_l in it.get("name", "").lower()]
    if collection_id:
        hits = [it for it in hits if collection_id in it.get("collectionIds", [])]
    return hits


def resolve_scope(items: list, scope: str, org_id: str = None,
                  collections: list = None) -> list:
    """Filter items by search scope.

    scope: 'org' (default) — items in the default org's collections
           'personal'     — items with no org/collection (personal vault)
           'all'          — everything

    Org membership is determined by collectionIds intersecting the org's
    collection IDs — NOT by item.organizationId, which is null on items
    created via `bw create item` (verified 2026-06-28).
    """
    if scope == "all":
        return items
    if scope == "personal":
        return [it for it in items if not it.get("collectionIds")]
    # org scope
    if org_id and collections:
        org_coll_ids = {c["id"] for c in collections
                        if c.get("organizationId") == org_id}
        return [it for it in items
                if org_coll_ids.intersection(it.get("collectionIds", []))]
    return [it for it in items if it.get("collectionIds")]


def get_field(item: dict, field: str):
    """Extract a field from a bw item. Returns (value, is_json)."""
    if field == "json":
        return json.dumps(item, indent=2), True
    if field == "password":
        return item.get("login", {}).get("password", ""), False
    if field == "username":
        return item.get("login", {}).get("username", ""), False
    if field == "notes":
        return item.get("notes", ""), False
    if field == "totp" or field == "totp_uri":
        return item.get("login", {}).get("totp", ""), False
    if field == "url":
        uris = item.get("login", {}).get("uris") or []
        return (uris[0].get("uri", "") if uris else ""), False
    if field.startswith("custom:"):
        fname = field[len("custom:"):]
        for f in item.get("fields", []):
            if f.get("name") == fname:
                return f.get("value", ""), False
        return "", False
    # dotted path, e.g. login.uris.0.uri
    val = item
    for part in field.split("."):
        if isinstance(val, list):
            try:
                val = val[int(part)]
            except (ValueError, IndexError):
                return "", False
        elif isinstance(val, dict):
            val = val.get(part)
        else:
            return "", False
    return (val if val is not None else ""), False


# --------------------------------------------------------------------------
# Commands
# --------------------------------------------------------------------------

def cmd_get(args):
    if not args:
        sys.stderr.write("Usage: bw-secrets get <name> [field] [--collection <name>] [--personal|--all] [--json]\n")
        sys.exit(1)
    name = args[0]
    field = "password"
    collection = None
    scope = "org"
    as_json = False
    i = 1
    while i < len(args):
        a = args[i]
        if a == "--collection" and i + 1 < len(args):
            collection = args[i + 1]
            i += 2
        elif a == "--personal":
            scope = "personal"
            i += 1
        elif a == "--all":
            scope = "all"
            i += 1
        elif a == "--json":
            as_json = True
            i += 1
        else:
            field = a
            i += 1
    if as_json:
        field = "json"

    session = unlock()
    sync(session)
    items = list_items(session)
    collections = list_collections(session)
    org_id = CFG.get("BW_ORG_ID", "")
    coll_id = find_collection_id(collections, collection, org_id) if collection else None
    if collection and not coll_id:
        sys.stderr.write(f"ERROR: collection '{collection}' not found\n")
        sys.exit(1)

    if collection:
        items = [it for it in items if coll_id in it.get("collectionIds", [])]
    else:
        items = resolve_scope(items, scope, org_id, collections)

    hits = match_items(items, name)
    if not hits:
        sys.stderr.write(f"ERROR: no items found matching '{name}'\n")
        sys.exit(1)
    if len(hits) > 1:
        names = ", ".join(h.get("name", "?") for h in hits)
        sys.stderr.write(f"ERROR: ambiguous match for '{name}': {names}\n")
        sys.exit(2)

    value, is_json = get_field(hits[0], field)
    if is_json:
        print(value)
    else:
        print(value)


def cmd_list(args):
    search = args[0] if args and not args[0].startswith("--") else None
    collection = None
    scope = "org"
    if "--collection" in args:
        idx = args.index("--collection")
        if idx + 1 < len(args):
            collection = args[idx + 1]
    if "--personal" in args:
        scope = "personal"
    if "--all" in args:
        scope = "all"

    session = unlock()
    sync(session)
    items = list_items(session)
    collections = list_collections(session)
    org_id = CFG.get("BW_ORG_ID", "")
    coll_id = find_collection_id(collections, collection, org_id) if collection else None
    if collection and not coll_id:
        sys.stderr.write(f"ERROR: collection '{collection}' not found\n")
        sys.exit(1)

    if collection:
        items = [it for it in items if coll_id in it.get("collectionIds", [])]
    else:
        items = resolve_scope(items, scope, org_id, collections)

    if search:
        items = [it for it in items if search.lower() in it.get("name", "").lower()]

    for it in sorted(items, key=lambda x: x.get("name", "").lower()):
        kind = "login" if it.get("type") == 1 else "other"
        print(f"{it.get('name', '?')} [{kind}] {it.get('id', '?')}")


def cmd_env(args):
    if not args:
        sys.stderr.write("Usage: bw-secrets env <name> [field] [--collection <name>] [--personal|--all]\n")
        sys.exit(1)
    name = args[0]
    field = "password"
    collection = None
    scope = "org"
    i = 1
    while i < len(args):
        a = args[i]
        if a == "--collection" and i + 1 < len(args):
            collection = args[i + 1]
            i += 2
        elif a == "--personal":
            scope = "personal"
            i += 1
        elif a == "--all":
            scope = "all"
            i += 1
        else:
            field = a
            i += 1

    session = unlock()
    sync(session)
    items = list_items(session)
    collections = list_collections(session)
    org_id = CFG.get("BW_ORG_ID", "")
    coll_id = find_collection_id(collections, collection, org_id) if collection else None
    if collection:
        items = [it for it in items if coll_id in it.get("collectionIds", [])]
    else:
        items = resolve_scope(items, scope, org_id, collections)

    hits = match_items(items, name)
    if not hits:
        sys.stderr.write(f"ERROR: no items found matching '{name}'\n")
        sys.exit(1)
    if len(hits) > 1:
        names = ", ".join(h.get("name", "?") for h in hits)
        sys.stderr.write(f"ERROR: ambiguous match for '{name}': {names}\n")
        sys.exit(2)

    value, _ = get_field(hits[0], field)
    key = re.sub(r"[^A-Z0-9_]", "_", hits[0].get("name", "SECRET").upper())
    print(f"{key}={value}")


def cmd_sync(_args):
    session = unlock()
    sync(session)
    print("Vault synced.")


def cmd_status(_args):
    proc = subprocess.run([CFG["BW_BIN"], "status"], capture_output=True, text=True, env=_env(), timeout=30)
    print(proc.stdout.strip() or proc.stderr.strip())


def cmd_unlock(_args):
    session = unlock()
    print(f"Vault unlocked. Session: {session[:8]}...")


def cmd_org(args):
    if not args:
        sys.stderr.write("Usage: bw-secrets org <list|collections|items|get> ...\n")
        sys.exit(1)
    sub = args[0]
    rest = args[1:]

    if sub == "list":
        session = unlock()
        sync(session)
        for o in list_orgs(session):
            print(f"{o.get('name', '?')} [{o.get('id', '?')}]")
        return

    if sub == "collections":
        session = unlock()
        sync(session)
        org_id = rest[0] if rest else CFG.get("BW_ORG_ID", "")
        for c in list_collections(session):
            if c.get("organizationId") == org_id:
                print(f"{c.get('name', '?')} [{c.get('id', '?')}]")
        return

    if sub == "items":
        if not rest:
            sys.stderr.write("Usage: bw-secrets org items <collection> [search]\n")
            sys.exit(1)
        collection = rest[0]
        search = rest[1] if len(rest) > 1 else None
        session = unlock()
        sync(session)
        collections = list_collections(session)
        coll_id = find_collection_id(collections, collection, CFG.get("BW_ORG_ID", ""))
        if not coll_id:
            sys.stderr.write(f"ERROR: collection '{collection}' not found\n")
            sys.exit(1)
        items = [it for it in list_items(session) if coll_id in it.get("collectionIds", [])]
        if search:
            items = [it for it in items if search.lower() in it.get("name", "").lower()]
        for it in sorted(items, key=lambda x: x.get("name", "").lower()):
            kind = "login" if it.get("type") == 1 else "other"
            print(f"{it.get('name', '?')} [{kind}] {it.get('id', '?')}")
        return

    if sub == "get":
        if len(rest) < 2:
            sys.stderr.write("Usage: bw-secrets org get <collection> <name> [field] [--json]\n")
            sys.exit(1)
        collection = rest[0]
        name = rest[1]
        field = "password"
        as_json = False
        for a in rest[2:]:
            if a == "--json":
                as_json = True
            else:
                field = a
        if as_json:
            field = "json"
        session = unlock()
        sync(session)
        collections = list_collections(session)
        coll_id = find_collection_id(collections, collection, CFG.get("BW_ORG_ID", ""))
        if not coll_id:
            sys.stderr.write(f"ERROR: collection '{collection}' not found\n")
            sys.exit(1)
        hits = match_items(list_items(session), name, coll_id)
        if not hits:
            sys.stderr.write(f"ERROR: no items found matching '{name}' in collection '{collection}'\n")
            sys.exit(1)
        if len(hits) > 1:
            names = ", ".join(h.get("name", "?") for h in hits)
            sys.stderr.write(f"ERROR: ambiguous match for '{name}': {names}\n")
            sys.exit(2)
        value, is_json = get_field(hits[0], field)
        print(value if is_json else value)
        return

    sys.stderr.write(f"ERROR: unknown org subcommand '{sub}'\n")
    sys.exit(1)


def cmd_help(_args=None):
    print(__doc__)


def main():
    if not args:
        cmd_help()
        return
    cmd = args[0]
    rest = args[1:]
    handlers = {
        "get": cmd_get,
        "list": cmd_list,
        "env": cmd_env,
        "sync": cmd_sync,
        "status": cmd_status,
        "unlock": cmd_unlock,
        "org": cmd_org,
        "help": cmd_help,
        "--help": cmd_help,
        "-h": cmd_help,
    }
    if cmd not in handlers:
        sys.stderr.write(f"ERROR: unknown command '{cmd}'. Run 'bw-secrets help'.\n")
        sys.exit(1)
    handlers[cmd](rest)


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