#!/usr/bin/env python3
"""
Filter domains that either:
  - do not exist,
  - were registered in the last N days (default: 90),
  - exist but have no discoverable registration date ("nodate"),
  - or hit a timeout ("timeout").

The script:
  * Tries RDAP via whoisit (after bootstrapping with overrides).
  * Falls back to WHOIS via python-whois.
  * Uses thread-based timeouts for both RDAP and WHOIS.
  * Throttles between domains to avoid rate limits.

Output CSV columns:
    domain,status,registration_date,source

  status:
    - "nonexistent" : RDAP/WHOIS confirm domain is not registered
    - "recent"      : registered within the last N days
    - "nodate"      : domain exists but no registration/creation date was found
    - "timeout"     : one or both lookups timed out, existence unknown
    - "other_error" : Some other error occurred (see stderr)
"""

from __future__ import annotations

import argparse
import csv
import sys
import time
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeout
from datetime import date, datetime, timedelta
from typing import Callable, List, Optional, Tuple, TypeVar

import whois as pywhois
from whois.exceptions import WhoisError
import whoisit
from whoisit import errors as whoisit_errors

T = TypeVar("T")

# Shared thread pool for running blocking calls with timeouts.
EXECUTOR = ThreadPoolExecutor(max_workers=10)


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description=(
            "Find domains that either do not exist, were registered recently, "
            "exist but lack a discoverable registration date, or timed out."
        ),
    )
    parser.add_argument(
        "-i",
        "--input",
        metavar="PATH",
        help="Text file with one domain per line. If omitted, domains are read from stdin.",
    )
    parser.add_argument(
        "-o",
        "--output",
        metavar="PATH",
        help="Output CSV file path. Defaults to stdout.",
    )
    parser.add_argument(
        "--delay",
        type=float,
        default=0.5,
        help="Delay between domain lookups (seconds) to avoid rate limits. Default: 0.5.",
    )
    parser.add_argument(
        "--days",
        type=int,
        default=90,
        help="Number of days for the 'recent' window. Default: 90.",
    )
    parser.add_argument(
        "--rdap-timeout",
        type=float,
        default=8.0,
        metavar="SECONDS",
        help="Per-domain timeout for RDAP lookups. Default: 8.0 seconds.",
    )
    parser.add_argument(
        "--whois-timeout",
        type=float,
        default=8.0,
        metavar="SECONDS",
        help="Per-domain timeout for WHOIS lookups. Default: 8.0 seconds.",
    )
    return parser.parse_args()


def read_domains(path: Optional[str]) -> List[str]:
    if path:
        with open(path, encoding="utf-8") as fh:
            lines = fh.readlines()
    else:
        lines = list(sys.stdin)

    domains: List[str] = []
    for line in lines:
        line = line.strip()
        if not line or line.startswith("#"):
            continue
        domains.append(line)

    return domains


def run_with_timeout(
    fn: Callable[..., T],
    timeout_seconds: float,
    *args,
    **kwargs,
) -> T:
    """Run a blocking function in a thread with a hard timeout."""
    future = EXECUTOR.submit(fn, *args, **kwargs)
    try:
        return future.result(timeout=timeout_seconds)
    except FutureTimeout as exc:
        raise TimeoutError(
            f"Operation timed out after {timeout_seconds} seconds",
        ) from exc


def normalize_date(value: object) -> Optional[date]:
    """Normalize various date/datetime/list/string formats into a date, if possible."""
    if value is None:
        return None

    if isinstance(value, list):
        dates = [normalize_date(v) for v in value]
        non_null = [d for d in dates if d is not None]
        return min(non_null) if non_null else None

    if isinstance(value, datetime):
        return value.date()

    if isinstance(value, date):
        return value

    if isinstance(value, str):
        for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%SZ"):
            try:
                return datetime.strptime(value, fmt).date()
            except ValueError:
                pass
        return None

    return None


def extract_rdap_creation_date(rdap_obj: dict) -> Optional[date]:
    """Extract registration/creation date from RDAP 'events' list."""
    events = rdap_obj.get("events") or []
    if not isinstance(events, list):
        return None

    registration_actions = {"registration", "registered"}
    creation_actions = {"creation", "created"}

    for event in events:
        action = (event.get("eventAction") or "").lower()
        if action in registration_actions:
            raw = (event.get("eventDate") or "").rstrip("Z")
            try:
                return datetime.fromisoformat(raw).date()
            except ValueError:
                continue

    for event in events:
        action = (event.get("eventAction") or "").lower()
        if action in creation_actions:
            raw = (event.get("eventDate") or "").rstrip("Z")
            try:
                return datetime.fromisoformat(raw).date()
            except ValueError:
                continue

    return None


def _rdap_call(domain: str) -> dict:
    """Internal helper: perform the raw whoisit RDAP call."""
    return whoisit.domain(domain, raw=True, follow_related=False)


def rdap_lookup(
    domain: str,
    timeout_seconds: float,
) -> Tuple[Optional[bool], Optional[date], str]:
    """
    Try to determine existence and registration date using RDAP (whoisit).

    Returns:
        (exists, created_date, source_tag)

        exists:
          - True  : RDAP record found
          - False : RDAP explicitly says 'not found'
          - None  : couldn't tell (unsupported TLD, timeout, error, etc.)

        created_date:
          - registration/creation date if found
          - None otherwise

        source_tag: short string describing the RDAP result
    """
    try:
        rdap_obj = run_with_timeout(_rdap_call, timeout_seconds, domain)
    except TimeoutError:
        print(f"[RDAP] Timeout for {domain}", file=sys.stderr)
        return None, None, "rdap_timeout"
    except whoisit_errors.UnsupportedError as exc:
        print(f"[RDAP] Unsupported TLD for {domain}: {exc}", file=sys.stderr)
        return None, None, "rdap_unsupported"
    except whoisit_errors.BootstrapError as exc:
        print(f"[RDAP] Bootstrap error for {domain}: {exc}", file=sys.stderr)
        return None, None, "rdap_bootstrap_error"
    except whoisit_errors.QueryError as exc:
        msg = str(exc).lower()
        if "404" in msg or "not found" in msg or "no rdap data" in msg:
            print(f"[RDAP] Domain not found for {domain}: {exc}", file=sys.stderr)
            return False, None, "rdap_not_found"
        print(f"[RDAP] Query error for {domain}: {exc}", file=sys.stderr)
        return None, None, "rdap_error"
    except Exception as exc:  # safety net
        print(f"[RDAP] Unexpected error for {domain}: {exc}", file=sys.stderr)
        return None, None, "rdap_error"

    if not isinstance(rdap_obj, dict):
        print(
            f"[RDAP] Unexpected response type for {domain}: {type(rdap_obj)!r}",
            file=sys.stderr,
        )
        return None, None, "rdap_error"

    created_date = extract_rdap_creation_date(rdap_obj)
    if created_date is not None:
        return True, created_date, "rdap"

    print(
        f"[RDAP] No registration/creation date found in RDAP data for {domain}.",
        file=sys.stderr,
    )
    return True, None, "rdap_no_date"


def _whois_call(domain: str):
    """Internal helper: perform the python-whois lookup."""
    return pywhois.whois(domain)


def whois_lookup(
    domain: str,
    timeout_seconds: float,
) -> Tuple[Optional[bool], Optional[date], str]:
    """
    Determine existence and creation date using python-whois.

    Returns:
        (exists, created_date, source_tag)
    """
    try:
        record = run_with_timeout(_whois_call, timeout_seconds, domain)
    except TimeoutError:
        print(f"[WHOIS] Timeout for {domain}", file=sys.stderr)
        return None, None, "whois_timeout"
    except WhoisError as exc:
        msg = str(exc).lower()
        if "no match" in msg or "not found" in msg:
            print(f"[WHOIS] Domain not found for {domain}: {exc}", file=sys.stderr)
            return False, None, "whois_not_found"
        print(f"[WHOIS] WhoisError for {domain}: {exc}", file=sys.stderr)
        return None, None, "whois_error"
    except Exception as exc:  # safety net
        print(f"[WHOIS] Unexpected error for {domain}: {exc}", file=sys.stderr)
        return None, None, "whois_error"

    created = normalize_date(getattr(record, "creation_date", None))
    if created is not None:
        return True, created, "whois"

    print(
        f"[WHOIS] No creation_date found in WHOIS data for {domain}.",
        file=sys.stderr,
    )
    return True, None, "whois_no_date"


def lookup_domain(
    domain: str,
    rdap_timeout: float,
    whois_timeout: float,
) -> Tuple[Optional[bool], Optional[date], str]:
    """
    Combined RDAP + WHOIS lookup.

    Strategy:
      1. RDAP:
         - If RDAP says 'not found' → exists=False, done.
         - If RDAP has a date → exists=True, date, done.
         - If RDAP is inconclusive or has no date → try WHOIS.
      2. WHOIS:
         - If WHOIS says 'not found' → exists=False.
         - If WHOIS has a date → exists=True, date.
         - Else → exists=True but no date.

    If both RDAP and WHOIS are inconclusive, exists will be None.
    """
    exists, created, src = rdap_lookup(domain, rdap_timeout)

    if exists is False:
        return False, None, src
    if exists is True and created is not None:
        return True, created, src

    w_exists, w_created, w_src = whois_lookup(domain, whois_timeout)

    if w_exists is not None:
        combined_source = f"{src}+{w_src}" if src.startswith("rdap") else w_src
        return w_exists, w_created, combined_source

    # WHOIS also inconclusive → fall back to RDAP result.
    return exists, created, src


def bootstrap_rdap() -> None:
    """Ensure whoisit is bootstrapped once, with overrides enabled."""
    if whoisit.is_bootstrapped():
        return

    print("[RDAP] Bootstrapping whoisit (with overrides)…", file=sys.stderr)
    whoisit.bootstrap(overrides=True)
    print("[RDAP] Bootstrapping complete.", file=sys.stderr)


def main() -> None:
    args = parse_args()
    domains = read_domains(args.input)

    bootstrap_rdap()

    today = date.today()
    threshold = today - timedelta(days=args.days)

    if args.output:
        out_fh = open(args.output, "w", newline="", encoding="utf-8")
        close_output = True
    else:
        out_fh = sys.stdout
        close_output = False

    writer = csv.writer(out_fh)
    writer.writerow(["domain", "status", "registration_date", "source"])

    total = len(domains)
    for idx, domain in enumerate(domains, start=1):
        print(f"[{idx}/{total}] Checking {domain}…", file=sys.stderr)

        exists, created, source = lookup_domain(
            domain,
            rdap_timeout=args.rdap_timeout,
            whois_timeout=args.whois_timeout,
        )

        # Case 1: domain definitely does not exist.
        if exists is False:
            writer.writerow([domain, "nonexistent", "", source])

        # Case 2: domain exists and we know its creation date; check if recent.
        elif exists is True and created is not None:
            if created >= threshold:
                writer.writerow(
                    [
                        domain,
                        "recent",
                        created.isoformat(),
                        source,
                    ],
                )

        # Case 3: domain exists but we cannot find a registration date.
        elif exists is True and created is None:
            writer.writerow(
                [
                    domain,
                    "nodate",
                    "",
                    source,
                ],
            )

        # Case 4: existence is unknown but we hit a timeout → include as timeout.
        elif exists is None and "timeout" in source:
            writer.writerow(
                [
                    domain,
                    "timeout",
                    "",
                    source,
                ],
            )

        # Case 5: Other errors
        elif exists is None:
            writer.writerow(
                [
                    domain,
                    "other_error",
                    "",
                    source,
                ],
            )

        if args.delay > 0 and idx < total:
            time.sleep(args.delay)

    if close_output:
        out_fh.close()


if __name__ == "__main__":
    main()
