"""The account commands: `accounts`, `sessions` and `audit-log` (docs/ACCOUNTS.md).

Registered into `app.cli` by one line, like the quality and editorial commands. The CLI is the
launch path for credentials: a welcome or reset link is printed once to the operator's
terminal, never to a log, and carries its token in the fragment; a password is prompted,
never taken from argv.

    python -m app.cli accounts list | owner-check | bench
    python -m app.cli accounts create --username adam --email a@x --display-name Adam --level admin
    python -m app.cli accounts set-password --username rian        # prompted, never argv
    python -m app.cli accounts invite-link --username adam          # printed once
    python -m app.cli accounts reset-link --username adam
    python -m app.cli accounts disable | enable | unlock --username adam
    python -m app.cli sessions revoke --username adam | --all
    python -m app.cli sessions prune
    python -m app.cli audit-log prune [--keep-days 90]
"""

from __future__ import annotations

import argparse
import getpass
from datetime import timedelta

from sqlalchemy import delete, select

from app.config import settings
from app.db import SessionLocal
from app.models import Account, AccountCredential, AccountLevel, AccountMember, AuditLog
from app.services import accounts as acc
from app.services import audit_log, directory, passwords, sessions
from app.vendor.bw_accounts import AccountsError

# Actions that record every request or heartbeat; pruned to a bounded history.
NOISY_ACTIONS = ("login.ok", "login.refused", "logout")


def cmd_accounts_list(_: argparse.Namespace) -> int:
    with SessionLocal() as db:
        rows = db.execute(
            select(Account, AccountCredential.password_hash.isnot(None), AccountMember.level)
            .outerjoin(AccountCredential, AccountCredential.account_id == Account.id)
            .outerjoin(AccountMember, AccountMember.username == Account.username)
            .order_by(Account.id)
        ).all()
    owner = acc.owner_or_sentinel()
    print(f"{'id':>4}  {'username':<20} {'status':<9} {'level':<12} {'password':<9} display_name / email")
    for account, has_password, level in rows:
        tag = "owner" if account.username == owner else (level or "-")
        print(f"{account.id:>4}  {account.username or '-':<20} {account.status:<9} {tag:<12} "
              f"{'set' if has_password else 'none':<9} {account.display_name} / {account.email or '-'}")
    return 0


def cmd_accounts_owner_check(_: argparse.Namespace) -> int:
    """Says whether the host has an owner and whether that owner can sign in; exit 1 if not."""
    owner = settings.owner_username
    if owner is None or not acc.valid_username(owner):
        print("owner-check: NO OWNER. ACCOUNT_OWNER is unset, a placeholder or not a valid "
              "username; nobody is super admin on this host (the site stays up).")
        return 1
    with SessionLocal() as db:
        account = db.scalar(select(Account).where(Account.username == owner))
        credential = db.get(AccountCredential, account.id) if account else None
    if account is None:
        print(f"owner-check: ACCOUNT_OWNER={owner} but no accounts row has that username "
              f"(create it: accounts create --username {owner} ...)")
        return 1
    if account.status != "active":
        print(f"owner-check: owner {owner} exists but is {account.status}")
        return 1
    if credential is None or not credential.password_hash:
        print(f"owner-check: owner {owner} has no password yet (accounts set-password --username {owner})")
        return 1
    print(f"owner-check: ok, {owner} is the owner (id={account.id}) and can sign in")
    return 0


def cmd_accounts_bench(_: argparse.Namespace) -> int:
    ms = passwords.bench()
    print(f"bench: argon2id t={passwords.TIME_COST} m={passwords.MEMORY_COST // 1024}MiB "
          f"p={passwords.PARALLELISM}: {ms:.0f} ms per verify on this host")
    return 0


def _account_or_fail(db, username: str) -> Account:
    account = directory.by_username(db, username)
    if account is None:
        raise SystemExit(f"no account with username {username.strip().lower()!r}")
    return account


def cmd_accounts_create(args: argparse.Namespace) -> int:
    """The accounts row (status invited, no credential) and its membership at a level; then
    `invite-link` or `set-password` gives it a way in."""
    with SessionLocal() as db:
        if db.get(AccountLevel, args.level) is None:
            raise SystemExit(f"no level {args.level!r} (run `backfill levels`, or create it in the Levels editor)")
        try:
            account = directory.create_account(db, args.username, args.email, args.display_name, status="invited")
            directory.add_member_row(db, account.username, args.level)
        except AccountsError as exc:
            raise SystemExit(f"refused: {exc} ({exc.code})") from None
        db.commit()
        username, account_id = account.username, account.id
    audit_log.record("account.create", entity_type="account", entity_key=username,
                     detail={"via": "cli", "level": args.level})
    print(f"created {username} (id={account_id}) at level {args.level}; next: accounts invite-link "
          f"--username {username}, or accounts set-password --username {username}")
    return 0


def cmd_accounts_set_password(args: argparse.Namespace) -> int:
    """Prompted twice, never from argv or a log; clears any lock; signs the account out
    everywhere; the must-change flag is off (the owner chose this password deliberately)."""
    with SessionLocal() as db:
        account = _account_or_fail(db, args.username)
        first = getpass.getpass(f"New password for {account.username}: ")
        second = getpass.getpass("Again: ")
        if first != second:
            raise SystemExit("the two entries differ; nothing changed")
        try:
            directory.set_password(db, account, first, via="cli", must_change=False)
        except passwords.PasswordRuleError as exc:
            raise SystemExit(f"refused: {exc}") from None
        print(f"password set for {account.username}; every session of the account is signed out")
    return 0


def _print_link(args: argparse.Namespace, mode: str) -> int:
    with SessionLocal() as db:
        account = _account_or_fail(db, args.username)
        try:
            token = directory.mint_token(db, account, mode)
        except AccountsError as exc:
            raise SystemExit(f"refused: {exc} ({exc.code})") from None
        db.commit()
        username = account.username
    if not settings.public_base_url:
        print("PUBLIC_BASE_URL is not set: the link below is a path; prefix it with the site's origin.")
    print(f"One-time link for {username} (48 hours, single use; send it yourself, never paste it into a log):")
    print(directory.link_for(mode, token))
    return 0


def cmd_accounts_invite_link(args: argparse.Namespace) -> int:
    return _print_link(args, "invite")


def cmd_accounts_reset_link(args: argparse.Namespace) -> int:
    return _print_link(args, "reset")


def cmd_accounts_disable(args: argparse.Namespace) -> int:
    if not directory.disable_account(args.username, via="cli"):
        raise SystemExit(f"no account with username {args.username!r}")
    print(f"{args.username} disabled; every session revoked; membership kept (enable restores it)")
    return 0


def cmd_accounts_enable(args: argparse.Namespace) -> int:
    if not directory.enable_account(args.username):
        raise SystemExit(f"no account with username {args.username!r}")
    print(f"{args.username} enabled (active if it has a password, invited otherwise); the member row is untouched")
    return 0


def cmd_accounts_unlock(args: argparse.Namespace) -> int:
    with SessionLocal() as db:
        account = _account_or_fail(db, args.username)
        directory.unlock(db, account)
    print(f"{args.username} unlocked")
    return 0


def cmd_sessions_revoke(args: argparse.Namespace) -> int:
    if args.all:
        n = sessions.revoke_all(reason="break-glass")
        audit_log.record("session.revoke", detail={"scope": "all", "count": n, "via": "cli"})
        print(f"revoked {n} session(s): everyone is signed out")
        return 0
    if not args.username:
        raise SystemExit("pass --username <name> or --all")
    with SessionLocal() as db:
        account = _account_or_fail(db, args.username)
        account_id, username = account.id, account.username
    n = sessions.revoke_account(account_id, reason="cli")
    audit_log.record("session.revoke", entity_type="account", entity_key=username,
                     detail={"count": n, "via": "cli"})
    print(f"revoked {n} session(s) of {username}")
    return 0


def cmd_sessions_prune(_: argparse.Namespace) -> int:
    n = sessions.prune()
    with SessionLocal() as db:
        t = directory.prune_tokens(db)
    print(f"pruned {n} expired or old revoked session(s) and {t} used or old token(s)")
    return 0


def cmd_audit_prune(args: argparse.Namespace) -> int:
    """Keep every row younger than --keep-days; older rows of the noisy actions go."""
    cutoff = sessions.utcnow() - timedelta(days=args.keep_days)
    with SessionLocal() as db:
        result = db.execute(delete(AuditLog).where(AuditLog.at < cutoff, AuditLog.action.in_(NOISY_ACTIONS)))
        db.commit()
    print(f"pruned {result.rowcount or 0} audit row(s) older than {args.keep_days} days "
          f"({', '.join(NOISY_ACTIONS)}); every other action is kept")
    return 0


def register(sub: argparse._SubParsersAction) -> None:
    accounts = sub.add_parser("accounts", help="accounts: list, owner-check, bench, and the credential commands")
    asub = accounts.add_subparsers(dest="accounts_command", required=True)

    create = asub.add_parser("create", help="an account row and its level; no password yet")
    create.add_argument("--username", required=True)
    create.add_argument("--email", required=True)
    create.add_argument("--display-name", required=True)
    create.add_argument("--level", required=True, help="a level from the Levels editor (admin, member)")
    create.set_defaults(func=cmd_accounts_create)

    setpw = asub.add_parser("set-password", help="set a password (prompted); clears the lock; signs out everywhere")
    setpw.add_argument("--username", required=True)
    setpw.set_defaults(func=cmd_accounts_set_password)

    for name, fn, help_ in (
        ("invite-link", cmd_accounts_invite_link, "print a one-time welcome link (48 h)"),
        ("reset-link", cmd_accounts_reset_link, "print a one-time reset link (48 h)"),
        ("disable", cmd_accounts_disable, "disable an account and revoke its sessions"),
        ("enable", cmd_accounts_enable, "re-enable a disabled account"),
        ("unlock", cmd_accounts_unlock, "clear a lockout"),
    ):
        p = asub.add_parser(name, help=help_)
        p.add_argument("--username", required=True)
        p.set_defaults(func=fn)

    listing = asub.add_parser("list", help="every account with status, level and whether a password is set")
    listing.set_defaults(func=cmd_accounts_list)

    owner_check = asub.add_parser("owner-check", help="is ACCOUNT_OWNER set, present and able to sign in?")
    owner_check.set_defaults(func=cmd_accounts_owner_check)

    bench = asub.add_parser("bench", help="the argon2 cost on this host")
    bench.set_defaults(func=cmd_accounts_bench)

    sess = sub.add_parser("sessions", help="sessions: revoke one account's or everyone's, prune old rows")
    ssub = sess.add_subparsers(dest="sessions_command", required=True)
    revoke = ssub.add_parser("revoke", help="sign one account out everywhere, or everyone (--all, the break-glass)")
    revoke.add_argument("--username")
    revoke.add_argument("--all", action="store_true")
    revoke.set_defaults(func=cmd_sessions_revoke)
    prune = ssub.add_parser("prune", help="delete expired sessions, old revoked ones, used and old tokens")
    prune.set_defaults(func=cmd_sessions_prune)

    audit = sub.add_parser("audit-log", help="the audit log: prune the noisy actions to a bounded history")
    ausub = audit.add_subparsers(dest="audit_command", required=True)
    aprune = ausub.add_parser("prune", help="delete login and logout rows older than --keep-days")
    aprune.add_argument("--keep-days", type=int, default=90)
    aprune.set_defaults(func=cmd_audit_prune)
