# retoor <retoor@molodetz.nl>

import argparse
import sys
from devplacepy.database import get_table
from devplacepy.utils import strip_html


def _audit_cli(event_key, summary, metadata=None, target_type=None, target_uid=None, target_label=None, links=None):
    from devplacepy.services.audit import record as audit

    audit.record_system(
        event_key,
        actor_kind="cli",
        actor_role="system",
        origin="cli",
        target_type=target_type,
        target_uid=target_uid,
        target_label=target_label,
        summary=summary,
        metadata=metadata,
        links=links,
    )


def cmd_role_get(args):
    users = get_table("users")
    user = users.find_one(username=args.username)
    if not user:
        print(f"User '{args.username}' not found")
        sys.exit(1)
    print(user.get("role", "member").lower())


def cmd_role_set(args):
    role = args.role.lower()
    if role not in ("member", "admin"):
        print("Role must be 'member' or 'admin'")
        sys.exit(1)

    users = get_table("users")
    user = users.find_one(username=args.username)
    if not user:
        print(f"User '{args.username}' not found")
        sys.exit(1)

    old_role = user.get("role")
    users.update({"uid": user["uid"], "role": role.capitalize()}, ["uid"])
    from devplacepy.services.audit import record as audit

    _audit_cli(
        "cli.role.set",
        f"CLI set role of user {args.username} from {old_role} to {role.capitalize()}",
        metadata={"old": old_role, "new": role.capitalize()},
        target_type="user",
        target_uid=user["uid"],
        target_label=args.username,
        links=[audit.target("user", user["uid"], args.username)],
    )
    print(f"User '{args.username}' role set to '{role}'")


def cmd_apikey_get(args):
    users = get_table("users")
    user = users.find_one(username=args.username)
    if not user:
        print(f"User '{args.username}' not found")
        sys.exit(1)
    print(user.get("api_key", "") or "")


def cmd_apikey_reset(args):
    from devplacepy.utils import generate_uid, clear_user_cache

    users = get_table("users")
    user = users.find_one(username=args.username)
    if not user:
        print(f"User '{args.username}' not found")
        sys.exit(1)
    new_key = generate_uid()
    users.update({"uid": user["uid"], "api_key": new_key}, ["uid"])
    clear_user_cache(user["uid"])
    from devplacepy.services.audit import record as audit

    _audit_cli(
        "cli.apikey.reset",
        f"CLI regenerated the API key of user {args.username}",
        target_type="user",
        target_uid=user["uid"],
        target_label=args.username,
        links=[audit.target("user", user["uid"], args.username)],
    )
    print(new_key)


def cmd_apikey_backfill(args):
    from devplacepy.database import backfill_api_keys

    updated = backfill_api_keys()
    _audit_cli(
        "cli.apikey.backfill",
        f"CLI backfilled API keys for {updated} users",
        metadata={"count": updated},
    )
    print(f"Assigned API keys to {updated} user(s) without one")


def cmd_devii_reset_quota(args):
    from devplacepy.database import db

    table_name = "devii_usage_ledger"
    if table_name not in db.tables:
        print(f"Table '{table_name}' does not exist, nothing to reset")
        return
    table = db[table_name]
    if args.all:
        count = table.count()
        table.delete()
        _audit_cli("cli.devii.quota.reset", "CLI reset AI quota (all)", metadata={"scope": "all", "rows_removed": count})
        print(f"Reset all AI quotas ({count} ledger rows deleted)")
        return
    if args.guests:
        count = table.count(owner_kind="guest")
        table.delete(owner_kind="guest")
        _audit_cli("cli.devii.quota.reset", "CLI reset AI quota (guests)", metadata={"scope": "guests", "rows_removed": count})
        print(f"Reset all guest AI quotas ({count} ledger rows deleted)")
        return
    if not args.username:
        print("Provide a username, or --guests, or --all")
        sys.exit(1)
    user = get_table("users").find_one(username=args.username)
    if not user:
        print(f"User '{args.username}' not found")
        sys.exit(1)
    count = table.count(owner_kind="user", owner_id=user["uid"])
    table.delete(owner_kind="user", owner_id=user["uid"])
    from devplacepy.services.audit import record as audit

    _audit_cli(
        "cli.devii.quota.reset",
        f"CLI reset AI quota for {args.username}",
        metadata={"scope": "user", "rows_removed": count},
        target_type="user",
        target_uid=user["uid"],
        target_label=args.username,
        links=[audit.target("user", user["uid"], args.username)],
    )
    print(f"Reset AI quota for '{args.username}' ({count} ledger rows deleted)")


def cmd_news_clear(args):
    from devplacepy.database import db

    deleted = {}
    for table in ("news", "news_images", "news_sync"):
        if table in db.tables:
            count = db[table].count()
            db[table].delete()
            deleted[table] = count
            print(f"Deleted {count} rows from '{table}'")
        else:
            print(f"Table '{table}' does not exist, skipping")
    _audit_cli("cli.news.clear", "CLI cleared all news data", metadata={"deleted": deleted})
    print("News data cleared")


def cmd_news_sanitize(args):
    from devplacepy.database import db

    if "news" not in db.tables:
        print("News table does not exist")
        return
    news_table = db["news"]
    updated = 0
    for row in news_table.all():
        desc = (strip_html(row.get("description", "") or ""))[:5000]
        content = (strip_html(row.get("content", "") or ""))[:10000]
        if desc != row.get("description", "") or content != row.get("content", ""):
            news_table.update(
                {"id": row["id"], "description": desc, "content": content}, ["id"]
            )
            updated += 1
    _audit_cli("cli.news.sanitize", f"CLI sanitized {updated} news articles", metadata={"count": updated})
    print(f"Sanitized {updated} news article(s)")


def cmd_attachments_prune(args):
    from datetime import datetime, timezone, timedelta
    from devplacepy.database import db
    from devplacepy.attachments import delete_attachment

    if "attachments" not in db.tables:
        print("Attachments table does not exist")
        return

    cutoff = (datetime.now(timezone.utc) - timedelta(hours=args.hours)).isoformat()
    orphans = [
        att
        for att in db["attachments"].find(target_type="", target_uid="")
        if att.get("created_at", "") < cutoff
    ]
    for att in orphans:
        delete_attachment(att["uid"])
    _audit_cli(
        "cli.attachments.prune",
        f"CLI pruned {len(orphans)} orphan attachments",
        metadata={"count": len(orphans), "hours": args.hours},
    )
    print(f"Pruned {len(orphans)} orphan attachment(s) older than {args.hours}h")


def _remove_zip_artifacts(job):
    import shutil
    from pathlib import Path
    from devplacepy.services.jobs.zip_service import STAGING_DIR

    local_path = (job.get("result") or {}).get("local_path")
    if local_path:
        Path(local_path).unlink(missing_ok=True)
    shutil.rmtree(STAGING_DIR / job["uid"], ignore_errors=True)


def cmd_zips_prune(args):
    from datetime import datetime, timezone
    from devplacepy.services.jobs import queue

    now = datetime.now(timezone.utc)
    removed = 0
    for job in queue.list_jobs(kind="zip", status=queue.DONE):
        expires_at = job.get("expires_at")
        if not expires_at:
            continue
        try:
            expiry = datetime.fromisoformat(expires_at)
        except (ValueError, TypeError):
            continue
        if expiry < now:
            _remove_zip_artifacts(job)
            get_table("jobs").delete(uid=job["uid"])
            removed += 1
    _audit_cli("cli.zips.prune", f"CLI pruned {removed} expired zip jobs", metadata={"count": removed})
    print(f"Pruned {removed} expired zip job(s)")


def cmd_zips_clear(args):
    from devplacepy.services.jobs import queue

    jobs = queue.list_jobs(kind="zip")
    for job in jobs:
        _remove_zip_artifacts(job)
        get_table("jobs").delete(uid=job["uid"])
    _audit_cli("cli.zips.clear", f"CLI cleared all zip jobs ({len(jobs)})", metadata={"count": len(jobs)})
    print(f"Cleared {len(jobs)} zip job(s) and their archives")


def cmd_forks_prune(args):
    from datetime import datetime, timezone
    from devplacepy.services.jobs import queue

    now = datetime.now(timezone.utc)
    removed = 0
    for job in queue.list_jobs(kind="fork", status=queue.DONE):
        expires_at = job.get("expires_at")
        if not expires_at:
            continue
        try:
            expiry = datetime.fromisoformat(expires_at)
        except (ValueError, TypeError):
            continue
        if expiry < now:
            get_table("jobs").delete(uid=job["uid"])
            removed += 1
    _audit_cli("cli.forks.prune", f"CLI pruned {removed} expired fork jobs", metadata={"count": removed})
    print(f"Pruned {removed} expired fork job(s)")


def cmd_forks_clear(args):
    from devplacepy.services.jobs import queue

    jobs = queue.list_jobs(kind="fork")
    for job in jobs:
        get_table("jobs").delete(uid=job["uid"])
    _audit_cli("cli.forks.clear", f"CLI cleared all fork jobs ({len(jobs)})", metadata={"count": len(jobs)})
    print(f"Cleared {len(jobs)} fork job(s)")


def cmd_backups_list(args):
    from devplacepy.services.backup import store

    backups = store.list_backups()
    if not backups:
        print("No backups recorded")
        return
    for backup in backups:
        size = store.human_bytes(int(backup.get("size_bytes") or 0))
        print(
            f"{backup['uid']}  {backup.get('target', ''):<9}  "
            f"{backup.get('status', ''):<8}  {size:>10}  "
            f"{backup.get('created_at', '')}  {backup.get('filename', '')}"
        )


def cmd_backups_run(args):
    from devplacepy.services.backup import store
    from devplacepy.services.jobs import queue

    if not store.is_valid_target(args.target):
        print(f"Unknown target '{args.target}'. Choose one of: {', '.join(store.BACKUP_TARGETS)}")
        sys.exit(1)
    job_uid = queue.enqueue(
        "backup",
        {"target": args.target, "schedule_uid": "", "created_by": "cli"},
        owner_kind="system",
        owner_id="cli",
        preferred_name=f"{store.target_label(args.target)} (cli)",
    )
    store.create_backup(target=args.target, created_by="cli", job_uid=job_uid)
    _audit_cli(
        "cli.backups.run",
        f"CLI enqueued {args.target} backup",
        metadata={"target": args.target, "job_uid": job_uid},
    )
    print(f"Enqueued {args.target} backup job {job_uid} (processed by the running server)")


def cmd_backups_prune(args):
    from devplacepy.services.backup import store

    removed = store.prune_orphans()
    _audit_cli("cli.backups.prune", f"CLI pruned {removed} orphan backups", metadata={"count": removed})
    print(f"Pruned {removed} orphan backup record(s)")


def cmd_backups_clear(args):
    from devplacepy.services.backup import store

    removed = store.clear_all()
    _audit_cli("cli.backups.clear", f"CLI cleared all backups ({removed})", metadata={"count": removed})
    print(f"Cleared {removed} backup(s) and their archives")


def _remove_seo_artifacts(job):
    import shutil
    from devplacepy.config import SEO_REPORTS_DIR

    shutil.rmtree(SEO_REPORTS_DIR / job["uid"], ignore_errors=True)


def cmd_seo_prune(args):
    from datetime import datetime, timezone
    from devplacepy.services.jobs import queue

    now = datetime.now(timezone.utc)
    removed = 0
    for job in queue.list_jobs(kind="seo", status=queue.DONE):
        expires_at = job.get("expires_at")
        if not expires_at:
            continue
        try:
            expiry = datetime.fromisoformat(expires_at)
        except (ValueError, TypeError):
            continue
        if expiry < now:
            _remove_seo_artifacts(job)
            get_table("jobs").delete(uid=job["uid"])
            removed += 1
    _audit_cli("cli.seo.prune", f"CLI pruned {removed} expired SEO jobs", metadata={"count": removed})
    print(f"Pruned {removed} expired SEO audit(s)")


def cmd_seo_clear(args):
    from devplacepy.services.jobs import queue

    jobs = queue.list_jobs(kind="seo")
    for job in jobs:
        _remove_seo_artifacts(job)
        get_table("jobs").delete(uid=job["uid"])
    _audit_cli("cli.seo.clear", f"CLI cleared all SEO jobs ({len(jobs)})", metadata={"count": len(jobs)})
    print(f"Cleared {len(jobs)} SEO audit(s) and their reports")


def _remove_deepsearch_artifacts(job):
    import shutil
    from devplacepy.config import DEEPSEARCH_DIR
    from devplacepy.services.deepsearch.store import VectorStore

    uid = job["uid"]
    collection = f"ds_{uid.replace('-', '')}"
    VectorStore(collection).drop()
    shutil.rmtree(DEEPSEARCH_DIR / uid, ignore_errors=True)


def cmd_deepsearch_prune(args):
    from datetime import datetime, timezone
    from devplacepy.services.jobs import queue

    now = datetime.now(timezone.utc)
    removed = 0
    for job in queue.list_jobs(kind="deepsearch", status=queue.DONE):
        expires_at = job.get("expires_at")
        if not expires_at:
            continue
        try:
            expiry = datetime.fromisoformat(expires_at)
        except (ValueError, TypeError):
            continue
        if expiry < now:
            _remove_deepsearch_artifacts(job)
            get_table("jobs").delete(uid=job["uid"])
            removed += 1
    _audit_cli(
        "cli.deepsearch.prune",
        f"CLI pruned {removed} expired DeepSearch jobs",
        metadata={"count": removed},
    )
    print(f"Pruned {removed} expired DeepSearch job(s)")


def cmd_deepsearch_clear(args):
    from devplacepy.services.jobs import queue

    jobs = queue.list_jobs(kind="deepsearch")
    for job in jobs:
        _remove_deepsearch_artifacts(job)
        get_table("jobs").delete(uid=job["uid"])
    _audit_cli(
        "cli.deepsearch.clear",
        f"CLI cleared all DeepSearch jobs ({len(jobs)})",
        metadata={"count": len(jobs)},
    )
    print(f"Cleared {len(jobs)} DeepSearch job(s) and their collections")


def cmd_containers_list(args):
    from devplacepy.services.containers import store

    instances = store.all_instances()
    if not instances:
        print("No container instances")
        return
    for inst in instances:
        print(
            f"{inst['uid'][:8]} {inst.get('name', ''):24.24} {inst.get('status', ''):10} "
            f"desired={inst.get('desired_state', '')} policy={inst.get('restart_policy', '')}"
        )


def cmd_containers_reconcile(args):
    import asyncio
    from devplacepy.services.containers.service import ContainerService

    asyncio.run(ContainerService().run_once())
    _audit_cli("cli.containers.reconcile", "CLI ran one container reconcile pass")
    print("Reconcile pass complete")


def cmd_containers_prune(args):
    import asyncio
    from devplacepy.services.containers.runtime import get_backend
    from devplacepy.services.containers.service import ContainerService

    async def run():
        await ContainerService().run_once()
        await get_backend().image_prune()

    asyncio.run(run())
    _audit_cli("cli.containers.prune", "CLI reaped orphan containers and dangling images")
    print("Reaped orphans and pruned dangling images")


def cmd_containers_prune_builds(args):
    import asyncio
    from devplacepy.database import db, get_table
    from devplacepy.services.containers.runtime import get_backend

    async def run():
        backend = get_backend()
        removed = 0
        if "builds" in db.tables:
            for build in list(get_table("builds").find()):
                tag = build.get("image_tag")
                if tag:
                    await backend.remove_image(tag)
                    removed += 1
        for table in ("builds", "dockerfile_versions", "dockerfiles"):
            if table in db.tables:
                get_table(table).delete()
        return removed

    removed = asyncio.run(run())
    _audit_cli("cli.containers.prune_builds", "CLI removed legacy images and build tables", metadata={"removed": removed})
    print(
        f"Removed {removed} legacy per-project image(s) and cleared the dockerfiles/builds tables"
    )


def cmd_containers_gc_workspaces(args):
    import shutil
    from pathlib import Path
    from devplacepy import config
    from devplacepy.services.containers import store

    active = {inst["project_uid"] for inst in store.all_instances()}
    base = Path(config.CONTAINER_WORKSPACES_DIR)
    removed = 0
    if base.is_dir():
        for child in base.iterdir():
            if child.is_dir() and child.name not in active:
                shutil.rmtree(child, ignore_errors=True)
                removed += 1
    _audit_cli("cli.containers.gc_workspaces", f"CLI removed {removed} unused workspace dirs", metadata={"count": removed})
    print(
        f"Removed {removed} unused workspace director{'y' if removed == 1 else 'ies'}"
    )


def _crc32(path):
    import zlib

    crc = 0
    with open(path, "rb") as handle:
        while True:
            chunk = handle.read(1024 * 1024)
            if not chunk:
                break
            crc = zlib.crc32(chunk, crc)
    return crc & 0xFFFFFFFF


def _migrate_file(source, dest, dry_run, report):
    import os
    import shutil

    if not source.exists():
        return
    if source.resolve() == dest.resolve():
        return
    size = source.stat().st_size
    if dest.exists():
        if dest.stat().st_size == size and _crc32(dest) == _crc32(source):
            report.append(("done", source, dest, size))
            if not dry_run:
                source.unlink()
            return
        report.append(("conflict", source, dest, size))
        return
    report.append(("move", source, dest, size))
    if dry_run:
        return
    dest.parent.mkdir(parents=True, exist_ok=True)
    tmp = dest.with_name(dest.name + ".migrating")
    shutil.copyfile(source, tmp)
    with open(tmp, "rb") as handle:
        os.fsync(handle.fileno())
    if tmp.stat().st_size != size or _crc32(tmp) != _crc32(source):
        tmp.unlink(missing_ok=True)
        raise RuntimeError(f"verification failed copying {source} -> {dest}")
    os.replace(tmp, dest)
    source.unlink()


def _prune_empty_dirs(root):
    if not root.exists():
        return
    for path in sorted(root.rglob("*"), reverse=True):
        if path.is_dir():
            try:
                path.rmdir()
            except OSError:
                pass
    try:
        root.rmdir()
    except OSError:
        pass


def _migrate_tree(source, dest, dry_run, report):
    if not source.exists():
        return
    if source.resolve() == dest.resolve():
        return
    for child in sorted(source.rglob("*")):
        if child.is_file():
            _migrate_file(child, dest / child.relative_to(source), dry_run, report)
    if not dry_run:
        _prune_empty_dirs(source)


def _db_is_locked(path):
    import sqlite3

    try:
        conn = sqlite3.connect(str(path), timeout=0.5)
        try:
            conn.execute("BEGIN IMMEDIATE")
            conn.rollback()
            return False
        finally:
            conn.close()
    except sqlite3.OperationalError:
        return True


def _checkpoint(path):
    import sqlite3

    conn = sqlite3.connect(str(path), timeout=5)
    try:
        conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
        conn.commit()
    finally:
        conn.close()


def _migrate_db(source, dest, dry_run, report):
    if not source.exists():
        return
    if source.resolve() == dest.resolve():
        return
    if _db_is_locked(source):
        raise RuntimeError(
            f"{source} is locked - stop the app before running migrate-data"
        )
    if not dry_run:
        _checkpoint(source)
    _migrate_file(source, dest, dry_run, report)
    for suffix in ("-wal", "-shm"):
        _migrate_file(
            source.with_name(source.name + suffix),
            dest.with_name(dest.name + suffix),
            dry_run,
            report,
        )


def cmd_migrate_data(args):
    import os
    from pathlib import Path
    from collections import Counter
    from devplacepy import config

    base = config.BASE_DIR
    home = Path.home()
    dry = args.dry_run
    report = []

    config.ensure_data_dirs()

    db_items = []
    if config.DATABASE_URL == f"sqlite:///{config.DATA_DIR / 'devplace.db'}":
        db_items.append((base / "devplace.db", config.DATA_DIR / "devplace.db"))
    else:
        print("Skipping main DB: DEVPLACE_DATABASE_URL points outside the data dir.")
    if not os.environ.get("DEVII_TASKS_DB"):
        db_items.append((base / "devii_tasks.db", config.DEVII_TASKS_DB))
    if not os.environ.get("DEVII_LESSONS_DB"):
        db_items.append((base / "devii_lessons.db", config.DEVII_LESSONS_DB))

    file_items = [
        (base / name, config.KEYS_DIR / name)
        for name in (
            "notification-private.pem",
            "notification-private.pkcs8.pem",
            "notification-public.pem",
        )
    ]
    registry_dest = config.BOT_DIR / "article_registry.json"
    registry_sources = [
        path
        for path in (
            home / ".dpbot_article_registry.json",
            base / ".dpbot_article_registry.json",
        )
        if path.exists()
    ]
    registry_sources.sort(key=lambda path: path.stat().st_mtime, reverse=True)
    if registry_sources:
        file_items.append((registry_sources[0], registry_dest))
    for stale in registry_sources[1:]:
        print(f"Leaving older duplicate registry untouched: {stale}")

    legacy_var = base / "var"
    tree_items = [
        (base / "devplacepy" / "static" / "uploads", config.UPLOADS_DIR),
        (home / ".devplace_bots", config.BOT_DIR),
    ]
    for sub_name in ("container_workspaces", "zips", "zip_staging", "fork_staging"):
        tree_items.append((legacy_var / sub_name, config.DATA_PATHS[sub_name]))

    try:
        for source, dest in db_items:
            _migrate_db(source, dest, dry, report)
        for source, dest in file_items:
            _migrate_file(source, dest, dry, report)
        for source, dest in tree_items:
            _migrate_tree(source, dest, dry, report)
    except RuntimeError as exc:
        print(f"ERROR: {exc}")
        sys.exit(1)

    if not report:
        print("Nothing to migrate; the data directory is already consolidated.")
        return
    for status, source, dest, size in report:
        print(f"  [{status}] {source} -> {dest} ({size} bytes)")
    counts = Counter(status for status, *_ in report)
    print()
    print(
        ("Planned: " if dry else "Migrated: ")
        + ", ".join(f"{count} {status}" for status, count in sorted(counts.items()))
    )
    if any(status == "conflict" for status, *_ in report):
        print(
            "Conflicts left both source and destination untouched; resolve them by hand."
        )
    if dry:
        print("Dry run - nothing changed. Re-run without --dry-run to apply.")


def main():
    parser = argparse.ArgumentParser(description="DevPlace admin CLI")
    sub = parser.add_subparsers(title="commands", dest="command")

    role = sub.add_parser("role", help="Manage user roles")
    role_sub = role.add_subparsers(title="action", dest="action")

    role_get = role_sub.add_parser("get", help="Get a user's role")
    role_get.add_argument("username")
    role_get.set_defaults(func=cmd_role_get)

    role_set = role_sub.add_parser("set", help="Set a user's role")
    role_set.add_argument("username")
    role_set.add_argument("role", choices=["member", "admin"])
    role_set.set_defaults(func=cmd_role_set)

    apikey = sub.add_parser("apikey", help="Manage user API keys")
    apikey_sub = apikey.add_subparsers(title="action", dest="action")
    apikey_get = apikey_sub.add_parser("get", help="Print a user's API key")
    apikey_get.add_argument("username")
    apikey_get.set_defaults(func=cmd_apikey_get)
    apikey_reset = apikey_sub.add_parser("reset", help="Regenerate a user's API key")
    apikey_reset.add_argument("username")
    apikey_reset.set_defaults(func=cmd_apikey_reset)
    apikey_backfill = apikey_sub.add_parser(
        "backfill", help="Assign API keys to users that lack one"
    )
    apikey_backfill.set_defaults(func=cmd_apikey_backfill)

    news = sub.add_parser("news", help="News management")
    news_sub = news.add_subparsers(title="action", dest="action")
    news_clear = news_sub.add_parser(
        "clear", help="Delete all news from local database"
    )
    news_clear.set_defaults(func=cmd_news_clear)
    news_sanitize = news_sub.add_parser(
        "sanitize", help="Strip HTML from all existing news descriptions and content"
    )
    news_sanitize.set_defaults(func=cmd_news_sanitize)

    attachments = sub.add_parser("attachments", help="Attachment management")
    att_sub = attachments.add_subparsers(title="action", dest="action")
    att_prune = att_sub.add_parser(
        "prune", help="Remove orphaned attachment records and files"
    )
    att_prune.add_argument(
        "--hours",
        type=int,
        default=24,
        help="Only prune orphans older than this many hours",
    )
    att_prune.set_defaults(func=cmd_attachments_prune)

    devii = sub.add_parser("devii", help="Devii assistant management")
    devii_sub = devii.add_subparsers(title="action", dest="action")
    devii_reset = devii_sub.add_parser(
        "reset-quota", help="Reset the rolling 24h AI spend quota"
    )
    devii_reset.add_argument(
        "username", nargs="?", help="Reset the quota for a single user"
    )
    devii_reset.add_argument(
        "--guests", action="store_true", help="Reset every guest quota"
    )
    devii_reset.add_argument(
        "--all", action="store_true", help="Reset every quota (users and guests)"
    )
    devii_reset.set_defaults(func=cmd_devii_reset_quota)

    zips = sub.add_parser("zips", help="Zip archive job management")
    zips_sub = zips.add_subparsers(title="action", dest="action")
    zips_prune = zips_sub.add_parser(
        "prune", help="Delete expired zip archives and their job rows"
    )
    zips_prune.set_defaults(func=cmd_zips_prune)
    zips_clear = zips_sub.add_parser(
        "clear", help="Delete every zip archive and job row"
    )
    zips_clear.set_defaults(func=cmd_zips_clear)

    forks = sub.add_parser("forks", help="Fork job management")
    forks_sub = forks.add_subparsers(title="action", dest="action")
    forks_prune = forks_sub.add_parser(
        "prune", help="Delete expired completed fork job rows (forked projects persist)"
    )
    forks_prune.set_defaults(func=cmd_forks_prune)
    forks_clear = forks_sub.add_parser(
        "clear", help="Delete every fork job row (forked projects persist)"
    )
    forks_clear.set_defaults(func=cmd_forks_clear)

    backups = sub.add_parser("backups", help="Backup management")
    backups_sub = backups.add_subparsers(title="action", dest="action")
    backups_sub.add_parser("list", help="List recorded backups").set_defaults(
        func=cmd_backups_list
    )
    backups_run = backups_sub.add_parser(
        "run", help="Enqueue a backup (processed by the running server)"
    )
    backups_run.add_argument(
        "target",
        choices=["database", "uploads", "keys", "full"],
        help="What to back up",
    )
    backups_run.set_defaults(func=cmd_backups_run)
    backups_sub.add_parser(
        "prune", help="Remove backup records whose archive file is missing"
    ).set_defaults(func=cmd_backups_prune)
    backups_sub.add_parser(
        "clear", help="Delete every backup archive and record"
    ).set_defaults(func=cmd_backups_clear)

    seo = sub.add_parser("seo", help="SEO Diagnostics job management")
    seo_sub = seo.add_subparsers(title="action", dest="action")
    seo_prune = seo_sub.add_parser(
        "prune", help="Delete expired SEO audit reports and their job rows"
    )
    seo_prune.set_defaults(func=cmd_seo_prune)
    seo_clear = seo_sub.add_parser(
        "clear", help="Delete every SEO audit report and job row"
    )
    seo_clear.set_defaults(func=cmd_seo_clear)

    deepsearch = sub.add_parser("deepsearch", help="DeepSearch job management")
    deepsearch_sub = deepsearch.add_subparsers(title="action", dest="action")
    deepsearch_prune = deepsearch_sub.add_parser(
        "prune", help="Delete expired DeepSearch sessions and their job rows"
    )
    deepsearch_prune.set_defaults(func=cmd_deepsearch_prune)
    deepsearch_clear = deepsearch_sub.add_parser(
        "clear", help="Delete every DeepSearch session and job row"
    )
    deepsearch_clear.set_defaults(func=cmd_deepsearch_clear)

    containers = sub.add_parser("containers", help="Container manager")
    containers_sub = containers.add_subparsers(title="action", dest="action")
    containers_sub.add_parser("list", help="List container instances").set_defaults(
        func=cmd_containers_list
    )
    containers_sub.add_parser("reconcile", help="Run one reconcile pass").set_defaults(
        func=cmd_containers_reconcile
    )
    containers_sub.add_parser(
        "prune", help="Reap orphan containers and dangling images"
    ).set_defaults(func=cmd_containers_prune)
    containers_sub.add_parser(
        "prune-builds",
        help="Remove legacy per-project images and clear the dockerfiles/builds tables",
    ).set_defaults(func=cmd_containers_prune_builds)
    containers_sub.add_parser(
        "gc-workspaces", help="Remove workspace dirs with no instances"
    ).set_defaults(func=cmd_containers_gc_workspaces)

    migrate = sub.add_parser(
        "migrate-data",
        help="Relocate legacy runtime files into the consolidated data/ directory",
    )
    migrate.add_argument(
        "--dry-run",
        action="store_true",
        help="Print the source-to-destination plan without changing anything",
    )
    migrate.set_defaults(func=cmd_migrate_data)

    args = parser.parse_args()
    if hasattr(args, "func"):
        args.func(args)
    else:
        parser.print_help()
        sys.exit(1)


if __name__ == "__main__":
    main()
