#!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
import os
import re
import shutil
import stat
import sys
import tempfile
import textwrap
import time
import webbrowser
from pathlib import Path
from typing import Any, Callable
from urllib.error import HTTPError, URLError
from urllib.parse import quote, urlencode
from urllib.request import Request, urlopen


CLI_VERSION = "0.8.6"
DEFAULT_TIMEOUT_SECONDS = 60
DEFAULT_PAGE_SIZE = 20
MAX_PAGE_SIZE = 200
ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
GYMNASIEELEVEN_RSS_FEEDS: tuple[dict[str, str], ...] = (
    {"url": "https://www.tv2fyn.dk/rss", "name": "TV2 Fyn", "region": "Fyn", "source_family": "TV2"},
    {"url": "https://www.tv2east.dk/rss", "name": "TV2 Øst", "region": "Sjælland", "source_family": "TV2"},
    {"url": "https://www.tv2kosmopol.dk/rss", "name": "TV2 Kosmopol", "region": "København", "source_family": "TV2"},
    {"url": "https://feeds.services.tv2.dk/api/feeds/nyheder/rss", "name": "TV2 Nyheder", "region": "National", "source_family": "TV2"},
    {"url": "https://www.tvsyd.dk/rss", "name": "TV Syd", "region": "Syddanmark", "source_family": "TV2"},
    {"url": "https://www.tvmidtvest.dk/rss", "name": "TV Midtvest", "region": "Midtjylland", "source_family": "TV2"},
    {"url": "https://www.tv2nord.dk/rss", "name": "TV2 Nord", "region": "Nordjylland", "source_family": "TV2"},
    {"url": "https://www.dr.dk/nyheder/service/feeds/senestenyt", "name": "DR Seneste Nyt", "region": "National", "source_family": "DR"},
    {"url": "https://www.dr.dk/nyheder/service/feeds/indland", "name": "DR Indland", "region": "National", "source_family": "DR"},
    {"url": "https://www.dr.dk/nyheder/service/feeds/udland", "name": "DR Udland", "region": "National", "source_family": "DR"},
    {"url": "https://www.dr.dk/nyheder/service/feeds/penge", "name": "DR Penge", "region": "National", "source_family": "DR"},
    {"url": "https://www.dr.dk/nyheder/service/feeds/politik", "name": "DR Politik", "region": "National", "source_family": "DR"},
    {"url": "https://www.dr.dk/nyheder/service/feeds/sporten", "name": "DR Sporten", "region": "National", "source_family": "DR"},
    {"url": "https://www.dr.dk/nyheder/service/feeds/viden", "name": "DR Viden", "region": "National", "source_family": "DR"},
    {"url": "https://www.dr.dk/nyheder/service/feeds/kultur", "name": "DR Kultur", "region": "National", "source_family": "DR"},
    {"url": "https://www.dr.dk/nyheder/service/feeds/regionale", "name": "DR Regionale", "region": "National", "source_family": "DR"},
    {"url": "https://www.dr.dk/nyheder/service/feeds/regionale/kbh", "name": "DR Hovedstadsområdet", "region": "København", "source_family": "DR"},
    {"url": "https://www.dr.dk/nyheder/service/feeds/regionale/bornholm", "name": "DR Bornholm", "region": "Bornholm", "source_family": "DR"},
    {"url": "https://www.dr.dk/nyheder/service/feeds/regionale/syd", "name": "DR Syd og Sønderjylland", "region": "Syddanmark", "source_family": "DR"},
    {"url": "https://www.dr.dk/nyheder/service/feeds/regionale/fyn", "name": "DR Fyn", "region": "Fyn", "source_family": "DR"},
    {"url": "https://www.dr.dk/nyheder/service/feeds/regionale/vest", "name": "DR Midt- og Vestjylland", "region": "Midtjylland", "source_family": "DR"},
    {"url": "https://www.dr.dk/nyheder/service/feeds/regionale/nord", "name": "DR Nordjylland", "region": "Nordjylland", "source_family": "DR"},
    {"url": "https://www.dr.dk/nyheder/service/feeds/regionale/trekanten", "name": "DR Trekantområdet", "region": "Syddanmark", "source_family": "DR"},
    {"url": "https://www.dr.dk/nyheder/service/feeds/regionale/sjaelland", "name": "DR Sjælland", "region": "Sjælland", "source_family": "DR"},
    {"url": "https://www.dr.dk/nyheder/service/feeds/regionale/oestjylland", "name": "DR Østjylland", "region": "Midtjylland", "source_family": "DR"},
    {"url": "https://digitalt.tv/kategori/nyheder/feed/", "name": "Digitalt.tv", "region": "National", "source_family": "Digitalt.tv"},
)
DANISH_COVERAGE_RSS_FEEDS: tuple[dict[str, str], ...] = (
    {"url": "https://www.dust2.dk/rss", "name": "Dust2.dk", "region": "National", "source_family": "Dust2", "topic": "esport"},
    {"url": "https://indkast.dk/rss", "name": "Indkast", "region": "National", "source_family": "Indkast", "topic": "esport"},
    {"url": "https://www.berlingske.dk/content/rss", "name": "Berlingske", "region": "National", "source_family": "Berlingske", "topic": "general-news"},
    {"url": "https://feeds.thelocal.com/rss/dk", "name": "The Local Denmark", "region": "National", "source_family": "The Local", "topic": "english-denmark-news", "language": "en"},
    {"url": "https://feeds.jp.dk/jp/topnyheder", "name": "Jyllands-Posten Topnyheder", "region": "National", "source_family": "Jyllands-Posten", "topic": "general-news"},
    {"url": "https://www.odense.dk/feed.aspx", "name": "Odense Kommune", "region": "Fyn", "source_family": "Odense Kommune", "topic": "municipality"},
    {"url": "https://news.ku.dk/all_news/?get_rss=1", "name": "Kobenhavns Universitet News", "region": "Kobenhavn", "source_family": "KU", "topic": "education"},
    {"url": "https://news.ku.dk/university-life/?get_rss=1", "name": "Kobenhavns Universitet University Life", "region": "Kobenhavn", "source_family": "KU", "topic": "student-life"},
    {"url": "https://heavymetal.dk/rss", "name": "Heavymetal.dk", "region": "National", "source_family": "Heavymetal.dk", "topic": "music-culture"},
    {"url": "https://heavymetal.dk/nyheder.rss", "name": "Heavymetal.dk Nyheder", "region": "National", "source_family": "Heavymetal.dk", "topic": "music-culture"},
    {"url": "https://ue.dk/feed/alle-nyheder.rss", "name": "UgensErhverv", "region": "National", "source_family": "UgensErhverv", "topic": "business"},
    {"url": "https://www.beredskabsinfo.dk/feed/", "name": "BeredskabsInfo", "region": "National", "source_family": "BeredskabsInfo", "topic": "public-safety"},
    {"url": "https://cphpost.dk/feed/", "name": "The Copenhagen Post", "region": "Kobenhavn", "source_family": "The Copenhagen Post", "topic": "english-denmark-news", "language": "en"},
)
DANISH_COVERAGE_WEBSITE_TARGETS: tuple[dict[str, str], ...] = (
    {"url": "https://www.kk.dk/nyheder", "name": "Kobenhavns Kommune Nyheder", "region": "Kobenhavn", "source_family": "Kobenhavns Kommune", "topic": "municipality"},
    {"url": "https://www.aarhus.dk/nyheder", "name": "Aarhus Kommune Nyheder", "region": "Midtjylland", "source_family": "Aarhus Kommune", "topic": "municipality"},
    {"url": "https://www.aalborg.dk/nyheder", "name": "Aalborg Kommune Nyheder", "region": "Nordjylland", "source_family": "Aalborg Kommune", "topic": "municipality"},
    {"url": "https://www.visitcopenhagen.dk/koebenhavn/aktiviteter/det-sker/events-copenhagen", "name": "VisitCopenhagen Events", "region": "Kobenhavn", "source_family": "VisitCopenhagen", "topic": "events"},
    {"url": "https://www.visitdenmark.com/press/event-calendar/event-calendar", "name": "VisitDenmark Event Calendar", "region": "National", "source_family": "VisitDenmark", "topic": "events"},
    {"url": "https://www.kultunaut.dk/", "name": "KultuNaut", "region": "National", "source_family": "KultuNaut", "topic": "events"},
    {"url": "https://migogaarhus.dk/", "name": "MigogAarhus", "region": "Midtjylland", "source_family": "MigogAarhus", "topic": "city-culture"},
    {"url": "https://www.soundvenue.com/", "name": "Soundvenue", "region": "National", "source_family": "Soundvenue", "topic": "music-culture"},
    {"url": "https://gaffa.dk/", "name": "GAFFA", "region": "National", "source_family": "GAFFA", "topic": "music-culture"},
)
INTERNATIONAL_COVERAGE_RSS_FEEDS: tuple[dict[str, str], ...] = (
    {"url": "https://feeds.bbci.co.uk/news/rss.xml", "name": "BBC News", "region": "Global", "source_family": "BBC", "topic": "world-news", "language": "en"},
    {"url": "https://feeds.bbci.co.uk/news/world/rss.xml", "name": "BBC World", "region": "Global", "source_family": "BBC", "topic": "world-news", "language": "en"},
    {"url": "https://feeds.bbci.co.uk/news/entertainment_and_arts/rss.xml", "name": "BBC Entertainment & Arts", "region": "Global", "source_family": "BBC", "topic": "culture", "language": "en"},
    {"url": "https://feeds.bbci.co.uk/sport/rss.xml", "name": "BBC Sport", "region": "Global", "source_family": "BBC", "topic": "sport", "language": "en", "source_type": "sport"},
    {"url": "https://feeds.bbci.co.uk/sport/football/rss.xml", "name": "BBC Football", "region": "Global", "source_family": "BBC", "topic": "football", "language": "en", "source_type": "sport"},
    {"url": "https://www.theguardian.com/world/rss", "name": "The Guardian World", "region": "Global", "source_family": "The Guardian", "topic": "world-news", "language": "en"},
    {"url": "https://www.theguardian.com/sport/rss", "name": "The Guardian Sport", "region": "Global", "source_family": "The Guardian", "topic": "sport", "language": "en", "source_type": "sport"},
    {"url": "https://www.theguardian.com/football/rss", "name": "The Guardian Football", "region": "Global", "source_family": "The Guardian", "topic": "football", "language": "en", "source_type": "sport"},
    {"url": "https://www.theguardian.com/formulaone/rss", "name": "The Guardian Formula One", "region": "Global", "source_family": "The Guardian", "topic": "formula-one", "language": "en", "source_type": "sport"},
    {"url": "https://www.theguardian.com/music/rss", "name": "The Guardian Music", "region": "Global", "source_family": "The Guardian", "topic": "music", "language": "en", "source_type": "culture"},
    {"url": "https://www.theguardian.com/culture/rss", "name": "The Guardian Culture", "region": "Global", "source_family": "The Guardian", "topic": "culture", "language": "en", "source_type": "culture"},
    {"url": "https://www.theguardian.com/fashion/rss", "name": "The Guardian Fashion", "region": "Global", "source_family": "The Guardian", "topic": "fashion", "language": "en", "source_type": "lifestyle"},
    {"url": "https://www.theguardian.com/lifeandstyle/rss", "name": "The Guardian Life and Style", "region": "Global", "source_family": "The Guardian", "topic": "lifestyle", "language": "en", "source_type": "lifestyle"},
    {"url": "https://www.theguardian.com/food/rss", "name": "The Guardian Food", "region": "Global", "source_family": "The Guardian", "topic": "food", "language": "en", "source_type": "lifestyle"},
    {"url": "https://www.theguardian.com/travel/rss", "name": "The Guardian Travel", "region": "Global", "source_family": "The Guardian", "topic": "travel", "language": "en", "source_type": "lifestyle"},
    {"url": "https://feeds.npr.org/1001/rss.xml", "name": "NPR News", "region": "US", "source_family": "NPR", "topic": "world-news", "language": "en"},
    {"url": "https://feeds.npr.org/1008/rss.xml", "name": "NPR News - Arts & Life", "region": "US", "source_family": "NPR", "topic": "culture", "language": "en", "source_type": "culture"},
    {"url": "https://feeds.npr.org/1039/rss.xml", "name": "NPR Music", "region": "US", "source_family": "NPR", "topic": "music", "language": "en", "source_type": "culture"},
    {"url": "https://rss.nytimes.com/services/xml/rss/nyt/World.xml", "name": "New York Times World", "region": "Global", "source_family": "New York Times", "topic": "world-news", "language": "en"},
    {"url": "https://rss.nytimes.com/services/xml/rss/nyt/Sports.xml", "name": "New York Times Sports", "region": "US", "source_family": "New York Times", "topic": "sport", "language": "en", "source_type": "sport"},
    {"url": "https://rss.nytimes.com/services/xml/rss/nyt/Music.xml", "name": "New York Times Music", "region": "US", "source_family": "New York Times", "topic": "music", "language": "en", "source_type": "culture"},
    {"url": "https://rss.nytimes.com/services/xml/rss/nyt/FashionandStyle.xml", "name": "New York Times Fashion & Style", "region": "US", "source_family": "New York Times", "topic": "fashion", "language": "en", "source_type": "lifestyle"},
    {"url": "https://www.aljazeera.com/xml/rss/all.xml", "name": "Al Jazeera", "region": "Global", "source_family": "Al Jazeera", "topic": "world-news", "language": "en"},
    {"url": "https://pitchfork.com/feed/feed-news/rss", "name": "Pitchfork News", "region": "Global", "source_family": "Pitchfork", "topic": "music", "language": "en", "source_type": "culture"},
    {"url": "https://pitchfork.com/feed/feed-album-reviews/rss", "name": "Pitchfork Album Reviews", "region": "Global", "source_family": "Pitchfork", "topic": "music-reviews", "language": "en", "source_type": "culture"},
    {"url": "https://pitchfork.com/feed/feed-track-reviews/rss", "name": "Pitchfork Track Reviews", "region": "Global", "source_family": "Pitchfork", "topic": "music-reviews", "language": "en", "source_type": "culture"},
    {"url": "https://www.nme.com/feed", "name": "NME", "region": "Global", "source_family": "NME", "topic": "music", "language": "en", "source_type": "culture"},
    {"url": "https://www.rollingstone.com/music/music-news/feed/", "name": "Rolling Stone Music News", "region": "US", "source_family": "Rolling Stone", "topic": "music", "language": "en", "source_type": "culture"},
    {"url": "https://www.stereogum.com/feed/", "name": "Stereogum", "region": "US", "source_family": "Stereogum", "topic": "music", "language": "en", "source_type": "culture"},
    {"url": "https://consequence.net/feed/", "name": "Consequence", "region": "US", "source_family": "Consequence", "topic": "music-culture", "language": "en", "source_type": "culture"},
    {"url": "https://www.billboard.com/feed/", "name": "Billboard", "region": "US", "source_family": "Billboard", "topic": "music-industry", "language": "en", "source_type": "culture"},
    {"url": "https://www.vogue.com/feed/rss", "name": "Vogue", "region": "Global", "source_family": "Vogue", "topic": "fashion", "language": "en", "source_type": "lifestyle"},
    {"url": "https://www.gq.com/feed/rss", "name": "GQ", "region": "Global", "source_family": "GQ", "topic": "mens-style", "language": "en", "source_type": "lifestyle"},
    {"url": "https://www.teenvogue.com/feed/fashion/rss", "name": "Teen Vogue Fashion", "region": "US", "source_family": "Teen Vogue", "topic": "fashion", "language": "en", "source_type": "lifestyle"},
    {"url": "https://www.elle.com/rss/fashion.xml", "name": "ELLE Fashion", "region": "US", "source_family": "ELLE", "topic": "fashion", "language": "en", "source_type": "lifestyle"},
    {"url": "https://www.elle.com/rss/beauty.xml", "name": "ELLE Beauty", "region": "US", "source_family": "ELLE", "topic": "beauty", "language": "en", "source_type": "lifestyle"},
    {"url": "https://www.elle.com/rss/life-love.xml", "name": "ELLE Life & Love", "region": "US", "source_family": "ELLE", "topic": "lifestyle", "language": "en", "source_type": "lifestyle"},
    {"url": "https://www.harpersbazaar.com/fashion/fashion-rss/", "name": "Harper's Bazaar Fashion", "region": "US", "source_family": "Harper's Bazaar", "topic": "fashion", "language": "en", "source_type": "lifestyle"},
    {"url": "https://www.harpersbazaar.com/beauty/beauty-rss/", "name": "Harper's Bazaar Beauty", "region": "US", "source_family": "Harper's Bazaar", "topic": "beauty", "language": "en", "source_type": "lifestyle"},
    {"url": "https://www.harpersbazaar.com/celebrity/celebrity-rss/", "name": "Harper's Bazaar Celebrity", "region": "US", "source_family": "Harper's Bazaar", "topic": "celebrity-style", "language": "en", "source_type": "lifestyle"},
    {"url": "https://www.esquire.com/rss/style.xml", "name": "Esquire Style", "region": "US", "source_family": "Esquire", "topic": "mens-style", "language": "en", "source_type": "lifestyle"},
    {"url": "https://www.esquire.com/rss/lifestyle.xml", "name": "Esquire Lifestyle", "region": "US", "source_family": "Esquire", "topic": "lifestyle", "language": "en", "source_type": "lifestyle"},
    {"url": "https://www.esquire.com/rss/food-drink.xml", "name": "Esquire Food & Drink", "region": "US", "source_family": "Esquire", "topic": "food-drink", "language": "en", "source_type": "lifestyle"},
    {"url": "https://hypebeast.com/feed", "name": "Hypebeast", "region": "Global", "source_family": "Hypebeast", "topic": "streetwear-lifestyle", "language": "en", "source_type": "lifestyle"},
    {"url": "https://www.highsnobiety.com/feed/", "name": "Highsnobiety", "region": "Global", "source_family": "Highsnobiety", "topic": "streetwear-lifestyle", "language": "en", "source_type": "lifestyle"},
    {"url": "https://www.dezeen.com/feed/", "name": "Dezeen", "region": "Global", "source_family": "Dezeen", "topic": "design", "language": "en", "source_type": "lifestyle"},
    {"url": "https://www.designboom.com/feed/", "name": "Designboom", "region": "Global", "source_family": "Designboom", "topic": "design", "language": "en", "source_type": "lifestyle"},
    {"url": "https://www.eater.com/rss/index.xml", "name": "Eater", "region": "US", "source_family": "Eater", "topic": "food", "language": "en", "source_type": "lifestyle"},
    {"url": "https://www.theverge.com/rss/index.xml", "name": "The Verge", "region": "Global", "source_family": "The Verge", "topic": "technology", "language": "en", "source_type": "technology"},
    {"url": "https://techcrunch.com/feed/", "name": "TechCrunch", "region": "Global", "source_family": "TechCrunch", "topic": "technology-startups", "language": "en", "source_type": "technology"},
    {"url": "https://www.wired.com/feed/rss", "name": "WIRED", "region": "Global", "source_family": "WIRED", "topic": "technology-culture", "language": "en", "source_type": "technology"},
    {"url": "https://feeds.arstechnica.com/arstechnica/index", "name": "Ars Technica", "region": "Global", "source_family": "Ars Technica", "topic": "technology", "language": "en", "source_type": "technology"},
    {"url": "https://www.engadget.com/rss.xml", "name": "Engadget", "region": "Global", "source_family": "Engadget", "topic": "technology", "language": "en", "source_type": "technology"},
    {"url": "https://hnrss.org/frontpage", "name": "Hacker News Front Page", "region": "Global", "source_family": "Hacker News", "topic": "technology-community", "language": "en", "source_type": "technology"},
    {"url": "https://feeds.bloomberg.com/business/news.rss", "name": "Bloomberg Business", "region": "Global", "source_family": "Bloomberg", "topic": "business", "language": "en", "source_type": "finance"},
    {"url": "https://feeds.bloomberg.com/markets/news.rss", "name": "Bloomberg Markets", "region": "Global", "source_family": "Bloomberg", "topic": "markets", "language": "en", "source_type": "finance"},
    {"url": "https://feeds.bloomberg.com/economics/news.rss", "name": "Bloomberg Economics", "region": "Global", "source_family": "Bloomberg", "topic": "economy", "language": "en", "source_type": "finance"},
    {"url": "https://feeds.content.dowjones.io/public/rss/WSJcomUSBusiness", "name": "Wall Street Journal Business", "region": "US", "source_family": "Wall Street Journal", "topic": "business", "language": "en", "source_type": "finance"},
    {"url": "https://www.investing.com/rss/news.rss", "name": "Investing.com All News", "region": "Global", "source_family": "Investing.com", "topic": "finance", "language": "en", "source_type": "finance"},
    {"url": "https://www.investing.com/rss/news_25.rss", "name": "Investing.com Stock Market News", "region": "Global", "source_family": "Investing.com", "topic": "stocks", "language": "en", "source_type": "finance"},
    {"url": "https://www.investing.com/rss/news_14.rss", "name": "Investing.com Economy News", "region": "Global", "source_family": "Investing.com", "topic": "economy", "language": "en", "source_type": "finance"},
    {"url": "https://www.investing.com/rss/news_301.rss", "name": "Investing.com Cryptocurrency News", "region": "Global", "source_family": "Investing.com", "topic": "crypto", "language": "en", "source_type": "finance"},
    {"url": "https://www.investing.com/rss/news_95.rss", "name": "Investing.com Commodities News", "region": "Global", "source_family": "Investing.com", "topic": "commodities", "language": "en", "source_type": "finance"},
    {"url": "https://www.sciencedaily.com/rss/top/science.xml", "name": "ScienceDaily Top Science", "region": "Global", "source_family": "ScienceDaily", "topic": "science", "language": "en", "source_type": "science"},
    {"url": "https://www.nature.com/nature.rss", "name": "Nature", "region": "Global", "source_family": "Nature", "topic": "science", "language": "en", "source_type": "science"},
)


class CliError(Exception):
    def __init__(self, message: str, *, payload: Any | None = None) -> None:
        super().__init__(message)
        self.payload = payload


def compact(value: Any, limit: int = 64) -> str:
    text = str(value or "")
    if len(text) <= limit:
        return text
    return text[: limit - 1] + "…"


def terminal_supports_color() -> bool:
    if not sys.stdout.isatty():
        return False
    if os.getenv("NO_COLOR"):
        return False
    return os.getenv("TERM", "").lower() != "dumb"


def style(text: Any, *codes: str) -> str:
    rendered = str(text)
    if not codes or not terminal_supports_color():
        return rendered
    return f"\033[{';'.join(codes)}m{rendered}\033[0m"


def strip_ansi(text: str) -> str:
    return ANSI_RE.sub("", text)


def shorten_line(text: Any, *, width: int) -> str:
    rendered = str(text)
    if width < 8:
        return rendered
    return textwrap.shorten(rendered, width=width, placeholder="…")


def terminal_width(default: int = 110) -> int:
    try:
        return shutil.get_terminal_size((default, 20)).columns
    except OSError:
        return default


def resolve_output_format(args: argparse.Namespace) -> str:
    if args.format != "auto":
        return str(args.format)
    return "pretty" if sys.stdout.isatty() else "json"


def bool_badge(value: bool) -> str:
    if value:
        return style("enabled", "32", "1")
    return style("disabled", "31", "1")


def status_badge(value: Any) -> str:
    text = str(value or "unknown")
    color = {
        "success": "32",
        "error": "31",
        "conflict": "33",
        "running": "34",
        "pending": "36",
        "queued": "36",
        "skipped": "33",
        "cancelled": "90",
    }.get(text.casefold(), "37")
    return style(text, color, "1")


def render_simple_value(value: Any) -> str:
    if isinstance(value, bool):
        return "yes" if value else "no"
    if value is None:
        return ""
    if isinstance(value, (dict, list)):
        return json.dumps(value, ensure_ascii=False)
    return str(value)


def json_headers(api_key: str | None) -> dict[str, str]:
    headers = {
        "Accept": "application/json",
        "User-Agent": f"fredagsbar-scraper/{CLI_VERSION}",
    }
    if api_key:
        headers["X-API-Key"] = api_key
    return headers


def request_bytes(
    *,
    base_url: str,
    api_key: str | None,
    method: str,
    path: str,
    payload: dict[str, Any] | None = None,
    query: dict[str, Any] | None = None,
) -> bytes:
    url = base_url.rstrip("/") + path
    if query:
        normalized = {key: value for key, value in query.items() if value not in (None, "", False)}
        if normalized:
            url = f"{url}?{urlencode(normalized, doseq=True)}"
    data = None
    headers = json_headers(api_key)
    if payload is not None:
        data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
        headers["Content-Type"] = "application/json; charset=utf-8"
    request = Request(url, data=data, headers=headers, method=method.upper())
    try:
        with urlopen(request, timeout=DEFAULT_TIMEOUT_SECONDS) as response:
            return response.read()
    except HTTPError as exc:
        body = exc.read().decode("utf-8", errors="replace")
        try:
            error_payload = json.loads(body)
        except json.JSONDecodeError:
            error_payload = {"error": exc.reason, "status": exc.code, "body": body}
        raise CliError(str(error_payload.get("error") or exc.reason), payload=error_payload) from exc
    except URLError as exc:
        raise CliError(f"Request failed: {exc.reason}") from exc


def request_json(
    *,
    base_url: str,
    api_key: str | None,
    method: str,
    path: str,
    payload: dict[str, Any] | None = None,
    query: dict[str, Any] | None = None,
) -> Any:
    body = request_bytes(
        base_url=base_url,
        api_key=api_key,
        method=method,
        path=path,
        payload=payload,
        query=query,
    )
    if not body:
        return {}
    return json.loads(body.decode("utf-8"))


def try_request_json(
    *,
    base_url: str,
    api_key: str | None,
    method: str,
    path: str,
    payload: dict[str, Any] | None = None,
    query: dict[str, Any] | None = None,
) -> tuple[Any | None, str | None]:
    try:
        return (
            request_json(
                base_url=base_url,
                api_key=api_key,
                method=method,
                path=path,
                payload=payload,
                query=query,
            ),
            None,
        )
    except CliError as exc:
        return None, str(exc)


def print_json(payload: Any) -> None:
    print(json.dumps(payload, ensure_ascii=False, indent=2))


def print_table(rows: list[dict[str, Any]], columns: list[tuple[str, Callable[[dict[str, Any]], Any]]]) -> None:
    if not rows:
        print("No rows.")
        return
    rendered = []
    widths = [len(header) for header, _ in columns]
    for row in rows:
        rendered_row = []
        for index, (_, getter) in enumerate(columns):
            value = str(getter(row) or "")
            rendered_row.append(value)
            widths[index] = max(widths[index], len(value))
        rendered.append(rendered_row)
    header_line = "  ".join(header.ljust(widths[index]) for index, (header, _) in enumerate(columns))
    divider = "  ".join("-" * widths[index] for index in range(len(columns)))
    print(header_line)
    print(divider)
    for row in rendered:
        print("  ".join(value.ljust(widths[index]) for index, value in enumerate(row)))


def print_key_values(rows: list[tuple[str, Any]]) -> None:
    width = max((len(key) for key, _ in rows), default=0)
    for key, value in rows:
        print(f"{key.ljust(width)}  {value}")


def print_pretty_record(title: str, payload: dict[str, Any]) -> None:
    width = max(60, min(terminal_width(), 120))
    print(style(title, "1"))
    print(style("─" * min(width, len(title) + 8), "2"))
    simple_rows: list[tuple[str, Any]] = []
    complex_rows: list[tuple[str, Any]] = []
    for key, value in payload.items():
        if isinstance(value, (dict, list)):
            complex_rows.append((key, value))
        else:
            simple_rows.append((key, value))
    if simple_rows:
        label_width = max((len(key) for key, _ in simple_rows), default=0)
        for key, value in simple_rows:
            text = shorten_line(render_simple_value(value), width=max(24, width - label_width - 4))
            print(f"{style(key.ljust(label_width), '2')}  {text}")
    for key, value in complex_rows:
        print()
        print(style(key, "36", "1"))
        nested = json.dumps(value, ensure_ascii=False, indent=2)
        for line in nested.splitlines():
            print(style(f"  {line}", "2"))


def print_pretty_list(
    title: str,
    payload: dict[str, Any],
    *,
    primary: Callable[[dict[str, Any]], str],
    secondary: Callable[[dict[str, Any]], str] | None = None,
    details: Callable[[dict[str, Any]], list[str]] | None = None,
) -> None:
    items = list(payload.get("items") or [])
    page = payload.get("page")
    total_pages = payload.get("total_pages")
    count = payload.get("count")
    total = payload.get("total")
    filters = payload.get("filters") or {}
    width = terminal_width()

    header_bits = []
    if total is not None and count is not None:
        header_bits.append(f"{count}/{total} shown")
    if page and total_pages is not None:
        header_bits.append(f"page {page}/{max(total_pages, 1)}")
    filter_bits = [f"{key}={value}" for key, value in filters.items() if value not in (None, "", False)]
    if filter_bits:
        header_bits.append(", ".join(filter_bits))

    print(style(title, "1"))
    if header_bits:
        print(style(" · ".join(header_bits), "2"))
    print(style("─" * min(width, 100), "2"))

    if not items:
        print(style("No rows.", "2"))
        return

    start_index = ((page or 1) - 1) * int(payload.get("page_size") or len(items))
    for index, row in enumerate(items, start=1):
        prefix = style(f"{start_index + index:>2}.", "2")
        primary_text = shorten_line(primary(row), width=max(20, width - 24))
        line = f"{prefix} {style(primary_text, '1')}"
        if secondary is not None:
            secondary_text = secondary(row)
            if secondary_text:
                line += f"  {style(shorten_line(secondary_text, width=32), '36')}"
        print(line)
        if details is not None:
            for detail in details(row):
                print(style(f"    {shorten_line(detail, width=max(30, width - 4))}", "2"))
        print()


def print_collection_summary(payload: dict[str, Any]) -> None:
    total = payload.get("total")
    count = payload.get("count")
    page = payload.get("page")
    total_pages = payload.get("total_pages")
    filters = payload.get("filters") or {}
    bits = []
    if total is not None and count is not None:
        bits.append(f"{count}/{total} shown")
    if page and total_pages is not None:
        bits.append(f"page {page}/{max(total_pages, 1)}")
    filter_bits = [f"{key}={value}" for key, value in filters.items() if value not in (None, "", False)]
    if filter_bits:
        bits.append(", ".join(filter_bits))
    if bits:
        print(style(" · ".join(bits), "2"))


def normalize_list_payload(payload: Any, *, page_size: int | None = None) -> dict[str, Any]:
    if isinstance(payload, dict) and isinstance(payload.get("items"), list):
        return payload
    items = payload if isinstance(payload, list) else []
    size = page_size or len(items) or DEFAULT_PAGE_SIZE
    total = len(items)
    return {
        "items": items,
        "page": 1,
        "page_size": size,
        "total": total,
        "total_pages": 1 if total else 0,
        "count": total,
        "has_prev": False,
        "has_next": False,
        "prev_page": None,
        "next_page": None,
        "filters": {},
    }


def request_paginated_collection(
    *,
    base_url: str,
    api_key: str,
    path: str,
    query: dict[str, Any],
    fetch_all: bool,
) -> dict[str, Any]:
    normalized_query = dict(query)
    normalized_query["paginate"] = "true"
    page_size = int(normalized_query.get("page_size") or DEFAULT_PAGE_SIZE)
    page_size = max(1, min(page_size, MAX_PAGE_SIZE))
    normalized_query["page_size"] = page_size
    normalized_query["page"] = normalized_query.get("page") or 1
    payload = normalize_list_payload(
        request_json(
            base_url=base_url,
            api_key=api_key,
            method="GET",
            path=path,
            query=normalized_query,
        ),
        page_size=int(page_size),
    )
    if not fetch_all:
        return payload
    items = list(payload.get("items") or [])
    current_page = int(payload.get("page") or 1)
    while payload.get("has_next"):
        current_page += 1
        next_payload = normalize_list_payload(
            request_json(
                base_url=base_url,
                api_key=api_key,
                method="GET",
                path=path,
                query={**normalized_query, "page": current_page},
            ),
            page_size=int(page_size),
        )
        items.extend(next_payload.get("items") or [])
        payload = next_payload
    return {
        **payload,
        "items": items,
        "page": 1,
        "count": len(items),
        "pages_fetched": current_page,
        "has_prev": False,
        "has_next": False,
        "prev_page": None,
        "next_page": None,
    }


def registry_instagram_profile_handles(*, base_url: str, api_key: str) -> set[str]:
    payload = request_paginated_collection(
        base_url=base_url,
        api_key=api_key,
        path="/v1/admin/targets",
        query={"platform": "instagram", "target_type": "profile", "page_size": MAX_PAGE_SIZE},
        fetch_all=True,
    )
    handles: set[str] = set()
    for row in payload.get("items") or []:
        handle = str(row.get("target_value") or "").lstrip("@").strip().lower()
        if handle:
            handles.add(handle)
    return handles


def require_api_key(args: argparse.Namespace) -> str:
    if args.api_key:
        return str(args.api_key)
    raise CliError("This command requires an API key. Set SCRAPER_API_KEY or pass --api-key.")


def resolve_executable_path(explicit_target: str | None = None) -> Path:
    if explicit_target:
        return Path(explicit_target).expanduser()
    argv_path = Path(sys.argv[0])
    if argv_path.is_absolute() and argv_path.exists():
        return argv_path
    argv_name = argv_path.name
    if argv_name:
        resolved = shutil.which(argv_name)
        if resolved:
            return Path(resolved).resolve()
    installed = shutil.which("fredagsbar-scraper")
    if installed:
        return Path(installed).resolve()
    raise CliError("Could not determine install target. Pass --target.")


def handle_health(args: argparse.Namespace) -> None:
    payload = request_json(base_url=args.base_url, api_key=args.api_key, method="GET", path="/health")
    fmt = resolve_output_format(args)
    if fmt == "json":
        print_json(payload)
        return
    print_pretty_record("Health", payload)


def handle_version(args: argparse.Namespace) -> None:
    payload, error = try_request_json(base_url=args.base_url, api_key=args.api_key, method="GET", path="/v1/version")
    executable_path = resolve_executable_path(args.target) if args.target or shutil.which("fredagsbar-scraper") else Path(__file__).resolve()
    result = {
        "cli_version": CLI_VERSION,
        "base_url": args.base_url,
        "executable_path": str(executable_path),
        "service_reachable": payload is not None,
        "service": payload,
        "service_error": error,
    }
    fmt = resolve_output_format(args)
    if fmt == "json":
        print_json(result)
        return
    if fmt == "pretty":
        print_pretty_record(
            "CLI Version",
            {
                "cli_version": CLI_VERSION,
                "base_url": args.base_url,
                "executable_path": str(executable_path),
                "service_reachable": payload is not None,
                "service_version": (payload or {}).get("service_version", ""),
                "api_version": (payload or {}).get("api_version", ""),
                "remote_cli_version": (payload or {}).get("cli_version", ""),
                "service_error": error or "",
            },
        )
        return
    rows = [(key, value) for key, value in [
        ("cli_version", CLI_VERSION),
        ("base_url", args.base_url),
        ("executable_path", executable_path),
        ("service_reachable", "yes" if payload is not None else "no"),
        ("service_version", (payload or {}).get("service_version", "")),
        ("api_version", (payload or {}).get("api_version", "")),
        ("remote_cli_version", (payload or {}).get("cli_version", "")),
        ("service_error", error or ""),
    ] if value not in ("", None)]
    print_key_values(rows)


def handle_whoami(args: argparse.Namespace) -> None:
    payload = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="GET",
        path="/v1/whoami",
    )
    fmt = resolve_output_format(args)
    if fmt == "json":
        print_json(payload)
        return
    print_pretty_record("Whoami", payload)


def api_docs_browser_url(base_url: str) -> str:
    return f"{base_url.rstrip('/')}/ui/docs"


def iter_openapi_operations(openapi_doc: dict[str, Any]) -> list[dict[str, Any]]:
    operations: list[dict[str, Any]] = []
    for path, methods in (openapi_doc.get("paths") or {}).items():
        if not isinstance(methods, dict):
            continue
        for method, operation in methods.items():
            if not isinstance(operation, dict) or method.lower() not in {"get", "post", "delete", "put", "patch"}:
                continue
            operations.append(
                {
                    "method": method.upper(),
                    "path": path,
                    "auth": "public" if operation.get("security") == [] else "protected",
                    "summary": operation.get("summary") or "",
                    "tags": ",".join(operation.get("tags") or []),
                    "operation_id": operation.get("operationId") or "",
                }
            )
    return operations


def print_docs_summary(openapi_doc: dict[str, Any]) -> None:
    info = openapi_doc.get("info") or {}
    operations = iter_openapi_operations(openapi_doc)
    print(style(str(info.get("title") or "API Documentation"), "1"))
    print(style(f"version {info.get('version') or 'unknown'} · {len(operations)} operations", "2"))
    print(style("─" * min(terminal_width(), 100), "2"))
    for operation in operations:
        print(
            f"{style(str(operation['method']).ljust(6), '36', '1')} "
            f"{style(str(operation['auth']).ljust(9), '2')} "
            f"{operation['path']}  {style(compact(operation['summary'], 76), '2')}"
        )


def handle_docs(args: argparse.Namespace) -> None:
    browser_url = api_docs_browser_url(args.base_url)
    fmt = resolve_output_format(args)
    if args.open:
        opened = webbrowser.open(browser_url, new=2)
        payload = {
            "opened": opened,
            "url": browser_url,
            "requires_login": True,
            "note": "The browser page uses the admin console login cookie, not the CLI API key.",
        }
        if fmt == "json":
            print_json(payload)
            return
        print_pretty_record("API Docs", payload)
        return
    if args.url:
        payload = {"url": browser_url, "requires_login": True}
        if fmt == "json":
            print_json(payload)
            return
        print(browser_url)
        return

    payload = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="GET",
        path="/v1/openapi.json",
    )
    if args.output:
        output_path = Path(args.output).expanduser()
        output_path.parent.mkdir(parents=True, exist_ok=True)
        output_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
        result = {
            "written": True,
            "output_path": str(output_path),
            "operation_count": len(iter_openapi_operations(payload)),
        }
        if fmt == "json":
            print_json(result)
            return
        print_pretty_record("API Docs Exported", result)
        return
    if fmt == "json":
        print_json(payload)
        return
    operations = iter_openapi_operations(payload)
    if fmt == "table":
        print_table(
            operations,
            [
                ("method", lambda row: row.get("method")),
                ("auth", lambda row: row.get("auth")),
                ("path", lambda row: row.get("path")),
                ("summary", lambda row: compact(row.get("summary"), 72)),
            ],
        )
        return
    print_docs_summary(payload)


def handle_update(args: argparse.Namespace) -> None:
    version_payload, version_error = try_request_json(base_url=args.base_url, api_key=args.api_key, method="GET", path="/v1/version")
    current_version = CLI_VERSION
    remote_version = (version_payload or {}).get("cli_version")
    if args.check:
        result = {
            "cli_version": current_version,
            "remote_cli_version": remote_version,
            "service_error": version_error,
            "up_to_date": bool(remote_version and remote_version == current_version),
        }
        print_json(result)
        return
    if remote_version and remote_version == current_version and not args.force:
        print(f"Already up to date at version {current_version}.")
        return

    target_path = resolve_executable_path(args.target)
    body = request_bytes(
        base_url=args.base_url,
        api_key=args.api_key,
        method="GET",
        path="/downloads/fredagsbar-scraper",
    )
    target_path.parent.mkdir(parents=True, exist_ok=True)
    with tempfile.NamedTemporaryFile("wb", delete=False, dir=target_path.parent, prefix=f".{target_path.name}.", suffix=".tmp") as handle:
        handle.write(body)
        temp_path = Path(handle.name)
    current_mode = 0o755
    if target_path.exists():
        current_mode = stat.S_IMODE(target_path.stat().st_mode)
    temp_path.chmod(current_mode or 0o755)
    temp_path.replace(target_path)

    result = {
        "updated": True,
        "target_path": str(target_path),
        "cli_version_before": current_version,
        "remote_cli_version": remote_version or "unknown",
        "service_error": version_error,
    }
    print_json(result)


def handle_manifest(args: argparse.Namespace) -> None:
    payload = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="GET",
        path="/v1/manifest",
    )
    fmt = resolve_output_format(args)
    if fmt == "json":
        print_json(payload)
        return
    print_pretty_record("Manifest", payload)


def handle_targets_list(args: argparse.Namespace) -> None:
    payload = request_paginated_collection(
        base_url=args.base_url,
        api_key=require_api_key(args),
        path="/v1/admin/targets",
        query={
            "platform": args.platform,
            "target_type": args.target_type,
            "enabled": args.enabled,
            "q": args.query,
            "page": args.page,
            "page_size": args.page_size,
        },
        fetch_all=args.all,
    )
    fmt = resolve_output_format(args)
    if fmt == "json":
        print_json(payload)
        return
    items = payload.get("items") or []
    if fmt == "pretty":
        print_pretty_list(
            "Targets",
            payload,
            primary=lambda row: row.get("label") or row.get("target_value") or "",
            secondary=lambda row: f"{row.get('platform')}/{row.get('target_type')}",
            details=lambda row: [
                f"id {row.get('id')} · {bool_badge(bool(row.get('enabled')))} · priority {row.get('priority')}",
                f"value {row.get('target_value')}",
                f"updated {row.get('updated_at_utc') or ''}",
                f"error {compact(row.get('last_error') or 'none', 72)}",
            ],
        )
        return
    print_collection_summary(payload)
    print_table(
        items,
        [
            ("id", lambda row: row.get("id")),
            ("platform", lambda row: row.get("platform")),
            ("type", lambda row: row.get("target_type")),
            ("label", lambda row: compact(row.get("label") or row.get("target_value"), 34)),
            ("enabled", lambda row: "yes" if row.get("enabled") else "no"),
            ("priority", lambda row: row.get("priority")),
            ("last_success", lambda row: row.get("last_success_at_utc") or ""),
            ("last_error", lambda row: compact(row.get("last_error"), 40)),
        ],
    )


def handle_targets_get(args: argparse.Namespace) -> None:
    payload = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="GET",
        path=f"/v1/admin/targets/{args.target_id}",
    )
    fmt = resolve_output_format(args)
    if fmt == "json":
        print_json(payload)
        return
    print_pretty_record("Target", payload)


def handle_targets_create(args: argparse.Namespace) -> None:
    payload = {
        "platform": args.platform,
        "target_type": args.target_type,
        "target_value": args.target_value,
        "enabled": not args.disabled,
        "priority": args.priority,
    }
    if args.label is not None:
        payload["label"] = args.label
    if args.notes is not None:
        payload["notes"] = args.notes
    if args.refresh_interval_minutes is not None:
        payload["refresh_interval_minutes"] = args.refresh_interval_minutes
    if args.metadata_json is not None:
        metadata = json.loads(args.metadata_json)
        if not isinstance(metadata, dict):
            raise CliError("--metadata-json must be a JSON object.")
        payload["metadata"] = metadata
    result = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="POST",
        path="/v1/admin/targets",
        payload=payload,
    )
    if resolve_output_format(args) == "json":
        print_json(result)
        return
    print_pretty_record("Target Created", result)


def handle_targets_seed_gymnasieeleven_rss(args: argparse.Namespace) -> None:
    results: list[dict[str, Any]] = []
    payloads: list[dict[str, Any]] = []
    for index, feed in enumerate(GYMNASIEELEVEN_RSS_FEEDS, start=1):
        metadata = {
            "source_type": "news",
            "topic": args.topic,
            "country": "DK",
            "language": "da",
            "publisher": feed["name"],
            "region": feed["region"],
            "source_family": feed["source_family"],
            "source_collection": "gymnasieeleven",
        }
        payload: dict[str, Any] = {
            "platform": "rss",
            "target_type": "feed",
            "target_value": feed["url"],
            "label": feed["name"],
            "enabled": not args.disabled,
            "priority": args.priority + index - 1,
            "metadata": metadata,
        }
        if args.refresh_interval_minutes is not None:
            payload["refresh_interval_minutes"] = args.refresh_interval_minutes
        if args.notes:
            payload["notes"] = args.notes
        payloads.append(payload)

    if args.dry_run:
        result = {"dry_run": True, "count": len(payloads), "targets": payloads}
        print_json(result)
        return

    for payload in payloads:
        created = request_json(
            base_url=args.base_url,
            api_key=require_api_key(args),
            method="POST",
            path="/v1/admin/targets",
            payload=payload,
        )
        results.append(created)

    result = {
        "created_or_updated": len(results),
        "disabled": args.disabled,
        "refresh_interval_minutes": args.refresh_interval_minutes,
        "targets": results,
    }
    if resolve_output_format(args) == "json":
        print_json(result)
        return
    print_pretty_record(
        "Gymnasieeleven RSS Targets Seeded",
        {
            "created_or_updated": len(results),
            "disabled": args.disabled,
            "refresh_interval_minutes": args.refresh_interval_minutes or "",
            "source_collection": "gymnasieeleven",
        },
    )


def _seed_target_payload(
    source: dict[str, str],
    *,
    platform: str,
    target_type: str,
    enabled: bool,
    priority: int,
    refresh_interval_minutes: int | None,
    notes: str | None,
    source_collection: str,
    default_topic: str,
    default_country: str = "DK",
    default_language: str = "da",
    default_region: str = "National",
) -> dict[str, Any]:
    metadata = {
        "source_type": source.get("source_type") or ("news" if target_type == "feed" else "website"),
        "topic": source.get("topic") or default_topic,
        "country": source.get("country") or default_country,
        "language": source.get("language") or default_language,
        "publisher": source["name"],
        "region": source.get("region") or default_region,
        "source_family": source.get("source_family") or source["name"],
        "source_collection": source_collection,
    }
    payload: dict[str, Any] = {
        "platform": platform,
        "target_type": target_type,
        "target_value": source["url"],
        "label": source["name"],
        "enabled": enabled,
        "priority": priority,
        "metadata": metadata,
    }
    if refresh_interval_minutes is not None:
        payload["refresh_interval_minutes"] = refresh_interval_minutes
    if notes:
        payload["notes"] = notes
    return payload


def handle_targets_seed_danish_coverage(args: argparse.Namespace) -> None:
    payloads: list[dict[str, Any]] = []
    priority = args.priority
    if not args.skip_gymnasieeleven:
        for feed in GYMNASIEELEVEN_RSS_FEEDS:
            payloads.append(
                _seed_target_payload(
                    feed,
                    platform="rss",
                    target_type="feed",
                    enabled=not args.disabled,
                    priority=priority,
                    refresh_interval_minutes=args.refresh_interval_minutes,
                    notes=args.notes,
                    source_collection="gymnasieeleven",
                    default_topic=args.topic,
                )
            )
            priority += 1
    for feed in DANISH_COVERAGE_RSS_FEEDS:
        payloads.append(
            _seed_target_payload(
                feed,
                platform="rss",
                target_type="feed",
                enabled=not args.disabled,
                priority=priority,
                refresh_interval_minutes=args.refresh_interval_minutes,
                notes=args.notes,
                source_collection="danish-coverage",
                default_topic=args.topic,
            )
        )
        priority += 1
    for website in DANISH_COVERAGE_WEBSITE_TARGETS:
        payloads.append(
            _seed_target_payload(
                website,
                platform="website",
                target_type="homepage",
                enabled=not args.disabled,
                priority=priority,
                refresh_interval_minutes=args.website_refresh_interval_minutes,
                notes=args.notes,
                source_collection="danish-coverage",
                default_topic=args.topic,
            )
        )
        priority += 1

    unique_payloads: list[dict[str, Any]] = []
    seen: set[tuple[str, str, str]] = set()
    for payload in payloads:
        key = (
            str(payload.get("platform") or ""),
            str(payload.get("target_type") or ""),
            str(payload.get("target_value") or ""),
        )
        if key in seen:
            continue
        seen.add(key)
        unique_payloads.append(payload)

    if args.dry_run:
        print_json(
            {
                "dry_run": True,
                "count": len(unique_payloads),
                "rss_count": sum(1 for p in unique_payloads if p["platform"] == "rss"),
                "website_count": sum(1 for p in unique_payloads if p["platform"] == "website"),
                "targets": unique_payloads,
            }
        )
        return

    results: list[dict[str, Any]] = []
    for payload in unique_payloads:
        results.append(
            request_json(
                base_url=args.base_url,
                api_key=require_api_key(args),
                method="POST",
                path="/v1/admin/targets",
                payload=payload,
            )
        )

    result = {
        "created_or_updated": len(results),
        "rss_count": sum(1 for p in unique_payloads if p["platform"] == "rss"),
        "website_count": sum(1 for p in unique_payloads if p["platform"] == "website"),
        "disabled": args.disabled,
        "targets": results,
    }
    if resolve_output_format(args) == "json":
        print_json(result)
        return
    print_pretty_record(
        "Danish Coverage Targets Seeded",
        {
            "created_or_updated": len(results),
            "rss_count": result["rss_count"],
            "website_count": result["website_count"],
            "disabled": args.disabled,
        },
    )


def handle_targets_seed_international_coverage(args: argparse.Namespace) -> None:
    payloads: list[dict[str, Any]] = []
    for index, feed in enumerate(INTERNATIONAL_COVERAGE_RSS_FEEDS):
        payloads.append(
            _seed_target_payload(
                feed,
                platform="rss",
                target_type="feed",
                enabled=not args.disabled,
                priority=args.priority + index,
                refresh_interval_minutes=args.refresh_interval_minutes,
                notes=args.notes,
                source_collection="international-coverage",
                default_topic=args.topic,
                default_country="GLOBAL",
                default_language="en",
                default_region="Global",
            )
        )

    if args.dry_run:
        print_json(
            {
                "dry_run": True,
                "count": len(payloads),
                "rss_count": len(payloads),
                "targets": payloads,
            }
        )
        return

    results: list[dict[str, Any]] = []
    for payload in payloads:
        results.append(
            request_json(
                base_url=args.base_url,
                api_key=require_api_key(args),
                method="POST",
                path="/v1/admin/targets",
                payload=payload,
            )
        )

    result = {
        "created_or_updated": len(results),
        "rss_count": len(results),
        "disabled": args.disabled,
        "targets": results,
    }
    if resolve_output_format(args) == "json":
        print_json(result)
        return
    print_pretty_record(
        "International Coverage Targets Seeded",
        {
            "created_or_updated": len(results),
            "rss_count": len(results),
            "disabled": args.disabled,
        },
    )


def handle_targets_update(args: argparse.Namespace) -> None:
    payload: dict[str, Any] = {}
    if args.label is not None:
        payload["label"] = args.label
    if args.notes is not None:
        payload["notes"] = args.notes
    if args.priority is not None:
        payload["priority"] = args.priority
    if args.refresh_interval_minutes is not None:
        payload["refresh_interval_minutes"] = args.refresh_interval_minutes
    if args.enable:
        payload["enabled"] = True
    if args.disable:
        payload["enabled"] = False
    if args.metadata_json is not None:
        metadata = json.loads(args.metadata_json)
        if not isinstance(metadata, dict):
            raise CliError("--metadata-json must be a JSON object.")
        payload["metadata"] = metadata
    if args.link_instagram or args.unlink_instagram:
        # Read existing metadata so we can mutate the cross_platform_links list
        # without clobbering anything else.
        current = request_json(
            base_url=args.base_url,
            api_key=require_api_key(args),
            method="GET",
            path=f"/v1/admin/targets/{args.target_id}",
        )
        metadata = dict(payload.get("metadata") or current.get("metadata") or {})
        raw_links = metadata.get("cross_platform_links")
        links = dict(raw_links) if isinstance(raw_links, dict) else {}
        raw_profiles = links.get("instagram_profiles")
        profiles = (
            [str(handle).lstrip("@").strip().lower() for handle in raw_profiles if str(handle).strip()]
            if isinstance(raw_profiles, list)
            else []
        )

        # Validate that any --link-instagram handle exists as an IG profile
        # target — otherwise canonical merge will silently never see it. Allow
        # bypass via --force for cases where the IG dataset is populated
        # outside the target registry and already exists in storage.
        if args.link_instagram and not args.force:
            registry_handles = registry_instagram_profile_handles(
                base_url=args.base_url,
                api_key=require_api_key(args),
            )
            for handle in args.link_instagram:
                normalized = handle.lstrip("@").strip().lower()
                if normalized and normalized not in registry_handles:
                    raise CliError(
                        f"Instagram handle '{normalized}' is not a registered profile target. "
                        f"Add it via `targets create --platform instagram --target-type profile "
                        f"--target-value {normalized}` first, or pass --force to skip this check."
                    )

        for handle in args.link_instagram or []:
            handle = handle.lstrip("@").strip().lower()
            if handle and handle not in profiles:
                profiles.append(handle)
        for handle in args.unlink_instagram or []:
            handle = handle.lstrip("@").strip().lower()
            if handle in profiles:
                profiles.remove(handle)
        links["instagram_profiles"] = profiles
        metadata["cross_platform_links"] = links
        payload["metadata"] = metadata
    if not payload:
        raise CliError("No update fields supplied.")
    result = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="POST",
        path=f"/v1/admin/targets/{args.target_id}",
        payload=payload,
    )
    if resolve_output_format(args) == "json":
        print_json(result)
        return
    print_pretty_record("Target Updated", result)


def handle_targets_delete(args: argparse.Namespace) -> None:
    if not args.yes:
        raise CliError("Refusing to delete without --yes.")
    result = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="DELETE",
        path=f"/v1/admin/targets/{args.target_id}",
    )
    if resolve_output_format(args) == "json":
        print_json(result)
        return
    print_pretty_record("Target Deleted", result)


def handle_targets_refresh(args: argparse.Namespace) -> None:
    payload: dict[str, Any] = {
        "upcoming_only": not args.include_past,
    }
    if args.max_posts is not None:
        payload["max_posts"] = args.max_posts
    if args.skip_ocr:
        payload["skip_ocr"] = True
    result = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="POST",
        path=f"/v1/admin/targets/{args.target_id}/refresh",
        payload=payload,
    )
    if resolve_output_format(args) == "json":
        print_json(result)
        return
    print_pretty_record("Target Refreshed", result)


def handle_runs_list(args: argparse.Namespace) -> None:
    payload = request_paginated_collection(
        base_url=args.base_url,
        api_key=require_api_key(args),
        path="/v1/admin/runs",
        query={
            "target_id": args.target_id,
            "status": args.status,
            "trigger": args.trigger,
            "q": args.query,
            "page": args.page,
            "page_size": args.page_size,
        },
        fetch_all=args.all,
    )
    fmt = resolve_output_format(args)
    if fmt == "json":
        print_json(payload)
        return
    items = payload.get("items") or []
    if fmt == "pretty":
        print_pretty_list(
            "Runs",
            payload,
            primary=lambda row: f"{row.get('trigger') or 'run'} · {row.get('id')}",
            secondary=lambda row: status_badge(row.get("status")),
            details=lambda row: [
                f"target {row.get('target_id')} · items {row.get('items_found') or 0}",
                f"started {row.get('started_at_utc') or ''}",
                f"completed {row.get('completed_at_utc') or ''}",
                f"error {compact(row.get('error') or 'none', 72)}",
            ],
        )
        return
    print_collection_summary(payload)
    print_table(
        items,
        [
            ("id", lambda row: row.get("id")),
            ("status", lambda row: row.get("status")),
            ("trigger", lambda row: row.get("trigger")),
            ("target_id", lambda row: row.get("target_id")),
            ("items", lambda row: row.get("items_found") or 0),
            ("started", lambda row: row.get("started_at_utc") or ""),
            ("completed", lambda row: row.get("completed_at_utc") or ""),
            ("error", lambda row: compact(row.get("error"), 42)),
        ],
    )


def handle_runs_errors(args: argparse.Namespace) -> None:
    args.status = "error"
    handle_runs_list(args)


def handle_runs_get(args: argparse.Namespace) -> None:
    payload = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="GET",
        path=f"/v1/admin/runs/{args.run_id}",
    )
    if resolve_output_format(args) == "json":
        print_json(payload)
        return
    print_pretty_record("Run", payload)


def handle_scrape_jobs_list(args: argparse.Namespace) -> None:
    payload = request_paginated_collection(
        base_url=args.base_url,
        api_key=require_api_key(args),
        path="/v1/admin/scrape/jobs",
        query={
            "target_id": args.target_id,
            "status": args.status,
            "platform": args.platform,
            "trigger": args.trigger,
            "q": args.query,
            "page": args.page,
            "page_size": args.page_size,
        },
        fetch_all=args.all,
    )
    fmt = resolve_output_format(args)
    if fmt == "json":
        print_json(payload)
        return
    items = payload.get("items") or []
    if fmt == "pretty":
        print_pretty_list(
            "Scrape Jobs",
            payload,
            primary=lambda row: f"{row.get('platform')}/{row.get('target_type')} · {row.get('target_value')}",
            secondary=lambda row: status_badge(row.get("status")),
            details=lambda row: [
                f"id {row.get('id')} · target {row.get('target_id')}",
                f"trigger {row.get('trigger') or ''} · attempts {row.get('attempts') or 0}/{row.get('max_attempts') or 0}",
                f"run_after {row.get('run_after_utc') or ''}",
                f"error {compact(row.get('error') or 'none', 72)}",
            ],
        )
        return
    print_collection_summary(payload)
    print_table(
        items,
        [
            ("id", lambda row: row.get("id")),
            ("status", lambda row: row.get("status")),
            ("platform", lambda row: row.get("platform")),
            ("target", lambda row: compact(row.get("target_value"), 34)),
            ("trigger", lambda row: row.get("trigger")),
            ("attempts", lambda row: f"{row.get('attempts') or 0}/{row.get('max_attempts') or 0}"),
            ("run_after", lambda row: row.get("run_after_utc") or ""),
            ("error", lambda row: compact(row.get("error"), 42)),
        ],
    )


def handle_scrape_jobs_get(args: argparse.Namespace) -> None:
    payload = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="GET",
        path=f"/v1/admin/scrape/jobs/{args.job_id}",
    )
    if resolve_output_format(args) == "json":
        print_json(payload)
        return
    print_pretty_record("Scrape Job", payload)


def handle_scrape_safety_get(args: argparse.Namespace) -> None:
    payload = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="GET",
        path="/v1/admin/scrape/safety",
    )
    if resolve_output_format(args) == "json":
        print_json(payload)
        return
    print_pretty_record("Scrape Safety", payload)


def handle_scrape_safety_set(args: argparse.Namespace) -> None:
    payload: dict[str, Any] = {}
    if args.pause:
        payload["paused"] = True
    if args.resume:
        payload["paused"] = False
    for key in ("daily_job_limit", "per_run_job_limit", "target_daily_job_limit", "rss_max_items_per_job"):
        value = getattr(args, key)
        if value is not None:
            payload[key] = value
    if args.platform_limit:
        limits: dict[str, int] = {}
        for item in args.platform_limit:
            if "=" not in item:
                raise CliError("--platform-limit must use platform=count, e.g. rss=80")
            platform, value = item.split("=", 1)
            limits[platform.strip().lower()] = int(value)
        payload["platform_daily_job_limits"] = limits
    result = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="POST",
        path="/v1/admin/scrape/safety",
        payload=payload,
    )
    if resolve_output_format(args) == "json":
        print_json(result)
        return
    print_pretty_record("Scrape Safety Updated", result)


def handle_scrape_initiate(args: argparse.Namespace) -> None:
    job_payload: dict[str, Any] | None = None
    if args.job_payload_json:
        parsed = json.loads(args.job_payload_json)
        if not isinstance(parsed, dict):
            raise CliError("--job-payload-json must be a JSON object.")
        job_payload = parsed
    payload: dict[str, Any] = {
        "force": args.force,
        "process": args.process,
        "trigger": args.trigger,
        "max_jobs": args.max_jobs,
    }
    if args.platform:
        payload["platforms"] = args.platform
    if args.target_id:
        payload["target_ids"] = args.target_id
    if job_payload is not None:
        payload["job_payload"] = job_payload
    result = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="POST",
        path="/v1/admin/scrape/initiate",
        payload=payload,
    )
    if resolve_output_format(args) == "json":
        print_json(result)
        return
    plan = result.get("plan") if isinstance(result, dict) else {}
    print_pretty_record(
        "Scrape Initiated",
        {
            "created_count": (plan or {}).get("created_count"),
            "existing_active_count": (plan or {}).get("existing_active_count"),
            "skipped_count": (plan or {}).get("skipped_count"),
            "queued_count": result.get("queued_count") if isinstance(result, dict) else None,
            "running_count": result.get("running_count") if isinstance(result, dict) else None,
            "processed": bool(result.get("processed")) if isinstance(result, dict) else False,
        },
    )


def handle_scrape_process(args: argparse.Namespace) -> None:
    result = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="POST",
        path="/v1/admin/scrape/jobs/process",
        payload={"max_jobs": args.max_jobs, "worker_id": args.worker_id},
    )
    if resolve_output_format(args) == "json":
        print_json(result)
        return
    print_pretty_record(
        "Scrape Jobs Processed",
        {
            "claimed_count": result.get("claimed_count"),
            "success_count": result.get("success_count"),
            "error_count": result.get("error_count"),
            "worker_id": result.get("worker_id"),
        },
    )


def handle_runs_logs(args: argparse.Namespace) -> None:
    payload = request_paginated_collection(
        base_url=args.base_url,
        api_key=require_api_key(args),
        path=f"/v1/admin/runs/{args.run_id}/audit-logs",
        query={
            "status": args.status,
            "q": args.query,
            "page": args.page,
            "page_size": args.page_size,
        },
        fetch_all=args.all,
    )
    fmt = resolve_output_format(args)
    if fmt == "json":
        print_json(payload)
        return
    items = payload.get("items") or []
    if fmt == "pretty":
        print_pretty_list(
            f"Run Logs · {args.run_id}",
            payload,
            primary=lambda row: f"{row.get('action')} · {row.get('resource_type')}/{row.get('resource_id')}",
            secondary=lambda row: status_badge(row.get("status")),
            details=lambda row: [
                f"when {row.get('created_at_utc') or ''} · actor {row.get('actor_label') or row.get('actor_id') or 'unknown'}",
                f"message {compact(row.get('message') or 'none', 72)}",
            ],
        )
        return
    print_collection_summary(payload)
    print_table(
        items,
        [
            ("when", lambda row: row.get("created_at_utc")),
            ("status", lambda row: row.get("status")),
            ("action", lambda row: row.get("action")),
            ("resource", lambda row: f"{row.get('resource_type')}/{row.get('resource_id')}"),
            ("message", lambda row: compact(row.get("message"), 64)),
        ],
    )


def progress_endpoint(run_id: str | None) -> str:
    if run_id:
        return f"/v1/admin/runs/{run_id}/progress"
    return "/v1/admin/runs/progress"


def progress_event_line(event: dict[str, Any]) -> str:
    action = str(event.get("action") or "progress")
    status = status_badge(event.get("status"))
    when = compact(event.get("created_at_utc"), 30)
    context_bits = [
        str(event.get("stage") or event.get("script_name") or ""),
        f"run={compact(event.get('run_id'), 10)}" if event.get("run_id") else "",
        f"target={compact(event.get('target_label') or event.get('target_id'), 28)}" if event.get("target_label") or event.get("target_id") else "",
        str(event.get("stream") or ""),
    ]
    context = " · ".join(bit for bit in context_bits if bit)
    text = event.get("line") or event.get("message") or event.get("resource_id") or ""
    return f"{style(when, '2')} {status} {style(action, '1')} {style(context, '2')} {compact(text, max(48, terminal_width() - 70))}"


def print_progress_payload(payload: dict[str, Any]) -> None:
    summary = payload.get("summary") or {}
    print(style("Run Progress", "1"))
    print(
        style(
            f"{summary.get('running_count') or 0} running · {summary.get('run_count') or 0} runs · {summary.get('event_count') or 0} events · {payload.get('generated_at_utc') or ''}",
            "2",
        )
    )
    running = payload.get("running_runs") or []
    if running:
        print()
        print(style("Running", "36", "1"))
        print_table(
            running,
            [
                ("id", lambda row: compact(row.get("id"), 12)),
                ("trigger", lambda row: compact(row.get("trigger"), 28)),
                ("target", lambda row: compact(row.get("target_label") or row.get("target_id"), 34)),
                ("started", lambda row: row.get("started_at_utc") or ""),
                ("latest", lambda row: compact(((row.get("latest_event") or {}).get("line") or (row.get("latest_event") or {}).get("message") or ""), 42)),
            ],
        )
    events = list(reversed(payload.get("events") or []))
    if events:
        print()
        print(style("Recent Events", "36", "1"))
        for event in events:
            print(progress_event_line(event))
    elif not running:
        print(style("No progress events yet.", "2"))


def request_progress_payload(args: argparse.Namespace) -> dict[str, Any]:
    return request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="GET",
        path=progress_endpoint(args.run_id),
        query={
            "target_id": args.target_id,
            "limit": args.limit,
        },
    )


def handle_runs_watch(args: argparse.Namespace) -> None:
    fmt = resolve_output_format(args)
    if fmt == "json" or args.once:
        payload = request_progress_payload(args)
        if fmt == "json":
            print_json(payload)
            return
        print_progress_payload(payload)
        return

    seen_event_ids: set[str] = set()
    print(style("Watching scrape progress. Press Ctrl-C to stop.", "2"))
    try:
        while True:
            payload = request_progress_payload(args)
            events = list(reversed(payload.get("events") or []))
            printed = False
            for event in events:
                event_id = str(event.get("id") or "")
                if event_id and event_id in seen_event_ids:
                    continue
                if event_id:
                    seen_event_ids.add(event_id)
                print(progress_event_line(event))
                printed = True
            if not printed and not seen_event_ids:
                summary = payload.get("summary") or {}
                print(
                    style(
                        f"No progress events yet. {summary.get('running_count') or 0} running at {payload.get('generated_at_utc') or ''}.",
                        "2",
                    )
                )
            if args.exit_when_idle and seen_event_ids and not payload.get("running_runs"):
                print(style("No running runs left; exiting.", "2"))
                return
            sys.stdout.flush()
            time.sleep(max(0.5, float(args.interval)))
    except KeyboardInterrupt:
        print()
        print(style("Stopped watching.", "2"))


def handle_runs_rerun(args: argparse.Namespace) -> None:
    payload: dict[str, Any] = {
        "upcoming_only": not args.include_past,
    }
    if args.max_posts is not None:
        payload["max_posts"] = args.max_posts
    if args.skip_ocr:
        payload["skip_ocr"] = True
    result = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="POST",
        path=f"/v1/admin/runs/{args.run_id}/rerun",
        payload=payload,
    )
    if resolve_output_format(args) == "json":
        print_json(result)
        return
    print_pretty_record("Run Rerun", result)


def handle_audit_list(args: argparse.Namespace) -> None:
    payload = request_paginated_collection(
        base_url=args.base_url,
        api_key=require_api_key(args),
        path="/v1/admin/audit-logs",
        query={
            "action": args.action,
            "resource_type": args.resource_type,
            "resource_id": args.resource_id,
            "status": args.status,
            "actor": args.actor,
            "q": args.query,
            "page": args.page,
            "page_size": args.page_size,
        },
        fetch_all=args.all,
    )
    fmt = resolve_output_format(args)
    if fmt == "json":
        print_json(payload)
        return
    items = payload.get("items") or []
    if fmt == "pretty":
        print_pretty_list(
            "Audit Logs",
            payload,
            primary=lambda row: f"{row.get('action')} · {row.get('resource_type')}/{row.get('resource_id')}",
            secondary=lambda row: status_badge(row.get("status")),
            details=lambda row: [
                f"when {row.get('created_at_utc') or ''} · actor {row.get('actor_label') or row.get('actor_id') or 'unknown'}",
                f"message {compact(row.get('message') or 'none', 72)}",
            ],
        )
        return
    print_collection_summary(payload)
    print_table(
        items,
        [
            ("when", lambda row: row.get("created_at_utc")),
            ("status", lambda row: row.get("status")),
            ("action", lambda row: row.get("action")),
            ("resource", lambda row: f"{row.get('resource_type')}/{row.get('resource_id')}"),
            ("actor", lambda row: row.get("actor_label")),
            ("message", lambda row: compact(row.get("message"), 64)),
        ],
    )


def handle_audit_errors(args: argparse.Namespace) -> None:
    args.status = "error"
    handle_audit_list(args)


def handle_usage_list(args: argparse.Namespace) -> None:
    payload = request_paginated_collection(
        base_url=args.base_url,
        api_key=require_api_key(args),
        path="/v1/admin/usage-events",
        query={
            "actor_id": args.actor_id,
            "key_prefix": args.key_prefix,
            "method": args.method,
            "path_prefix": args.path_prefix,
            "billable": args.billable,
            "page": args.page,
            "page_size": args.page_size,
        },
        fetch_all=args.all,
    )
    fmt = resolve_output_format(args)
    if fmt == "json":
        print_json(payload)
        return
    items = payload.get("items") or []
    if fmt == "pretty":
        print_pretty_list(
            "Usage Events",
            payload,
            primary=lambda row: f"{row.get('method') or ''} {row.get('path') or ''}",
            secondary=lambda row: f"{row.get('status_code') or ''} · {'billable' if row.get('billable') else 'not billable'}",
            details=lambda row: [
                f"when {row.get('created_at_utc') or ''} · actor {row.get('actor_label') or row.get('actor_id') or 'unknown'}",
                f"key {row.get('key_prefix') or 'none'} · scope {row.get('required_scope') or 'none'} · {row.get('duration_ms') or 0}ms",
                f"bytes in {row.get('request_bytes') or 0} · out {row.get('response_bytes') or 0}",
            ],
        )
        return
    print_collection_summary(payload)
    print_table(
        items,
        [
            ("when", lambda row: row.get("created_at_utc") or ""),
            ("method", lambda row: row.get("method") or ""),
            ("path", lambda row: compact(row.get("path"), 38)),
            ("status", lambda row: row.get("status_code") or ""),
            ("ms", lambda row: row.get("duration_ms") or 0),
            ("out", lambda row: row.get("response_bytes") or 0),
            ("actor", lambda row: compact(row.get("actor_label") or row.get("actor_id"), 24)),
            ("prefix", lambda row: row.get("key_prefix") or ""),
            ("billable", lambda row: "yes" if row.get("billable") else "no"),
        ],
    )


def handle_api_keys_list(args: argparse.Namespace) -> None:
    payload = request_paginated_collection(
        base_url=args.base_url,
        api_key=require_api_key(args),
        path="/v1/admin/api-keys",
        query={
            "enabled": args.enabled,
            "scope": args.scope,
            "q": args.query,
            "page": args.page,
            "page_size": args.page_size,
        },
        fetch_all=args.all,
    )
    fmt = resolve_output_format(args)
    if fmt == "json":
        print_json(payload)
        return
    items = payload.get("items") or []
    if fmt == "pretty":
        print_pretty_list(
            "API Keys",
            payload,
            primary=lambda row: row.get("name") or row.get("id") or "",
            secondary=lambda row: bool_badge(bool(row.get("enabled"))),
            details=lambda row: [
                f"id {row.get('id')} · prefix {row.get('key_prefix')} · scopes {','.join(row.get('scopes') or [])}",
                f"updated {row.get('updated_at_utc') or ''} · last used {row.get('last_used_at_utc') or 'never'}",
                f"notes {compact(row.get('notes') or 'none', 72)}",
            ],
        )
        return
    print_collection_summary(payload)
    print_table(
        items,
        [
            ("id", lambda row: row.get("id")),
            ("name", lambda row: row.get("name")),
            ("prefix", lambda row: row.get("key_prefix")),
            ("scopes", lambda row: ",".join(row.get("scopes") or [])),
            ("enabled", lambda row: "yes" if row.get("enabled") else "no"),
            ("last_used", lambda row: row.get("last_used_at_utc") or ""),
        ],
    )


def handle_api_keys_get(args: argparse.Namespace) -> None:
    payload = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="GET",
        path=f"/v1/admin/api-keys/{args.key_id}",
    )
    if resolve_output_format(args) == "json":
        print_json(payload)
        return
    print_pretty_record("API Key", payload)


def handle_api_keys_create(args: argparse.Namespace) -> None:
    payload = {
        "name": args.name,
        "scopes": args.scope,
        "enabled": not args.disabled,
    }
    if args.notes is not None:
        payload["notes"] = args.notes
    result = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="POST",
        path="/v1/admin/api-keys",
        payload=payload,
    )
    if resolve_output_format(args) == "json":
        print_json(result)
        return
    print_pretty_record("API Key Created", result)


def handle_api_keys_update(args: argparse.Namespace) -> None:
    payload: dict[str, Any] = {}
    if args.name is not None:
        payload["name"] = args.name
    if args.notes is not None:
        payload["notes"] = args.notes
    if args.scope is not None:
        payload["scopes"] = args.scope
    if args.enable:
        payload["enabled"] = True
    if args.disable:
        payload["enabled"] = False
    if not payload:
        raise CliError("No update fields supplied.")
    result = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="POST",
        path=f"/v1/admin/api-keys/{args.key_id}",
        payload=payload,
    )
    if resolve_output_format(args) == "json":
        print_json(result)
        return
    print_pretty_record("API Key Updated", result)


def handle_api_keys_delete(args: argparse.Namespace) -> None:
    if not args.yes:
        raise CliError("Refusing to delete without --yes.")
    result = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="DELETE",
        path=f"/v1/admin/api-keys/{args.key_id}",
    )
    if resolve_output_format(args) == "json":
        print_json(result)
        return
    print_pretty_record("API Key Deleted", result)


def handle_happenings_sync_targets(args: argparse.Namespace) -> None:
    result = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="POST",
        path="/v1/admin/happenings/sync-targets",
        payload={},
    )
    print_json(result)


def handle_refresh_public(args: argparse.Namespace) -> None:
    payload: dict[str, Any] = {
        "facebook_upcoming_only": not args.facebook_include_past,
        "force": args.force,
    }
    if args.instagram_username is not None:
        payload["instagram_username"] = args.instagram_username
    if args.instagram_max_posts is not None:
        payload["instagram_max_posts"] = args.instagram_max_posts
    if args.instagram_skip_ocr:
        payload["instagram_skip_ocr"] = True
    result = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="POST",
        path="/v1/admin/refresh/public",
        payload=payload,
    )
    print_json(result)


def handle_refresh_facebook_public(args: argparse.Namespace) -> None:
    result = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="POST",
        path="/v1/admin/refresh/facebook/public",
        payload={"upcoming_only": not args.include_past, "force": args.force},
    )
    print_json(result)


def handle_refresh_facebook_auth(args: argparse.Namespace) -> None:
    payload: dict[str, Any] = {}
    if args.limit is not None:
        payload["limit"] = args.limit
    if args.missing_field:
        payload["missing_fields"] = args.missing_field
    result = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="POST",
        path="/v1/admin/refresh/facebook/auth",
        payload=payload,
    )
    print_json(result)


def handle_instagram_events(args: argparse.Namespace) -> None:
    username = args.username or os.getenv("INSTAGRAM_DEFAULT_USERNAME") or "fredagsbar_guiden"
    payload = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="GET",
        path=f"/v1/instagram/{username}/events",
    )
    print_json(payload)


def handle_instagram_posts(args: argparse.Namespace) -> None:
    username = args.username or os.getenv("INSTAGRAM_DEFAULT_USERNAME") or "fredagsbar_guiden"
    payload = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="GET",
        path=f"/v1/instagram/{username}/posts",
    )
    print_json(payload)


def handle_canonical_events(args: argparse.Namespace) -> None:
    payload = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="GET",
        path="/v1/events/canonical/events",
    )
    print_json(payload)


def handle_canonical_posts(args: argparse.Namespace) -> None:
    payload = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="GET",
        path="/v1/events/canonical/posts",
    )
    print_json(payload)


def handle_canonical_rebuild(args: argparse.Namespace) -> None:
    payload = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="POST",
        path="/v1/admin/canonical/rebuild",
        payload={},
    )
    print_json(payload)


def _resolve_username(args: argparse.Namespace) -> str:
    return args.username or os.getenv("INSTAGRAM_DEFAULT_USERNAME") or "fredagsbar_guiden"


def _emit_dataset(payload: Any, args: argparse.Namespace) -> None:
    """Print JSON to stdout, or write to --output FILE if supplied. Datasets
    can be large (full IG report bundles, FB merged events) — `--output` lets
    callers persist to disk without shell redirection."""
    output_path = getattr(args, "output", None)
    if output_path:
        Path(output_path).write_text(json.dumps(payload, ensure_ascii=False, indent=2))
        print(f"Wrote {output_path}")
        return
    print_json(payload)


def _fetch_dataset(args: argparse.Namespace, path: str) -> None:
    payload = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="GET",
        path=path,
    )
    _emit_dataset(payload, args)


def handle_facebook_public_summary(args: argparse.Namespace) -> None:
    _fetch_dataset(args, "/v1/facebook/public/summary")


def handle_facebook_public_profiles(args: argparse.Namespace) -> None:
    _fetch_dataset(args, "/v1/facebook/public/page-profiles")


def handle_facebook_public_profile(args: argparse.Namespace) -> None:
    _fetch_dataset(args, f"/v1/facebook/public/page-profiles/{args.page_key}")


def handle_facebook_public_events(args: argparse.Namespace) -> None:
    _fetch_dataset(args, "/v1/facebook/public/upcoming-events")


def handle_facebook_public_report(args: argparse.Namespace) -> None:
    _fetch_dataset(args, "/v1/facebook/public/report")


def handle_facebook_auth_summary(args: argparse.Namespace) -> None:
    _fetch_dataset(args, "/v1/facebook/auth/summary")


def handle_facebook_auth_events(args: argparse.Namespace) -> None:
    _fetch_dataset(args, "/v1/facebook/auth/events")


def handle_facebook_merged_summary(args: argparse.Namespace) -> None:
    _fetch_dataset(args, "/v1/facebook/merged/summary")


def handle_facebook_merged_events(args: argparse.Namespace) -> None:
    _fetch_dataset(args, "/v1/facebook/merged/events")


def handle_instagram_summary(args: argparse.Namespace) -> None:
    _fetch_dataset(args, f"/v1/instagram/{_resolve_username(args)}/summary")


def handle_instagram_report(args: argparse.Namespace) -> None:
    _fetch_dataset(args, f"/v1/instagram/{_resolve_username(args)}/report-data")


def handle_instagram_image(args: argparse.Namespace) -> None:
    """Download a single Instagram cover image (binary). Requires --output."""
    if not args.output:
        raise CliError("--output is required (Instagram images are binary).")
    body = request_bytes(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="GET",
        path=f"/v1/instagram/{args.username}/images/{args.filename}",
    )
    Path(args.output).write_bytes(body)
    print(f"Wrote {args.output} ({len(body)} bytes)")


def handle_refresh_instagram(args: argparse.Namespace) -> None:
    payload: dict[str, Any] = {}
    if args.username is not None:
        payload["username"] = args.username
    if args.max_posts is not None:
        payload["max_posts"] = args.max_posts
    if args.skip_ocr:
        payload["skip_ocr"] = True
    result = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="POST",
        path="/v1/admin/refresh/instagram",
        payload=payload,
    )
    print_json(result)


def handle_refresh_linkedin(args: argparse.Namespace) -> None:
    payload: dict[str, Any] = {
        "force": args.force,
        "include_posts_page": not args.skip_posts_page,
    }
    if args.company is not None:
        payload["slug"] = args.company
    if args.max_posts is not None:
        payload["max_posts"] = args.max_posts
    result = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="POST",
        path="/v1/admin/refresh/linkedin",
        payload=payload,
    )
    print_json(result)


def handle_linkedin_list(args: argparse.Namespace) -> None:
    payload = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="GET",
        path="/v1/linkedin/companies",
    )
    print_json(payload)


def handle_linkedin_get(args: argparse.Namespace) -> None:
    payload = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="GET",
        path=f"/v1/linkedin/companies/{args.company}",
    )
    print_json(payload)


def handle_linkedin_posts(args: argparse.Namespace) -> None:
    payload = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="GET",
        path=f"/v1/linkedin/companies/{args.company}/posts",
    )
    print_json(payload)


def handle_refresh_website(args: argparse.Namespace) -> None:
    payload: dict[str, Any] = {"force": args.force}
    if args.url is not None:
        payload["url"] = args.url
    result = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="POST",
        path="/v1/admin/refresh/website",
        payload=payload,
    )
    print_json(result)


def handle_website_list(args: argparse.Namespace) -> None:
    payload = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="GET",
        path="/v1/websites",
    )
    print_json(payload)


def handle_website_get(args: argparse.Namespace) -> None:
    payload = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="GET",
        path=f"/v1/websites/{args.host}",
    )
    print_json(payload)


def handle_refresh_rss(args: argparse.Namespace) -> None:
    payload: dict[str, Any] = {
        "force": args.force,
        "include_details": not args.no_details,
    }
    if args.feed_url is not None:
        payload["feed_url"] = args.feed_url
    if args.max_items is not None:
        payload["max_items"] = args.max_items
    source_metadata: dict[str, Any] = {}
    if args.metadata_json:
        parsed = json.loads(args.metadata_json)
        if not isinstance(parsed, dict):
            raise CliError("--metadata-json must be a JSON object.")
        source_metadata.update(parsed)
    for key in ("source_type", "topic", "country", "language", "publisher"):
        value = getattr(args, key)
        if value:
            source_metadata[key] = value
    if source_metadata:
        payload["source_metadata"] = source_metadata
    result = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="POST",
        path="/v1/admin/refresh/rss",
        payload=payload,
    )
    print_json(result)


def handle_rss_list(args: argparse.Namespace) -> None:
    payload = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="GET",
        path="/v1/rss/feeds",
    )
    print_json(payload)


def handle_rss_get(args: argparse.Namespace) -> None:
    payload = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="GET",
        path=f"/v1/rss/feeds/{args.feed_id}",
    )
    print_json(payload)


def _rss_article_rows_from_payload(payload: dict[str, Any], *, feed_id: str, feed_label: str = "") -> list[dict[str, Any]]:
    items = payload.get("items") if isinstance(payload.get("items"), list) else []
    feed_info = payload.get("feed") if isinstance(payload.get("feed"), dict) else {}
    label = feed_label or str(feed_info.get("title") or feed_info.get("feed_url") or feed_id)
    rows: list[dict[str, Any]] = []
    for item in items:
        if not isinstance(item, dict):
            continue
        detail = item.get("detail") if isinstance(item.get("detail"), dict) else {}
        rows.append(
            {
                "feed_id": feed_id,
                "feed": label,
                "article_id": item.get("id") or "",
                "title": item.get("title") or "",
                "link": item.get("link") or "",
                "published_at": item.get("published_at") or "",
                "detail_status": detail.get("status") or "missing_url",
                "status_reason": detail.get("status_reason") or "",
                "word_count": detail.get("word_count") or 0,
                "content_source": detail.get("content_source") or "",
                "best_image": detail.get("best_image") or "",
            }
        )
    return rows


def handle_rss_articles(args: argparse.Namespace) -> None:
    rows: list[dict[str, Any]] = []
    if args.feed_id:
        payload = request_json(
            base_url=args.base_url,
            api_key=require_api_key(args),
            method="GET",
            path=f"/v1/rss/feeds/{args.feed_id}",
        )
        rows.extend(_rss_article_rows_from_payload(payload, feed_id=args.feed_id))
    else:
        payload = request_json(
            base_url=args.base_url,
            api_key=require_api_key(args),
            method="GET",
            path="/v1/rss/feeds",
        )
        feeds = payload.get("feeds") if isinstance(payload.get("feeds"), list) else []
        for feed in feeds:
            if not isinstance(feed, dict):
                continue
            feed_payload = feed.get("feed") if isinstance(feed.get("feed"), dict) else {}
            rows.extend(
                _rss_article_rows_from_payload(
                    feed_payload,
                    feed_id=str(feed.get("feed_id") or ""),
                    feed_label=str(feed.get("label") or ""),
                )
            )
    if args.status:
        wanted = args.status.casefold()
        rows = [row for row in rows if str(row.get("detail_status") or "").casefold() == wanted]
    rows = rows[: max(1, args.limit)]
    result = {"count": len(rows), "filters": {"feed_id": args.feed_id or "", "status": args.status or ""}, "items": rows}
    fmt = resolve_output_format(args)
    if fmt == "json":
        print_json(result)
        return
    if fmt == "pretty":
        print_pretty_list(
            "RSS Articles",
            result,
            primary=lambda row: row.get("title") or row.get("article_id") or "",
            secondary=lambda row: status_badge(row.get("detail_status")),
            details=lambda row: [
                f"feed {compact(row.get('feed'), 42)} · words {row.get('word_count') or 0} · source {row.get('content_source') or 'none'}",
                f"reason {compact(row.get('status_reason') or 'none', 92)}",
                f"link {compact(row.get('link'), 92)}",
            ],
        )
        return
    print_collection_summary(result)
    print_table(
        rows,
        [
            ("status", lambda row: row.get("detail_status")),
            ("words", lambda row: row.get("word_count") or 0),
            ("source", lambda row: row.get("content_source") or ""),
            ("feed", lambda row: compact(row.get("feed"), 28)),
            ("title", lambda row: compact(row.get("title"), 52)),
            ("reason", lambda row: compact(row.get("status_reason"), 44)),
        ],
    )


def handle_rss_images(args: argparse.Namespace) -> None:
    payload = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="GET",
        path="/v1/rss/images",
        query={
            "feed_id": args.feed_id,
            "article_id": args.article_id,
            "limit": args.limit,
        },
    )
    print_json(payload)


def handle_rss_image(args: argparse.Namespace) -> None:
    body = request_bytes(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="GET",
        path=f"/v1/rss/images/{quote(args.image_hash, safe='')}",
        query={
            "feed_id": args.feed_id,
            "article_id": args.article_id,
        },
    )
    output_path = Path(args.output).expanduser()
    output_path.parent.mkdir(parents=True, exist_ok=True)
    output_path.write_bytes(body)
    result = {
        "written": True,
        "output_path": str(output_path),
        "byte_count": len(body),
        "feed_id": args.feed_id,
        "article_id": args.article_id,
        "image_hash": args.image_hash,
    }
    if resolve_output_format(args) == "json":
        print_json(result)
        return
    print_pretty_record("RSS Image Saved", result)


def handle_rss_markdown(args: argparse.Namespace) -> None:
    if args.article_id:
        path = (
            f"/v1/rss/feeds/{quote(args.feed_id, safe='')}"
            f"/articles/{quote(args.article_id, safe='')}/markdown"
        )
    else:
        path = f"/v1/rss/feeds/{quote(args.feed_id, safe='')}/markdown"
    body = request_bytes(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="GET",
        path=path,
    )
    if args.output:
        output_path = Path(args.output).expanduser()
        output_path.parent.mkdir(parents=True, exist_ok=True)
        output_path.write_bytes(body)
        result = {
            "written": True,
            "output_path": str(output_path),
            "byte_count": len(body),
            "feed_id": args.feed_id,
            "article_id": args.article_id or "",
        }
        if resolve_output_format(args) == "json":
            print_json(result)
            return
        print_pretty_record("RSS Markdown Saved", result)
        return
    sys.stdout.buffer.write(body)
    if not body.endswith(b"\n"):
        sys.stdout.write("\n")


def handle_refresh_studiz(args: argparse.Namespace) -> None:
    payload: dict[str, Any] = {
        "force": args.force,
        "include_content": args.include_content,
    }
    if args.catalog_url is not None:
        payload["catalog_url"] = args.catalog_url
    if args.max_providers is not None:
        payload["max_providers"] = args.max_providers
    if args.delay_seconds is not None:
        payload["delay_seconds"] = args.delay_seconds
    result = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="POST",
        path="/v1/admin/refresh/studiz",
        payload=payload,
    )
    print_json(result)


def handle_studiz_summary(args: argparse.Namespace) -> None:
    _fetch_dataset(args, "/v1/studiz/summary")


def handle_studiz_providers(args: argparse.Namespace) -> None:
    _fetch_dataset(args, "/v1/studiz/providers")


def handle_studiz_discounts(args: argparse.Namespace) -> None:
    _fetch_dataset(args, "/v1/studiz/discounts")


def handle_studiz_provider_content(args: argparse.Namespace) -> None:
    _fetch_dataset(args, "/v1/studiz/provider-content")


def handle_refresh_google_maps(args: argparse.Namespace) -> None:
    payload: dict[str, Any] = {"force": args.force}
    if args.place_id is not None:
        payload["place_id"] = args.place_id
    result = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="POST",
        path="/v1/admin/refresh/google-maps",
        payload=payload,
    )
    print_json(result)


def handle_google_maps_list(args: argparse.Namespace) -> None:
    payload = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="GET",
        path="/v1/google-maps/places",
    )
    print_json(payload)


def handle_google_maps_get(args: argparse.Namespace) -> None:
    payload = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="GET",
        path=f"/v1/google-maps/places/{args.place_id}",
    )
    print_json(payload)


def _print_google_maps_search_table(results: list[dict[str, Any]]) -> None:
    """Render search results as a numbered table for picking."""
    if not results:
        print("(no results)")
        return
    for idx, summary in enumerate(results, start=1):
        place_id = summary.get("place_id") or "-"
        name = summary.get("name") or "-"
        address = summary.get("address") or "-"
        rating = summary.get("rating")
        rating_count = summary.get("rating_count")
        photo_count = summary.get("photo_count")
        primary = summary.get("primary_type_display") or summary.get("primary_type") or "-"
        rating_str = (
            f"{rating}★ ({rating_count})"
            if rating is not None and rating_count is not None
            else (f"{rating}★" if rating is not None else "-")
        )
        print(f"[{idx:>2}] {name}")
        print(f"      place_id: {place_id}")
        print(f"      type:     {primary}")
        print(f"      rating:   {rating_str}")
        if photo_count:
            print(f"      photos:   {photo_count}")
        print(f"      address:  {address}")


def _prompt_pick_index(count: int) -> int | None:
    """Prompt the user to pick 1..count or quit. Returns 0-based index or None."""
    if count <= 0:
        return None
    while True:
        try:
            answer = input(f"\nPick [1-{count} / q to quit]: ").strip().lower()
        except EOFError:
            return None
        if answer in ("", "q", "quit", "exit"):
            return None
        if answer.isdigit():
            n = int(answer)
            if 1 <= n <= count:
                return n - 1
        print(f"  invalid — enter 1..{count} or q")


def handle_google_maps_search(args: argparse.Namespace) -> None:
    payload: dict[str, Any] = {"query": args.query, "max_results": args.max_results}
    response = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="POST",
        path="/v1/admin/search/google-maps",
        payload=payload,
    )

    output_format = resolve_output_format(args)
    if output_format == "json":
        print_json(response)
        if not args.register:
            return

    results = list(response.get("results") or [])
    if output_format != "json":
        print(
            f"Found {response.get('result_count', len(results))} candidate(s) for "
            f"{args.query!r}:\n"
        )
        _print_google_maps_search_table(results)

    if not args.register:
        return

    pick = _prompt_pick_index(len(results))
    if pick is None:
        print("(nothing registered)")
        return
    chosen = results[pick]
    place_id = chosen.get("place_id")
    if not place_id:
        print("error: chosen result has no place_id (cannot register)")
        return

    target_payload: dict[str, Any] = {
        "platform": "google_maps",
        "target_type": "place",
        "target_value": place_id,
        "enabled": True,
        "priority": args.priority,
    }
    label = args.label or chosen.get("name")
    if label:
        target_payload["label"] = label
    if args.refresh_interval_minutes is not None:
        target_payload["refresh_interval_minutes"] = args.refresh_interval_minutes

    created = request_json(
        base_url=args.base_url,
        api_key=require_api_key(args),
        method="POST",
        path="/v1/admin/targets",
        payload=target_payload,
    )
    if output_format == "json":
        print_json(created)
        return
    print_pretty_record(f"Registered google_maps/{place_id}", created)


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Manage the Fredagsbar scraper service.")
    parser.add_argument("--base-url", default=os.getenv("SCRAPER_BASE_URL", "http://localhost:8080"))
    parser.add_argument("--api-key", default=os.getenv("SCRAPER_API_KEY"))
    parser.add_argument("--format", choices=("auto", "pretty", "table", "json"), default="auto")

    subparsers = parser.add_subparsers(dest="command", required=True)

    health = subparsers.add_parser("health", help="Check service health.")
    health.set_defaults(func=handle_health)

    version = subparsers.add_parser("version", help="Show local CLI and remote service version details.")
    version.add_argument("--target", help="Explicit CLI install path for reporting.")
    version.set_defaults(func=handle_version)

    whoami = subparsers.add_parser("whoami", help="Show the authenticated API identity.")
    whoami.set_defaults(func=handle_whoami)

    docs = subparsers.add_parser("docs", help="Fetch, export, or open protected API documentation.")
    docs.add_argument("--open", action="store_true", help="Open the protected browser docs at /ui/docs.")
    docs.add_argument("--url", action="store_true", help="Print the protected browser docs URL without opening it.")
    docs.add_argument("--output", help="Write the protected OpenAPI JSON document to this path.")
    docs.set_defaults(func=handle_docs)

    update = subparsers.add_parser("update", help="Download the latest CLI from the configured service.")
    update.add_argument("--target", help="Install path to overwrite. Defaults to the current fredagsbar-scraper executable.")
    update.add_argument("--check", action="store_true", help="Only compare local vs remote CLI version.")
    update.add_argument("--force", action="store_true", help="Download even if the remote version matches.")
    update.set_defaults(func=handle_update)

    manifest = subparsers.add_parser("manifest", help="Fetch the current manifest.")
    manifest.set_defaults(func=handle_manifest)

    targets = subparsers.add_parser("targets", help="Manage scrape targets.")
    targets_sub = targets.add_subparsers(dest="targets_command", required=True)

    targets_list = targets_sub.add_parser("list", help="List targets.")
    targets_list.add_argument("--platform")
    targets_list.add_argument("--target-type")
    targets_list.add_argument("--enabled", choices=("true", "false"))
    targets_list.add_argument("-q", "--query")
    targets_list.add_argument("--page", type=int, default=1)
    targets_list.add_argument("--page-size", type=int, default=DEFAULT_PAGE_SIZE)
    targets_list.add_argument("--all", action="store_true")
    targets_list.set_defaults(func=handle_targets_list)

    targets_get = targets_sub.add_parser("get", help="Show one target.")
    targets_get.add_argument("target_id")
    targets_get.set_defaults(func=handle_targets_get)

    targets_create = targets_sub.add_parser("create", aliases=["add"], help="Create a target.")
    targets_create.add_argument("--platform", required=True, choices=("facebook", "instagram", "linkedin", "website", "rss", "google_maps", "studiz"))
    targets_create.add_argument("--target-type", required=True, choices=("page", "event", "profile", "company", "homepage", "feed", "place", "catalog"))
    targets_create.add_argument("--target-value", required=True)
    targets_create.add_argument("--label")
    targets_create.add_argument("--notes")
    targets_create.add_argument("--priority", type=int, default=100)
    targets_create.add_argument("--refresh-interval-minutes", type=int)
    targets_create.add_argument("--metadata-json", help="JSON object stored on the target, e.g. RSS source classification.")
    targets_create.add_argument("--disabled", action="store_true")
    targets_create.set_defaults(func=handle_targets_create)

    targets_seed_ge = targets_sub.add_parser(
        "seed-gymnasieeleven-rss",
        help="Create/update the 26 RSS feed targets used by Gymnasieeleven.",
    )
    targets_seed_ge.add_argument("--priority", type=int, default=300, help="Starting priority; increments by one per feed.")
    targets_seed_ge.add_argument(
        "--refresh-interval-minutes",
        type=int,
        default=180,
        help="Refresh interval for each seeded feed target.",
    )
    targets_seed_ge.add_argument("--topic", default="general-news", help="Metadata topic for seeded feeds.")
    targets_seed_ge.add_argument("--notes", default="Seeded from Gymnasieeleven RSS source list.")
    targets_seed_ge.add_argument("--disabled", action="store_true", help="Create targets disabled.")
    targets_seed_ge.add_argument("--dry-run", action="store_true", help="Print target payloads without writing.")
    targets_seed_ge.set_defaults(func=handle_targets_seed_gymnasieeleven_rss)

    targets_seed_coverage = targets_sub.add_parser(
        "seed-danish-coverage",
        help="Create/update broad Danish RSS, news, municipality, culture, and event targets.",
    )
    targets_seed_coverage.add_argument("--priority", type=int, default=300, help="Starting priority; increments by one per target.")
    targets_seed_coverage.add_argument(
        "--refresh-interval-minutes",
        type=int,
        default=180,
        help="Refresh interval for RSS feed targets.",
    )
    targets_seed_coverage.add_argument(
        "--website-refresh-interval-minutes",
        type=int,
        default=720,
        help="Refresh interval for website/homepage targets.",
    )
    targets_seed_coverage.add_argument("--topic", default="general-news", help="Default metadata topic.")
    targets_seed_coverage.add_argument("--notes", default="Seeded from Danish coverage source list.")
    targets_seed_coverage.add_argument("--skip-gymnasieeleven", action="store_true", help="Do not include the 26 Gymnasieeleven RSS feeds.")
    targets_seed_coverage.add_argument("--disabled", action="store_true", help="Create targets disabled.")
    targets_seed_coverage.add_argument("--dry-run", action="store_true", help="Print target payloads without writing.")
    targets_seed_coverage.set_defaults(func=handle_targets_seed_danish_coverage)

    targets_seed_international = targets_sub.add_parser(
        "seed-international-coverage",
        help="Create/update international fashion, finance, lifestyle, sport, music, culture, tech, science, and world-news RSS targets.",
    )
    targets_seed_international.add_argument("--priority", type=int, default=500, help="Starting priority; increments by one per target.")
    targets_seed_international.add_argument(
        "--refresh-interval-minutes",
        type=int,
        default=180,
        help="Refresh interval for RSS feed targets.",
    )
    targets_seed_international.add_argument("--topic", default="international", help="Default metadata topic.")
    targets_seed_international.add_argument("--notes", default="Seeded from international coverage source list.")
    targets_seed_international.add_argument("--disabled", action="store_true", help="Create targets disabled.")
    targets_seed_international.add_argument("--dry-run", action="store_true", help="Print target payloads without writing.")
    targets_seed_international.set_defaults(func=handle_targets_seed_international_coverage)

    targets_update = targets_sub.add_parser("update", help="Update a target.")
    targets_update.add_argument("target_id")
    targets_update.add_argument("--label")
    targets_update.add_argument("--notes")
    targets_update.add_argument("--priority", type=int)
    targets_update.add_argument("--refresh-interval-minutes", type=int)
    targets_update.add_argument("--metadata-json", help="Replace target metadata with this JSON object.")
    targets_update.add_argument(
        "--link-instagram",
        action="append",
        help="Add an Instagram handle to metadata.cross_platform_links.instagram_profiles (repeatable).",
    )
    targets_update.add_argument(
        "--unlink-instagram",
        action="append",
        help="Remove an Instagram handle from metadata.cross_platform_links.instagram_profiles (repeatable).",
    )
    targets_update.add_argument(
        "--force",
        action="store_true",
        help="Skip target-registry validation for --link-instagram; use only when the IG dataset already exists outside the registry.",
    )
    targets_toggle = targets_update.add_mutually_exclusive_group()
    targets_toggle.add_argument("--enable", action="store_true")
    targets_toggle.add_argument("--disable", action="store_true")
    targets_update.set_defaults(func=handle_targets_update)

    targets_delete = targets_sub.add_parser("delete", aliases=["rm", "remove"], help="Delete a target.")
    targets_delete.add_argument("target_id")
    targets_delete.add_argument("--yes", action="store_true", help="Confirm deletion.")
    targets_delete.set_defaults(func=handle_targets_delete)

    targets_refresh = targets_sub.add_parser("refresh", help="Run one target immediately.")
    targets_refresh.add_argument("target_id")
    targets_refresh.add_argument("--include-past", action="store_true", help="For Facebook page targets, include past events.")
    targets_refresh.add_argument("--max-posts", type=int, help="For Instagram targets, limit scanned posts.")
    targets_refresh.add_argument("--skip-ocr", action="store_true", help="For Instagram targets, skip image OCR.")
    targets_refresh.set_defaults(func=handle_targets_refresh)

    runs = subparsers.add_parser("runs", help="Inspect scrape runs.")
    runs_sub = runs.add_subparsers(dest="runs_command", required=True)
    runs_list = runs_sub.add_parser("list", help="List scrape runs.")
    runs_list.add_argument("--target-id")
    runs_list.add_argument("--status", choices=("pending", "running", "success", "error", "conflict"))
    runs_list.add_argument("--trigger")
    runs_list.add_argument("-q", "--query")
    runs_list.add_argument("--page", type=int, default=1)
    runs_list.add_argument("--page-size", type=int, default=DEFAULT_PAGE_SIZE)
    runs_list.add_argument("--all", action="store_true")
    runs_list.set_defaults(func=handle_runs_list)

    runs_errors = runs_sub.add_parser("errors", help="List failed scrape runs.")
    runs_errors.add_argument("--target-id")
    runs_errors.add_argument("--trigger")
    runs_errors.add_argument("-q", "--query")
    runs_errors.add_argument("--page", type=int, default=1)
    runs_errors.add_argument("--page-size", type=int, default=DEFAULT_PAGE_SIZE)
    runs_errors.add_argument("--all", action="store_true")
    runs_errors.set_defaults(func=handle_runs_errors)

    runs_get = runs_sub.add_parser("get", help="Show one scrape run.")
    runs_get.add_argument("run_id")
    runs_get.set_defaults(func=handle_runs_get)

    runs_logs = runs_sub.add_parser("logs", help="Show audit rows related to one run.")
    runs_logs.add_argument("run_id")
    runs_logs.add_argument("--status", choices=("success", "error", "conflict"))
    runs_logs.add_argument("-q", "--query")
    runs_logs.add_argument("--page", type=int, default=1)
    runs_logs.add_argument("--page-size", type=int, default=DEFAULT_PAGE_SIZE)
    runs_logs.add_argument("--all", action="store_true")
    runs_logs.set_defaults(func=handle_runs_logs)

    runs_watch = runs_sub.add_parser("watch", help="Watch live scrape progress from run/audit events.")
    runs_watch.add_argument("run_id", nargs="?", help="Optional run id to watch.")
    runs_watch.add_argument("--target-id", help="Limit progress to one target id.")
    runs_watch.add_argument("--limit", type=int, default=80, help="Recent progress events to fetch per poll.")
    runs_watch.add_argument("--interval", type=float, default=2.5, help="Polling interval in seconds.")
    runs_watch.add_argument("--once", action="store_true", help="Fetch one progress snapshot and exit.")
    runs_watch.add_argument("--exit-when-idle", action="store_true", help="Exit after observed progress and no runs are running.")
    runs_watch.set_defaults(func=handle_runs_watch)

    runs_rerun = runs_sub.add_parser("rerun", help="Rerun a prior scrape run's target.")
    runs_rerun.add_argument("run_id")
    runs_rerun.add_argument("--include-past", action="store_true", help="For Facebook page targets, include past events.")
    runs_rerun.add_argument("--max-posts", type=int, help="For Instagram targets, limit scanned posts.")
    runs_rerun.add_argument("--skip-ocr", action="store_true", help="For Instagram targets, skip image OCR.")
    runs_rerun.set_defaults(func=handle_runs_rerun)

    scrape = subparsers.add_parser("scrape", help="Plan and process queued scrape jobs.")
    scrape_sub = scrape.add_subparsers(dest="scrape_command", required=True)

    scrape_initiate = scrape_sub.add_parser("initiate", help="Create queued jobs for due enabled targets.")
    scrape_initiate.add_argument("--platform", action="append", choices=("facebook", "instagram", "linkedin", "website", "rss", "google_maps", "studiz"), help="Limit planning to one platform. Repeatable.")
    scrape_initiate.add_argument("--target-id", action="append", help="Limit planning to one target id. Repeatable.")
    scrape_initiate.add_argument("--force", action="store_true", help="Ignore per-target refresh intervals.")
    scrape_initiate.add_argument("--process", action="store_true", help="Immediately process queued jobs after planning.")
    scrape_initiate.add_argument("--max-jobs", type=int, default=10, help="Maximum jobs to process when --process is set.")
    scrape_initiate.add_argument("--trigger", default="cli", help="Trigger label stored on created jobs.")
    scrape_initiate.add_argument("--job-payload-json", help="JSON object with scrape options such as max_posts, skip_ocr, max_items, include_details, include_content, or max_providers.")
    scrape_initiate.set_defaults(func=handle_scrape_initiate)

    scrape_jobs = scrape_sub.add_parser("jobs", help="Inspect queued scrape jobs.")
    scrape_jobs_sub = scrape_jobs.add_subparsers(dest="scrape_jobs_command", required=True)
    scrape_jobs_list = scrape_jobs_sub.add_parser("list", help="List scrape jobs.")
    scrape_jobs_list.add_argument("--target-id")
    scrape_jobs_list.add_argument("--status", choices=("queued", "running", "success", "error", "skipped", "cancelled"))
    scrape_jobs_list.add_argument("--platform", choices=("facebook", "instagram", "linkedin", "website", "rss", "google_maps", "studiz"))
    scrape_jobs_list.add_argument("--trigger")
    scrape_jobs_list.add_argument("-q", "--query")
    scrape_jobs_list.add_argument("--page", type=int, default=1)
    scrape_jobs_list.add_argument("--page-size", type=int, default=DEFAULT_PAGE_SIZE)
    scrape_jobs_list.add_argument("--all", action="store_true")
    scrape_jobs_list.set_defaults(func=handle_scrape_jobs_list)

    scrape_jobs_get = scrape_jobs_sub.add_parser("get", help="Show one scrape job.")
    scrape_jobs_get.add_argument("job_id")
    scrape_jobs_get.set_defaults(func=handle_scrape_jobs_get)

    scrape_safety = scrape_sub.add_parser("safety", help="View or update scrape cost safety limits.")
    scrape_safety_sub = scrape_safety.add_subparsers(dest="scrape_safety_command", required=True)
    scrape_safety_get = scrape_safety_sub.add_parser("get", help="Show scrape safety settings.")
    scrape_safety_get.set_defaults(func=handle_scrape_safety_get)
    scrape_safety_set = scrape_safety_sub.add_parser("set", help="Update scrape safety settings.")
    pause_group = scrape_safety_set.add_mutually_exclusive_group()
    pause_group.add_argument("--pause", action="store_true", help="Stop planning and processing scrape jobs.")
    pause_group.add_argument("--resume", action="store_true", help="Allow planning and processing scrape jobs.")
    scrape_safety_set.add_argument("--daily-job-limit", type=int)
    scrape_safety_set.add_argument("--per-run-job-limit", type=int)
    scrape_safety_set.add_argument("--target-daily-job-limit", type=int)
    scrape_safety_set.add_argument("--rss-max-items-per-job", type=int)
    scrape_safety_set.add_argument("--platform-limit", action="append", help="Set a platform daily cap as platform=count. Repeatable.")
    scrape_safety_set.set_defaults(func=handle_scrape_safety_set)

    scrape_process = scrape_sub.add_parser("process", help="Claim and process queued scrape jobs.")
    scrape_process.add_argument("--max-jobs", type=int, default=10)
    scrape_process.add_argument("--worker-id", default="cli")
    scrape_process.set_defaults(func=handle_scrape_process)

    audit = subparsers.add_parser("audit", help="Inspect audit logs.")
    audit_sub = audit.add_subparsers(dest="audit_command", required=True)
    audit_list = audit_sub.add_parser("list", help="List audit log rows.")
    audit_list.add_argument("--action")
    audit_list.add_argument("--resource-type")
    audit_list.add_argument("--resource-id")
    audit_list.add_argument("--actor")
    audit_list.add_argument("--status", choices=("success", "error", "conflict"))
    audit_list.add_argument("-q", "--query")
    audit_list.add_argument("--page", type=int, default=1)
    audit_list.add_argument("--page-size", type=int, default=DEFAULT_PAGE_SIZE)
    audit_list.add_argument("--all", action="store_true")
    audit_list.set_defaults(func=handle_audit_list)

    audit_errors = audit_sub.add_parser("errors", help="List audit log rows with error status.")
    audit_errors.add_argument("--action")
    audit_errors.add_argument("--resource-type")
    audit_errors.add_argument("--resource-id")
    audit_errors.add_argument("--actor")
    audit_errors.add_argument("-q", "--query")
    audit_errors.add_argument("--page", type=int, default=1)
    audit_errors.add_argument("--page-size", type=int, default=DEFAULT_PAGE_SIZE)
    audit_errors.add_argument("--all", action="store_true")
    audit_errors.set_defaults(func=handle_audit_errors)

    usage = subparsers.add_parser("usage", help="Inspect API usage events.")
    usage_sub = usage.add_subparsers(dest="usage_command", required=True)
    usage_list = usage_sub.add_parser("list", help="List request-level API usage events.")
    usage_list.add_argument("--actor-id")
    usage_list.add_argument("--key-prefix")
    usage_list.add_argument("--method", choices=("GET", "POST", "DELETE", "PUT", "PATCH", "HEAD"))
    usage_list.add_argument("--path-prefix")
    usage_list.add_argument("--billable", choices=("true", "false"))
    usage_list.add_argument("--page", type=int, default=1)
    usage_list.add_argument("--page-size", type=int, default=DEFAULT_PAGE_SIZE)
    usage_list.add_argument("--all", action="store_true")
    usage_list.set_defaults(func=handle_usage_list)

    keys = subparsers.add_parser("api-keys", help="Manage API keys.")
    keys_sub = keys.add_subparsers(dest="keys_command", required=True)
    keys_list = keys_sub.add_parser("list", help="List managed API keys.")
    keys_list.add_argument("--enabled", choices=("true", "false"))
    keys_list.add_argument("--scope", choices=("read", "admin"))
    keys_list.add_argument("-q", "--query")
    keys_list.add_argument("--page", type=int, default=1)
    keys_list.add_argument("--page-size", type=int, default=DEFAULT_PAGE_SIZE)
    keys_list.add_argument("--all", action="store_true")
    keys_list.set_defaults(func=handle_api_keys_list)

    keys_get = keys_sub.add_parser("get", help="Show one managed API key.")
    keys_get.add_argument("key_id")
    keys_get.set_defaults(func=handle_api_keys_get)

    keys_create = keys_sub.add_parser("create", help="Create a managed API key.")
    keys_create.add_argument("--name", required=True)
    keys_create.add_argument("--scope", action="append", required=True, choices=("read", "admin"))
    keys_create.add_argument("--notes")
    keys_create.add_argument("--disabled", action="store_true")
    keys_create.set_defaults(func=handle_api_keys_create)

    keys_update = keys_sub.add_parser("update", help="Update a managed API key.")
    keys_update.add_argument("key_id")
    keys_update.add_argument("--name")
    keys_update.add_argument("--scope", action="append", choices=("read", "admin"))
    keys_update.add_argument("--notes")
    keys_toggle = keys_update.add_mutually_exclusive_group()
    keys_toggle.add_argument("--enable", action="store_true")
    keys_toggle.add_argument("--disable", action="store_true")
    keys_update.set_defaults(func=handle_api_keys_update)

    keys_delete = keys_sub.add_parser("delete", aliases=["rm", "remove"], help="Delete a managed API key.")
    keys_delete.add_argument("key_id")
    keys_delete.add_argument("--yes", action="store_true", help="Confirm deletion.")
    keys_delete.set_defaults(func=handle_api_keys_delete)

    refresh = subparsers.add_parser("refresh", help="Trigger refresh jobs.")
    refresh_sub = refresh.add_subparsers(dest="refresh_command", required=True)

    refresh_public = refresh_sub.add_parser("public", help="Refresh all public datasets.")
    refresh_public.add_argument("--facebook-include-past", action="store_true")
    refresh_public.add_argument(
        "--force",
        action="store_true",
        help="Ignore each target's refresh_interval_minutes (always run all enabled targets).",
    )
    refresh_public.add_argument("--instagram-username")
    refresh_public.add_argument("--instagram-max-posts", type=int)
    refresh_public.add_argument("--instagram-skip-ocr", action="store_true")
    refresh_public.set_defaults(func=handle_refresh_public)

    refresh_fb_public = refresh_sub.add_parser("facebook-public", help="Refresh public Facebook data.")
    refresh_fb_public.add_argument("--include-past", action="store_true")
    refresh_fb_public.add_argument(
        "--force",
        action="store_true",
        help="Ignore each target's refresh_interval_minutes (always run all enabled targets).",
    )
    refresh_fb_public.set_defaults(func=handle_refresh_facebook_public)

    refresh_fb_auth = refresh_sub.add_parser("facebook-auth", help="Refresh authenticated Facebook enrichment.")
    refresh_fb_auth.add_argument("--limit", type=int)
    refresh_fb_auth.add_argument("--missing-field", action="append")
    refresh_fb_auth.set_defaults(func=handle_refresh_facebook_auth)

    refresh_instagram = refresh_sub.add_parser("instagram", help="Refresh an Instagram report.")
    refresh_instagram.add_argument("--username")
    refresh_instagram.add_argument("--max-posts", type=int)
    refresh_instagram.add_argument("--skip-ocr", action="store_true")
    refresh_instagram.set_defaults(func=handle_refresh_instagram)

    refresh_linkedin = refresh_sub.add_parser(
        "linkedin",
        help="Refresh one LinkedIn company (public scrape). Omit --company to refresh all enabled LinkedIn targets.",
    )
    refresh_linkedin.add_argument(
        "--company",
        help="LinkedIn company slug (e.g. 'novo-nordisk'). If omitted, every enabled LinkedIn target is refreshed.",
    )
    refresh_linkedin.add_argument(
        "--force",
        action="store_true",
        help="Ignore each target's refresh_interval_minutes (always run all enabled targets).",
    )
    refresh_linkedin.add_argument(
        "--skip-posts-page",
        action="store_true",
        help="Only parse post previews from /company/<slug>; do not fetch the public /posts/ page.",
    )
    refresh_linkedin.add_argument(
        "--max-posts",
        type=int,
        help="Limit stored LinkedIn posts after merging and deduping company-page and posts-page rows.",
    )
    refresh_linkedin.set_defaults(func=handle_refresh_linkedin)

    refresh_website = refresh_sub.add_parser(
        "website",
        help="Refresh one website homepage (public scrape). Omit --url to refresh all enabled website targets.",
    )
    refresh_website.add_argument(
        "--url",
        help="Website homepage URL (e.g. 'https://novonordisk.com'). If omitted, every enabled website target is refreshed.",
    )
    refresh_website.add_argument(
        "--force",
        action="store_true",
        help="Ignore each target's refresh_interval_minutes (always run all enabled targets).",
    )
    refresh_website.set_defaults(func=handle_refresh_website)

    refresh_rss = refresh_sub.add_parser(
        "rss",
        help="Refresh one RSS/Atom feed. Omit --feed-url to refresh all enabled rss/feed targets.",
    )
    refresh_rss.add_argument(
        "--feed-url",
        help="RSS/Atom feed URL (e.g. 'https://www.dust2.dk/rss').",
    )
    refresh_rss.add_argument(
        "--no-details",
        action="store_true",
        help="Only parse feed items; do not fetch each item link and scrape the actual article page.",
    )
    refresh_rss.add_argument("--max-items", type=int, help="Maximum feed items to process.")
    refresh_rss.add_argument("--source-type", help="High-level source type, e.g. news, blog, calendar.")
    refresh_rss.add_argument("--topic", help="Primary source topic, e.g. esport, student-life.")
    refresh_rss.add_argument("--country", help="Source country code, e.g. DK.")
    refresh_rss.add_argument("--language", help="Source language code, e.g. da.")
    refresh_rss.add_argument("--publisher", help="Publisher/source display name.")
    refresh_rss.add_argument("--metadata-json", help="Additional source metadata JSON object.")
    refresh_rss.add_argument(
        "--force",
        action="store_true",
        help="Ignore each target's refresh_interval_minutes (always run all enabled targets).",
    )
    refresh_rss.set_defaults(func=handle_refresh_rss)

    refresh_studiz = refresh_sub.add_parser(
        "studiz",
        help="Refresh the public Studiz discount catalog.",
    )
    refresh_studiz.add_argument(
        "--catalog-url",
        help="Studiz catalog URL. Defaults to /kategorier/alle or enabled studiz/catalog targets.",
    )
    refresh_studiz.add_argument(
        "--include-content",
        action="store_true",
        help="Also fetch each provider page for description/content/contact fields.",
    )
    refresh_studiz.add_argument(
        "--max-providers",
        type=int,
        help="Limit providers processed after catalog extraction.",
    )
    refresh_studiz.add_argument(
        "--delay-seconds",
        type=float,
        help="Delay between provider-page requests when --include-content is used.",
    )
    refresh_studiz.add_argument(
        "--force",
        action="store_true",
        help="Ignore each target's refresh_interval_minutes (always run all enabled targets).",
    )
    refresh_studiz.set_defaults(func=handle_refresh_studiz)

    refresh_google_maps = refresh_sub.add_parser(
        "google-maps",
        help="Refresh one Google place (Places API). Omit --place-id to refresh all enabled google_maps targets.",
    )
    refresh_google_maps.add_argument(
        "--place-id",
        dest="place_id",
        help="Google Place ID (e.g. 'ChIJ...'). If omitted, every enabled google_maps target is refreshed.",
    )
    refresh_google_maps.add_argument(
        "--force",
        action="store_true",
        help="Ignore each target's refresh_interval_minutes (always run all enabled targets).",
    )
    refresh_google_maps.set_defaults(func=handle_refresh_google_maps)

    happenings = subparsers.add_parser(
        "happenings",
        help="Sync state with the Happenings platform (page list, etc).",
    )
    happenings_sub = happenings.add_subparsers(dest="happenings_command", required=True)
    happenings_sync = happenings_sub.add_parser(
        "sync-targets",
        help="Pull the scrape-target list from Happenings and upsert locally.",
    )
    happenings_sync.set_defaults(func=handle_happenings_sync_targets)

    instagram_events = subparsers.add_parser(
        "events",
        help="Fetch the structured events extracted from an Instagram profile.",
    )
    instagram_events.add_argument("--username", help="Instagram profile (defaults to INSTAGRAM_DEFAULT_USERNAME or fredagsbar_guiden).")
    instagram_events.set_defaults(func=handle_instagram_events)

    instagram_posts = subparsers.add_parser(
        "posts",
        help="Fetch every scraped Instagram post (with cover image and LLM verdict, event or not).",
    )
    instagram_posts.add_argument("--username", help="Instagram profile (defaults to INSTAGRAM_DEFAULT_USERNAME or fredagsbar_guiden).")
    instagram_posts.set_defaults(func=handle_instagram_posts)

    canonical = subparsers.add_parser(
        "canonical",
        help="Fetch the cross-platform merged canonical events / posts dataset.",
    )
    canonical_sub = canonical.add_subparsers(dest="canonical_command", required=True)
    canonical_events = canonical_sub.add_parser("events", help="Fetch canonical events.json.")
    canonical_events.set_defaults(func=handle_canonical_events)
    canonical_posts = canonical_sub.add_parser("posts", help="Fetch canonical posts.json.")
    canonical_posts.set_defaults(func=handle_canonical_posts)
    canonical_rebuild = canonical_sub.add_parser(
        "rebuild",
        help="Rebuild the canonical merged dataset from current IG and FB datasets without re-scraping.",
    )
    canonical_rebuild.set_defaults(func=handle_canonical_rebuild)

    # --- facebook dataset endpoints (read-only) ----------------------------
    facebook = subparsers.add_parser(
        "facebook",
        help="Fetch Facebook scrape datasets (public, auth, merged).",
    )
    facebook_sub = facebook.add_subparsers(dest="facebook_command", required=True)

    fb_public = facebook_sub.add_parser("public", help="Public (no-login) Facebook scrape datasets.")
    fb_public_sub = fb_public.add_subparsers(dest="facebook_public_command", required=True)
    for name, help_text, func in (
        ("summary", "Fetch facebook/public/summary.json.", handle_facebook_public_summary),
        ("profiles", "Fetch facebook/public/page_profiles.json.", handle_facebook_public_profiles),
        ("events",  "Fetch facebook/public/upcoming_events.json.", handle_facebook_public_events),
        ("report",  "Fetch facebook/public/report/upcoming_report.json.", handle_facebook_public_report),
    ):
        sp = fb_public_sub.add_parser(name, help=help_text)
        sp.add_argument("--output", help="Write the JSON payload to this file instead of stdout.")
        sp.set_defaults(func=func)

    fb_public_profile = fb_public_sub.add_parser("profile", help="Fetch one facebook/public/pages/<key>/profile.json.")
    fb_public_profile.add_argument("page_key", help="Stored Facebook page key.")
    fb_public_profile.add_argument("--output", help="Write the JSON payload to this file instead of stdout.")
    fb_public_profile.set_defaults(func=handle_facebook_public_profile)

    fb_auth = facebook_sub.add_parser("auth", help="Authenticated (logged-in) Facebook enrichment datasets.")
    fb_auth_sub = fb_auth.add_subparsers(dest="facebook_auth_command", required=True)
    for name, help_text, func in (
        ("summary", "Fetch facebook/auth/summary.json.", handle_facebook_auth_summary),
        ("events",  "Fetch facebook/auth/events.json.", handle_facebook_auth_events),
    ):
        sp = fb_auth_sub.add_parser(name, help=help_text)
        sp.add_argument("--output", help="Write the JSON payload to this file instead of stdout.")
        sp.set_defaults(func=func)

    fb_merged = facebook_sub.add_parser("merged", help="Merged (public + auth-enriched) Facebook events dataset.")
    fb_merged_sub = fb_merged.add_subparsers(dest="facebook_merged_command", required=True)
    for name, help_text, func in (
        ("summary", "Fetch facebook/merged/summary.json.", handle_facebook_merged_summary),
        ("events",  "Fetch facebook/merged/events.json.", handle_facebook_merged_events),
    ):
        sp = fb_merged_sub.add_parser(name, help=help_text)
        sp.add_argument("--output", help="Write the JSON payload to this file instead of stdout.")
        sp.set_defaults(func=func)

    # --- instagram dataset endpoints (read-only) ---------------------------
    instagram = subparsers.add_parser(
        "instagram",
        help="Fetch Instagram scrape datasets (summary, report bundle, cover images).",
    )
    instagram_sub = instagram.add_subparsers(dest="instagram_command", required=True)

    ig_summary = instagram_sub.add_parser("summary", help="Fetch instagram/<u>/summary.json.")
    ig_summary.add_argument("--username", help="Instagram profile (defaults to INSTAGRAM_DEFAULT_USERNAME or fredagsbar_guiden).")
    ig_summary.add_argument("--output", help="Write the JSON payload to this file instead of stdout.")
    ig_summary.set_defaults(func=handle_instagram_summary)

    ig_report = instagram_sub.add_parser("report", help="Fetch the full instagram/<u>/report-data bundle.")
    ig_report.add_argument("--username", help="Instagram profile (defaults to INSTAGRAM_DEFAULT_USERNAME or fredagsbar_guiden).")
    ig_report.add_argument("--output", help="Write the JSON payload to this file instead of stdout.")
    ig_report.set_defaults(func=handle_instagram_report)

    ig_image = instagram_sub.add_parser("image", help="Download a single Instagram cover image (binary).")
    ig_image.add_argument("username", help="Instagram profile.")
    ig_image.add_argument("filename", help="Image filename (as referenced by cover_image_key/url).")
    ig_image.add_argument("--output", required=True, help="Local path to write the image bytes to.")
    ig_image.set_defaults(func=handle_instagram_image)

    # --- linkedin dataset endpoints (read-only) ----------------------------
    linkedin = subparsers.add_parser(
        "linkedin",
        help="Fetch LinkedIn public company snapshots.",
    )
    linkedin_sub = linkedin.add_subparsers(dest="linkedin_command", required=True)

    li_list = linkedin_sub.add_parser(
        "list",
        help="List every LinkedIn company target with its most recent snapshot (if any).",
    )
    li_list.set_defaults(func=handle_linkedin_list)

    li_get = linkedin_sub.add_parser(
        "get",
        help="Fetch linkedin/<slug>/profile.json for one company.",
    )
    li_get.add_argument("company", help="LinkedIn company slug (e.g. 'novo-nordisk').")
    li_get.set_defaults(func=handle_linkedin_get)

    li_posts = linkedin_sub.add_parser(
        "posts",
        help="Fetch stored public LinkedIn posts for one company.",
    )
    li_posts.add_argument("company", help="LinkedIn company slug (e.g. 'novo-nordisk').")
    li_posts.set_defaults(func=handle_linkedin_posts)

    # --- website dataset endpoints (read-only) -----------------------------
    website = subparsers.add_parser(
        "website",
        help="Fetch website public homepage snapshots.",
    )
    website_sub = website.add_subparsers(dest="website_command", required=True)

    web_list = website_sub.add_parser(
        "list",
        help="List every website target with its most recent snapshot (if any).",
    )
    web_list.set_defaults(func=handle_website_list)

    web_get = website_sub.add_parser(
        "get",
        help="Fetch websites/<host>/profile.json for one site.",
    )
    web_get.add_argument(
        "host",
        help="Storage-key host (e.g. 'novonordisk.com' — no scheme, no path).",
    )
    web_get.set_defaults(func=handle_website_get)

    # --- RSS dataset endpoints (read-only) ---------------------------------
    rss = subparsers.add_parser(
        "rss",
        help="Fetch RSS/Atom feed snapshots with scraped detail pages.",
    )
    rss_sub = rss.add_subparsers(dest="rss_command", required=True)

    rss_list = rss_sub.add_parser(
        "list",
        help="List every RSS feed target with its most recent snapshot (if any).",
    )
    rss_list.set_defaults(func=handle_rss_list)

    rss_get = rss_sub.add_parser(
        "get",
        help="Fetch rss/<feed_id>/feed.json for one feed.",
    )
    rss_get.add_argument("feed_id", help="Storage feed id from `rss list`.")
    rss_get.set_defaults(func=handle_rss_get)

    rss_markdown = rss_sub.add_parser(
        "markdown",
        help="Print or save Markdown for a feed snapshot or one scraped article.",
    )
    rss_markdown.add_argument("feed_id", help="Storage feed id from `rss list`.")
    rss_markdown.add_argument("--article-id", help="Return Markdown for one article id.")
    rss_markdown.add_argument("--output", help="Write Markdown to this path instead of stdout.")
    rss_markdown.set_defaults(func=handle_rss_markdown)

    rss_articles = rss_sub.add_parser(
        "articles",
        help="List RSS articles with detail-page quality status.",
    )
    rss_articles.add_argument("--feed-id", help="Limit to one RSS feed id.")
    rss_articles.add_argument(
        "--status",
        choices=("ok", "low_text", "blocked", "failed", "missing_url"),
        help="Filter by article detail status.",
    )
    rss_articles.add_argument("--limit", type=int, default=100, help="Maximum articles to return.")
    rss_articles.set_defaults(func=handle_rss_articles)

    rss_images = rss_sub.add_parser(
        "images",
        help="List stored RSS article image metadata from the database.",
    )
    rss_images.add_argument("--feed-id", help="Filter by RSS feed id.")
    rss_images.add_argument("--article-id", help="Filter by article id.")
    rss_images.add_argument("--limit", type=int, default=50, help="Maximum images to return.")
    rss_images.set_defaults(func=handle_rss_images)

    rss_image = rss_sub.add_parser(
        "image",
        help="Download one stored RSS article image from the database.",
    )
    rss_image.add_argument("feed_id", help="RSS feed id.")
    rss_image.add_argument("article_id", help="RSS article id.")
    rss_image.add_argument("image_hash", help="Image hash from `rss images`.")
    rss_image.add_argument("--output", required=True, help="Output path for the image bytes.")
    rss_image.set_defaults(func=handle_rss_image)

    # --- Studiz dataset endpoints (read-only) ------------------------------
    studiz = subparsers.add_parser(
        "studiz",
        help="Fetch Studiz public discount catalog datasets.",
    )
    studiz_sub = studiz.add_subparsers(dest="studiz_command", required=True)
    for name, help_text, func in (
        ("summary", "Fetch studiz/summary.json.", handle_studiz_summary),
        ("providers", "Fetch studiz/providers.json.", handle_studiz_providers),
        ("discounts", "Fetch studiz/discounts.json.", handle_studiz_discounts),
        ("provider-content", "Fetch studiz/provider_content.json.", handle_studiz_provider_content),
    ):
        sp = studiz_sub.add_parser(name, help=help_text)
        sp.add_argument("--output", help="Write the JSON payload to this file instead of stdout.")
        sp.set_defaults(func=func)

    # --- Google Maps (Places API) dataset endpoints ------------------------
    gmaps = subparsers.add_parser(
        "google-maps",
        help="Fetch Google Maps Places API snapshots.",
    )
    gmaps_sub = gmaps.add_subparsers(dest="google_maps_command", required=True)

    gmaps_list = gmaps_sub.add_parser(
        "list",
        help="List every google_maps target with its most recent snapshot (if any).",
    )
    gmaps_list.set_defaults(func=handle_google_maps_list)

    gmaps_get = gmaps_sub.add_parser(
        "get",
        help="Fetch google_maps/<place_id>/profile.json for one place.",
    )
    gmaps_get.add_argument(
        "place_id",
        help="Google Place ID (e.g. 'ChIJ...').",
    )
    gmaps_get.set_defaults(func=handle_google_maps_get)

    gmaps_search = gmaps_sub.add_parser(
        "search",
        help=(
            "Run a Places text search and print candidate matches. Pass "
            "--register to interactively pick one and register it as a "
            "google_maps target."
        ),
    )
    gmaps_search.add_argument(
        "query",
        help="Free-text query, e.g. 'Esperanto Fredagsbar Aarhus'.",
    )
    gmaps_search.add_argument(
        "--max-results",
        type=int,
        default=10,
        help="Cap on returned candidates (1..20). Default 10.",
    )
    gmaps_search.add_argument(
        "--register",
        action="store_true",
        help="After listing, prompt to pick a result and register it as a target.",
    )
    gmaps_search.add_argument(
        "--label",
        help="Override the label used when registering. Defaults to the place's display name.",
    )
    gmaps_search.add_argument(
        "--priority",
        type=int,
        default=10,
        help="Target priority when registering (default 10).",
    )
    gmaps_search.add_argument(
        "--refresh-interval-minutes",
        type=int,
        help="refresh_interval_minutes for the registered target. Omit to leave unset.",
    )
    gmaps_search.set_defaults(func=handle_google_maps_search)

    return parser


def main() -> int:
    parser = build_parser()
    args = parser.parse_args()
    try:
        args.func(args)
    except CliError as exc:
        if exc.payload is not None:
            print_json(exc.payload)
        else:
            print(str(exc), file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
