#!/usr/bin/env python3
"""Bulk-identify the owners of a list of domains with the WhoisGenius API.

Companion script for the blog post "How to find out who owns a website,
programmatically, at SEO scale". Reads one domain per line (comments and
blank lines ok), submits them through POST /analyze/batch in chunks of 50,
polls until every job reaches a terminal state, and writes a flat CSV.

    export WHOISGENIUS_API_KEY=wg_...
    python bulk_analyze.py domains_sample.txt owners.csv

Notes:
- /analyze/batch accepts up to 50 domains per call (10 for depth=quick).
- One credit per accepted domain; failed submissions refund automatically.
- Rate limits are per hour (free 200 req/h, pro 500 req/h); this script
  honors 429 + Retry-After instead of hammering.
- For 500+ domains or fire-and-forget delivery, use POST /analyze/bulk
  instead: it aggregates to CSV+JSON and emails you download links.
"""

from __future__ import annotations

import csv
import os
import sys
import time

import httpx

API = "https://api.whoisgeni.us"
KEY = os.environ["WHOISGENIUS_API_KEY"]
HEADERS = {"X-API-Key": KEY, "Content-Type": "application/json"}
TERMINAL = {"completed", "partial", "failed"}


def load_domains(path: str) -> list[str]:
    with open(path, encoding="utf-8") as fh:
        return [
            line.strip()
            for line in fh
            if line.strip() and not line.startswith("#")
        ]


def request_with_backoff(client: httpx.Client, method: str, url: str, **kw) -> httpx.Response:
    for attempt in range(6):
        resp = client.request(method, url, headers=HEADERS, **kw)
        if resp.status_code != 429:
            resp.raise_for_status()
            return resp
        wait = float(resp.headers.get("Retry-After", 2**attempt))
        print(f"  429 rate-limited, sleeping {wait:.0f}s", file=sys.stderr)
        time.sleep(wait)
    resp.raise_for_status()
    return resp  # unreachable, keeps type checkers happy


def submit_batch(client: httpx.Client, domains: list[str]) -> dict[str, str]:
    """Submit one batch, return {domain: job_id} for the accepted domains.

    The response is 200/207 with a jobs[] entry per domain: accepted ones
    carry a job_id, rejected ones (e.g. invalid syntax) carry an error and
    are never charged, so there is nothing to refund for them.
    """
    resp = request_with_backoff(
        client, "POST", f"{API}/analyze/batch",
        json={"domains": domains, "depth": "deep"},
    )
    jobs: dict[str, str] = {}
    for item in resp.json()["data"]["jobs"]:
        if item.get("job_id"):
            jobs[item["domain"]] = item["job_id"]
        else:
            print(f"  rejected: {item['domain']} ({item.get('error')})", file=sys.stderr)
    return jobs


def poll_job(client: httpx.Client, job_id: str) -> dict:
    while True:
        job = request_with_backoff(client, "GET", f"{API}/jobs/{job_id}").json()["data"]
        if job["status"] in TERMINAL:
            return job
        time.sleep(10)


def main() -> None:
    domains = load_domains(sys.argv[1] if len(sys.argv) > 1 else "domains_sample.txt")
    out_path = sys.argv[2] if len(sys.argv) > 2 else "owners.csv"
    print(f"{len(domains)} domains to analyze")

    with httpx.Client(timeout=60) as client:
        pending: dict[str, str] = {}  # job_id -> domain
        for i in range(0, len(domains), 50):
            chunk = domains[i : i + 50]
            for domain, job_id in submit_batch(client, chunk).items():
                pending[job_id] = domain
            print(f"submitted {min(i + 50, len(domains))}/{len(domains)}")

        rows = []
        for job_id, domain in pending.items():
            job = poll_job(client, job_id)
            scoring = job.get("scoring_result") or {}
            attr = scoring.get("attribution") or {}
            # Entity detail (parent rollup, legal name, impersonation flag) lives
            # on the top entity, NOT on the attribution object.
            top = (scoring.get("entities") or [{}])[0]
            rows.append({
                "domain": domain,
                "status": job["status"],
                "verdict": attr.get("verdict", ""),
                "operator": attr.get("operator") or "",
                "entity_name": attr.get("entity_name") or "",
                "confidence": attr.get("confidence", ""),
                "parent_company": top.get("parent_company") or "",
                "impersonation_suspected": top.get("impersonation_suspected", ""),
                "rationale": attr.get("rationale", ""),
            })
            print(f"{domain}: {rows[-1]['verdict']} -> {rows[-1]['operator'] or '(none)'}")

    with open(out_path, "w", newline="", encoding="utf-8") as fh:
        writer = csv.DictWriter(fh, fieldnames=list(rows[0].keys()))
        writer.writeheader()
        writer.writerows(rows)
    print(f"wrote {len(rows)} rows to {out_path}")


if __name__ == "__main__":
    main()
