#!/usr/bin/env -S uv run --script --quiet
# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "httpx",
#     "atproto",
#     "pydantic-settings",
# ]
# ///
"""Daily curation post from pub-search.

Selects a focal via the backend's /recommended-by-top-authors cascade (what
the network's signal-writers are themselves recommending), constructs an
`app.bsky.embed.external` artifact aligned with bsky's standard.site embed
shape, and posts from pub-search.waow.tech.

The post is built to be simultaneously human-readable and Phi-actionable:
- text headline → readable in the bsky feed, parseable by Phi's notification poller
- the doc's AT-URI is in the post text → Phi can pass it directly into
  `describe_cluster()` / `get_document()` via the pub-search MCP it already
  has wired (no URL→AT-URI lookup needed)
- standard external embed with title + url → rich card render in bsky clients

Dedups against pub-search's own recent posts: fetches several candidates
and posts the first focal URL not already posted within the lookback
(default 7 days — matched to the endorsement window so a doc that stays
on top of the rolling-week leaderboard isn't reposted daily).

Creds: ATPROTO_HANDLE / ATPROTO_PASSWORD (from .env, or env / ENV_FILE).

Usage:
    ./scripts/daily-discovery                       # select, dedup, post if new
    ./scripts/daily-discovery --dry-run             # print artifact, don't post
    ./scripts/daily-discovery --top-authors 50      # tune resolution
    ./scripts/daily-discovery --window month        # tune time scope
    ./scripts/daily-discovery --force               # skip the dedup check
"""

import argparse
import datetime
import json
import os
import sys

import httpx
from atproto import Client, models
from pydantic_settings import BaseSettings, SettingsConfigDict

BACKEND = "https://leaflet-search-backend.fly.dev"
DEFAULT_POOL = 25
DEFAULT_WINDOW = "week"
DEDUP_HOURS_DEFAULT = 24 * 7
CANDIDATES = 10


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=os.environ.get("ENV_FILE", ".env"), extra="ignore"
    )
    atproto_handle: str = ""
    atproto_password: str = ""


def fetch_candidates(pool: int, window: str) -> list[dict]:
    """Ranked items from the top-author cascade. Empty on quiet days."""
    r = httpx.get(
        f"{BACKEND}/recommended-by-top-authors",
        params={"pool": pool, "since": window, "limit": CANDIDATES},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def compose_text(focal: dict, pool: int, window: str) -> str:
    """Headline + AT-URI line. AT-URI is in plain text so Phi extracts it
    straight from the post record without any URL→AT-URI lookup."""
    n = focal["recommendCount"]
    plural = "writer" if pool == 1 else "writers"
    return (
        f"{n} of the top {pool} signal-{plural} on long-form ATProto endorsed "
        f"this ({window}):\n\n"
        f"“{focal['title']}”\n\n"
        f"{focal['uri']}"
    )


def build_embed(focal: dict) -> models.AppBskyEmbedExternal.Main:
    """Standard external embed — title + url for the card render. bsky's
    standard.site embed renderer keys on the URL hostname (leaflet.pub etc.)
    so a plain external embed is enough to get the rich card layout."""
    return models.AppBskyEmbedExternal.Main(
        external=models.AppBskyEmbedExternal.External(
            uri=focal["url"],
            title=focal["title"],
            description="",  # v1: empty. layer in first-paragraph fetch later.
        )
    )


def recently_posted_urls(client: Client, hours: int) -> set[str]:
    """External-embed URLs pub-search itself posted within the lookback."""
    try:
        feed = client.get_author_feed(client.me.did, limit=30).feed
    except Exception:
        return set()
    cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=hours)
    urls: set[str] = set()
    for item in feed:
        rec = getattr(item.post, "record", None)
        emb = getattr(rec, "embed", None)
        ext = getattr(emb, "external", None) if emb is not None else None
        url = getattr(ext, "uri", None) if ext is not None else None
        if not url:
            continue
        created = getattr(rec, "created_at", None)
        if created:
            try:
                if datetime.datetime.fromisoformat(created.replace("Z", "+00:00")) < cutoff:
                    continue
            except ValueError:
                pass
        urls.add(url)
    return urls


def main() -> None:
    ap = argparse.ArgumentParser(description="pub-search daily curation post")
    ap.add_argument("--top-authors", type=int, default=DEFAULT_POOL,
                    help="signal-writer pool size (resolution knob — small=sharp, large=broad)")
    ap.add_argument("--window", default=DEFAULT_WINDOW,
                    choices=["day", "week", "month", "year", "all"],
                    help="when endorsements count (default week)")
    ap.add_argument("--dry-run", action="store_true",
                    help="print the artifact (text + embed JSON), don't post")
    ap.add_argument("--force", action="store_true",
                    help="skip the dedup check (for testing)")
    ap.add_argument("--dedup-hours", type=int, default=DEDUP_HOURS_DEFAULT)
    args = ap.parse_args()

    settings = Settings()
    if not (settings.atproto_handle and settings.atproto_password) and not args.dry_run:
        print("ATPROTO_HANDLE/ATPROTO_PASSWORD not set — skipping (configure to enable)")
        return

    candidates = fetch_candidates(args.top_authors, args.window)
    if not candidates:
        print(f"no focal in pool={args.top_authors} window={args.window} — quiet day, skipping")
        return

    client = None
    if not args.dry_run:
        client = Client()
        client.login(settings.atproto_handle, settings.atproto_password)

    posted = set()
    if client is not None and not args.force:
        posted = recently_posted_urls(client, args.dedup_hours)

    focal = next((c for c in candidates if c["url"] not in posted), None)
    if focal is None:
        print(f"skipped — all {len(candidates)} candidates already posted within {args.dedup_hours}h")
        return

    text = compose_text(focal, args.top_authors, args.window)
    embed = build_embed(focal)

    if args.dry_run:
        print("=== text ===")
        print(text)
        print(f"\n=== embed (app.bsky.embed.external) ===")
        print(json.dumps(
            {"$type": "app.bsky.embed.external", "external": {
                "uri": embed.external.uri,
                "title": embed.external.title,
                "description": embed.external.description,
            }},
            indent=2,
        ))
        print(f"\n=== focal ===  endorsements={focal['recommendCount']}  platform={focal['platform']}")
        print(f"  at-uri:  {focal['uri']}")
        print(f"  web url: {focal['url']}")
        return

    resp = client.send_post(text=text, embed=embed)
    print(f"posted: {resp.uri}")


if __name__ == "__main__":
    main()
