# Bewary API v1 response models (pydantic 2). Copy into your project:
#   from bewary_models import validate_envelope; env = validate_envelope(response.json())
# Source: https://bewary.app/docs/response
"""The v1 response contract (docs/b2b/API-CONTRACT.md) as Pydantic models, for the OpenAPI spec.

These describe what contract.build_envelope returns; they do not build it. Routes declare them
with `responses=` (documentation only), so a model can never drop or rewrite a live field. The
tests validate real envelopes, and the docs' captured examples, against them, which is what keeps
the published schema honest. Fact models forbid unknown keys: a fact the engine starts sending
fails the test until it is documented here and in the contract.
"""
from __future__ import annotations

from typing import Annotated, Any, Dict, List, Literal, Optional, Union

from pydantic import BaseModel, ConfigDict, Field

Verdict = Literal["scam", "suspicious", "safe", "unknown"]
EntityType = Literal["url", "domain", "ip", "phone", "shortcode", "email", "wallet", "payment_handle", "iban", "app"]
Role = Literal["sender", "link_target", "redirect_hop", "hosting", "callback_number", "reply_to",
               "payee", "mentioned", "unknown"]
Severity = Literal["info", "low", "medium", "high", "critical"]
SourceClass = Literal["confirmed", "first_party", "heuristic", "model", "attacker_content"]
Channel = Literal["sms", "email", "web", "voice", "social_dm", "messaging_app", "unknown"]
DegradedReason = Literal["source_timeout", "source_unavailable", "budget_limit", "redirect_unresolved",
                         "input_truncated", "model_unavailable", "page_unavailable"]
NumberType = Literal["mobile", "landline", "voip", "toll_free", "premium", "shared_cost", "shortcode",
                     "unknown"]
AgeBucket = Literal["lt_7d", "d7_30", "d30_90", "d90_365", "gt_1y", "unknown"]
# "WW" (worldwide) or the ISO 3166 alpha-2 code of the one country a dataset describes ("US", "DE")
Coverage = Annotated[str, Field(pattern=r"^(WW|[A-Z]{2})$")]
DatasetId = Literal["registration", "dns", "certificates", "web_archive", "threat_feeds", "threat_aggregates",
                    "brand_registry", "web_pages",
                    "ip_network", "phone_numbering", "us_numbering", "phone_complaints", "honeypot_lines",
                    "phone_reports_de", "shortcodes",
                    "email_providers", "breaches", "wallet_reports", "sanctions", "community_reports",
                    "campaigns", "text_model", "platform_pages", "iban_registry", "bank_codes", "app_stores",
                    "short_links", "payment_profiles"]
ShortLinkState = Literal["active", "warning", "disabled", "removed", "not_found", "hosted", "refused", "unknown"]
ShortLinkService = Literal["bit.ly", "tinyurl.com", "is.gd", "v.gd", "cutt.ly", "rebrand.ly", "t.ly", "ow.ly",
                           "shorturl.at", "tiny.cc", "t.co", "qrco.de", "surl.li", "u.to", "zpr.io", "vk.cc", "clck.ru",
                           "other"]
HandleStatus = Literal["active", "inactive", "not_found", "refused", "unknown"]


class _Strict(BaseModel):
    model_config = ConfigDict(extra="forbid")


# ── facts by entity type (API-CONTRACT §4.1). Every key is always present, null when unknown. ──

class RedirectHop(_Strict):
    url: str = Field(..., description="A short link we read: the link itself, then any other short link it led to.")
    status: Optional[int] = Field(None, description="The HTTP status the shortener answered. The destination is never "
                                                    "requested, so it is final_url, not a hop.")


class ShortLink(_Strict):
    """What the shortener itself says about the link, read from its own answer (E3, added 2026-10-29)."""
    service: ShortLinkService = Field(..., description=(
        "The shortening service, named by its main short domain (j.mp is bit.ly, rb.gy is rebrand.ly, q-r.to and "
        "l.ead.me are qrco.de). other: a shortener we follow but whose block and warning pages are not measured."))
    state: ShortLinkState = Field(..., description=(
        "active: it redirects to final_url. warning: it shows its own warning page before the destination. "
        "disabled: the shortener disabled the link and says why (reason). removed: the link existed and no longer "
        "works, no reason given. not_found: the service has no such link. hosted: the service answers with a page "
        "of its own instead of a redirect. refused: the service refused our read. unknown: no answer we can read."))
    reason: Optional[Literal["terms", "account_suspended", "threat"]] = Field(None, description=(
        "When disabled: it broke the service's rules (terms), the account that made it was suspended for abuse "
        "(account_suspended), or the service flagged it as harmful (threat). Null otherwise."))
    read_at: Optional[str] = Field(None, description="When the shortener's answer was read, RFC 3339 UTC.")


class PageFacts(_Strict):
    analyzed: Optional[bool] = Field(None, description="The page was read (deep=true only).")
    title_present: Optional[bool] = None
    has_login_form: Optional[bool] = None
    has_payment_form: Optional[bool] = None
    brand_shown: Optional[str] = Field(None, description="Brand id the page shows, if any.")


HostingPlatform = Literal["vercel", "netlify", "cloudflare", "github_pages", "firebase", "render", "glitch", "blogger",
                          "gitbook", "godaddy_sites", "lovable", "heroku", "surge", "replit", "framer", "weebly",
                          "square_online", "wix", "webflow", "canva_sites", "google_docs", "linktree", "jotform",
                          "microsoft_forms", "typeform"]


class PlatformStatus(_Strict):
    """What the hosting or form platform itself serves here, from a page our own worker read in the last 90 days
    (E5/E7). Only the platform's own pages count; a live page read after them clears the fact."""
    platform: HostingPlatform
    status: Literal["taken_down", "warning", "not_found", "suspended"] = Field(..., description=(
        "taken_down: the platform's page says it removed or blocked it for abuse or a terms violation. warning: the "
        "platform shows its own phishing or malware warning in front of it. not_found: the platform serves nothing "
        "here (never made, deleted by its owner, or removed without saying why: most hosts show one page for all "
        "three). suspended: paused for its owner's plan or usage, or by its owner; not a verdict on the site."))
    read_at: str = Field(..., description="The newest read that showed this page, RFC 3339 UTC.")
    flagged_at: Optional[str] = Field(None, description=(
        "The first read that showed the platform's takedown page (else its warning page) since the site was last "
        "seen live, RFC 3339 UTC. Kept when the platform later deletes the site; null if it never flagged it."))


_TARGETS = ("ISO 3166-1 alpha-2 countries whose people it is aimed at, from the national lists that name it (CERT "
            "Polska: PL, Taiwan's 165 line: TW, Germany's regulator: DE, US complaint feeds: US). Empty when no list "
            "with a national scope names it.")
_LISTS = "How many lists name it (our own and others'); 96% of listed domains are on one list (2026-10-27)."


class UrlFacts(_Strict):
    scheme: Optional[Literal["http", "https", "other"]] = None
    host: Optional[str] = Field(None, description="Entity id of the link's domain.")
    final_url: Optional[str] = Field(None, description="Where the link lands after shorteners and redirects.")
    redirect_chain: Optional[List[RedirectHop]] = Field(None, description="The short links read on the way, in order.")
    shortener: Optional[bool] = None
    redirect_resolved: Optional[bool] = None
    short_link: Optional[ShortLink] = Field(None, description=(
        "On a shortener: the link's state as the shortener answers it. Null for a link that is not on a shortener."))
    page: Optional[PageFacts] = None
    target_countries: Optional[List[str]] = Field(None, description=_TARGETS)
    list_count: Optional[int] = Field(None, description=_LISTS)
    platform_status: Optional[PlatformStatus] = Field(None, description=(
        "A form or link-in-bio platform's own answer about this one URL (a Google form, a Linktree profile, a "
        "Jotform form). A hosting platform's answer about the whole site is on the host's domain entity."))


class Lookalike(_Strict):
    brand_id: Optional[str] = None
    technique: Optional[Literal["homoglyph", "typo", "brand_word", "brand_scam_word", "subdomain",
                                "tld_swap", "userinfo"]] = None


class DnsFacts(_Strict):
    resolves: Optional[bool] = None
    a: Optional[List[str]] = None
    mx: Optional[bool] = None
    ns: Optional[List[str]] = None


SiteReason = Literal["impersonates", "throwaway_name", "claim_vs_age", "asks_sensitive", "too_good"]


class SiteBand(_Strict):
    """The score band a site's score fell in, and what the labelled hosts in it turned out to be (1.4.0)."""
    score_from: float = Field(..., ge=0, le=1, description="The band's lowest score (inclusive).")
    score_to: float = Field(..., ge=0, le=1, description="The band's highest score (exclusive, but 1.0 is in the top band).")
    hosts: int = Field(..., ge=0, description="Labelled hosts whose score fell in this band.")
    scam_sites: int = Field(..., ge=0, description="How many of them were scam or compromised sites.")
    p_low: float = Field(..., ge=0, le=1, description="95% interval of the band's scam share, low end (Clopper-Pearson).")
    p_high: float = Field(..., ge=0, le=1, description="95% interval of the band's scam share, high end.")


class SiteVerdict(_Strict):
    """Our reading of the site at this host, from a page our own worker read (added 2026-10-28; calibrated 1.4.0).
    Jev reads the page together with the domain's facts. Its score ranks scam sites well (AUC 0.915 on a blind round)
    but is not a probability, so `p_scam_site` is the measured scam share of labelled hosts in the same score band
    (docs/b2b/PRECISION.md, GET /v1/calibration/site). It never changes the entity's verdict."""
    read_at: str = Field(..., description="When our worker read the page, RFC 3339 UTC.")
    scam_site: Optional[bool] = Field(None, description=(
        "Null: no call is made from the score (a call would need the precision PRECISION.md proves first). Only an "
        "operator's `live` mode, not used, sets it."))
    p_scam_site: float = Field(..., ge=0, le=1, description=(
        "The calibrated probability that the site is a scam site: the share of labelled hosts in `band` that were, "
        "estimated (k + 1/2) / (n + 1) and monotone in the score."))
    score: float = Field(..., ge=0, le=1, description="The model's raw answer, a ranking score, not a probability.")
    band: SiteBand = Field(..., description="The score band and its labelled counts and interval.")
    calibration: str = Field(..., description="The calibration fit used, e.g. site-cal-20261030-702.")
    reasons: List[SiteReason] = Field(..., description=(
        "When the score is at or above the threshold frozen before the blind test: what the page does, as the model "
        "read it (pretends to be an organisation it is not, a throwaway name, claims an established business on a "
        "young domain, asks for sensitive details or a payment, too good to be true). Empty otherwise."))
    template_hosts: Optional[int] = Field(None, description=(
        "How many other hosts we read whose page has this page's template (a campaign's sites share one)."))
    version: str = Field(..., description="The verdict's question set, e.g. d2.")
    model: str = Field(..., description="The pinned model that answered, e.g. jev-1.14.0.")


RegistryStatus = Literal[
    "active", "inactive", "associated", "locked", "validated", "proxy", "private", "removed", "obscured",
    "add_period", "auto_renew_period", "renew_period", "transfer_period", "redemption_period",
    "pending_create", "pending_delete", "pending_renew", "pending_restore", "pending_transfer", "pending_update",
    "client_hold", "server_hold",
    "client_delete_prohibited", "client_renew_prohibited", "client_transfer_prohibited", "client_update_prohibited",
    "server_delete_prohibited", "server_renew_prohibited", "server_transfer_prohibited", "server_update_prohibited",
    "delete_prohibited", "renew_prohibited", "transfer_prohibited", "update_prohibited", "other"]
Hold = Literal["none", "registrar", "registry", "both"]


class ArchiveHistory(_Strict):
    """The site's history in a public web archive (E9): when its homepage was first and last captured, and whether
    it was captured before the current registration began (a name that lapsed and was registered again)."""
    checked_at: str = Field(..., description="When we read the archive's index, RFC 3339 UTC. Kept for up to 30 days.")
    captured: bool = Field(..., description="The archive holds at least one capture of the host's homepage.")
    first_capture_at: Optional[str] = Field(None, description="The first capture, RFC 3339 UTC; null when none.")
    last_capture_at: Optional[str] = Field(None, description="The latest capture, RFC 3339 UTC; null when none.")
    captured_before_registration: Optional[bool] = Field(None, description=(
        "The homepage was captured more than 30 days before registered_at: the name had an earlier life before "
        "this registration (it lapsed and someone registered it again). Null when either date is unknown, or for a "
        "tenant of a hosting platform."))
    months_before_registration: Optional[int] = Field(None, description=(
        "How many calendar months hold a capture before registered_at; null unless captured_before_registration."))
    last_capture_before_registration_at: Optional[str] = Field(None, description=(
        "The last capture before registered_at, RFC 3339 UTC: the end of the earlier life."))


class DomainFacts(_Strict):
    registrable_domain: Optional[str] = Field(None, description=(
        "The owner boundary per the Public Suffix List with its private section: x.pages.dev stays x.pages.dev. "
        "Block by this."))
    registered_domain: Optional[str] = Field(None, description=(
        "The name a registry sold (Public Suffix List, ICANN section): x.pages.dev -> pages.dev. registered_at, "
        "expires_at and registrar describe this name."))
    tld: Optional[str] = None
    tld_abuse: Optional[Literal["low", "medium", "high"]] = None
    registered_at: Optional[str] = Field(None, description="RFC 3339 UTC.")
    expires_at: Optional[str] = Field(None, description="When the registration expires, RFC 3339 UTC, from RDAP.")
    age_days: Optional[int] = None
    age_bucket: Optional[AgeBucket] = None
    registrar: Optional[str] = Field(None, description="The registrar's name, from RDAP.")
    dns: Optional[DnsFacts] = None
    status: Optional[Literal["live", "gone", "broken", "parked", "held", "unknown"]] = Field(None, description=(
        "What the domain is doing now: live (resolves), gone (NXDOMAIN), broken (SERVFAIL: a delegation that no "
        "longer answers), parked (a parking service's name servers), held (does not resolve because its registry or "
        "registrar holds it, see hold), unknown (no answer in time)."))
    cert_first_seen: Optional[str] = None
    lookalike: Optional[Lookalike] = None
    platform: Optional[Literal["none", "multi_tenant"]] = None
    allowlisted: Optional[bool] = None
    listed: Optional[bool] = Field(None, description="On Bewary's scam list.")
    seen_in_checks_7d: Optional[int] = None
    country: Optional[str] = Field(None, description=(
        "ISO 3166-1 alpha-2 country its country-code TLD names; null for generic TLDs and generic-use ccTLDs (.co, "
        ".io, .tv...)."))
    target_countries: Optional[List[str]] = Field(None, description=_TARGETS)
    list_count: Optional[int] = Field(None, description=_LISTS)
    site: Optional[SiteVerdict] = Field(None, description=(
        "Our verdict on the site, when our worker read its page in the last 90 days; null otherwise."))
    platform_status: Optional[PlatformStatus] = Field(None, description=(
        "The hosting platform's own answer about the site (Vercel's takedown page, Cloudflare's phishing warning, a "
        "host's not-found page), when our worker read it; null otherwise."))
    registry_status: Optional[List[RegistryStatus]] = Field(None, description=(
        "The status codes the registry publishes for registered_domain (the EPP statuses of RFC 5731 and 3915 as RDAP "
        "carries them, RFC 8056), in snake case: server_hold, client_hold, redemption_period, pending_delete, "
        "client_transfer_prohibited and the rest. Null when the registry publishes no status."))
    hold: Optional[Hold] = Field(None, description=(
        "Who holds the name out of the DNS: registry (serverHold), registrar (clientHold), both, or none. A hold is "
        "how registries and registrars take a domain down. Null when the registry's status is unknown."))
    registered: Optional[bool] = Field(None, description=(
        "true: the registry has registered_domain; false: the registry answered that no such name is registered (it "
        "lapsed or was deleted, and anyone may register it). Null when no registration service covers the TLD."))
    registration_checked_at: Optional[str] = Field(None, description=(
        "When the registration record (registered_at, expires_at, registrar, registry_status, hold) was read, RFC "
        "3339 UTC. An answer is kept up to 7 days; a daily pass reads names on our lists again, those that stopped "
        "resolving first."))
    archive: Optional[ArchiveHistory] = Field(None, description=(
        "The site's history in a public web archive; null until we have read it (a first lookup queues the read)."))
    first_seen_by_bewary: Optional[str] = Field(None, description=(
        "When Bewary's own watch first saw this host, in a new certificate or a new name in a registry's zone file, "
        "RFC 3339 UTC; null when it never did."))
    first_listed_publicly: Optional[str] = Field(None, description=(
        "When a public list we read first named this host, RFC 3339 UTC; null when none names it, or when a list "
        "naming it already did the first time we read that list (the date it was added there is not known)."))


class IpFacts(_Strict):
    version: Optional[Literal[4, 6]] = None
    asn: Optional[int] = None
    as_org: Optional[str] = None
    country: Optional[str] = Field(None, description="Registry country of the address block.")
    hosting_type: Optional[Literal["datacenter", "cdn", "residential", "mobile", "business", "unknown"]] = None
    reverse_dns: Optional[str] = None
    listed: Optional[bool] = None


class DailyCount(_Strict):
    day: str = Field(..., description="YYYY-MM-DD.")
    count: int


class ComplaintStats(_Strict):
    """One source's complaints about a number over the retained 13 months (DIRECTORY-CONTRACT.md).
    Counts only: never a complaint's text, a name or a place smaller than a state."""
    count: int
    days: int = Field(..., description="Distinct days with a complaint.")
    first_at: str = Field(..., description="First complaint day, 00:00Z.")
    last_at: str = Field(..., description="Last complaint day, 00:00Z.")
    subjects: Dict[str, int] = Field(..., description="FTC: subject code (imposters, debt_reduction, ...); FCC: call type (prerecorded_voice, live_voice, text_message, ...).")
    robocall_share: float = Field(..., ge=0, le=1, description="FTC: recorded-message flag; FCC: prerecorded-voice share.")
    daily: List[DailyCount] = Field(..., description="The last 90 days, only days with complaints, oldest first.")
    reporter_states: Dict[str, int] = Field(..., description="Top 5 US states (USPS codes) of the people who complained.")
    hours: Dict[str, int] = Field(..., description='Complaints by the hour of the call, "0" to "23", every hour present: the reporter\'s local time as they gave it (FCC: the call time; FTC: the call time when it was not given as midnight). 13 months.')
    weekdays: Dict[str, int] = Field(..., description='Complaints by weekday of the complaint day, "1" (Monday) to "7", every day present. 13 months.')


class NumberLink(_Strict):
    e164: str
    reports: int
    indexable: bool = Field(..., description="The linked number has a page: the index rule, inside the current wave.")


class PushedBy(_Strict):
    """A number as the callback number other numbers' calls told people to call (FCC)."""
    reports: int = Field(..., description="Complaints that named this number as the callback number.")
    days: int = Field(..., description="Distinct days with such a complaint.")
    callers: int = Field(..., description="Distinct caller IDs that pushed it (complaints with no caller ID not counted).")
    first_at: str = Field(..., description="First such day, 00:00Z.")
    last_at: str = Field(..., description="Last such day, 00:00Z.")
    subjects: Dict[str, int] = Field(..., description="The FCC call types of those complaints (prerecorded_voice, live_voice, text_message, ...), most first.")
    daily: List[DailyCount] = Field(..., description="The last 90 days, only days with reports, oldest first.")
    hours: Dict[str, int] = Field(..., description='Reports by the hour of the call, "0" to "23", every hour present.')
    weekdays: Dict[str, int] = Field(..., description='Reports by weekday, "1" (Monday) to "7", every day present.')
    reporter_states: Dict[str, int] = Field(..., description="Top 5 US states (USPS codes) of the people who complained.")
    top: List[NumberLink] = Field(..., description="The caller IDs that pushed it, most reports first, at most 20.")


class PhoneComplaints(_Strict):
    ftc: Optional[ComplaintStats] = None
    fcc: Optional[ComplaintStats] = None
    callbacks: Optional[List[NumberLink]] = Field(None, description="FCC: the callback numbers calls from this number told people to call, most reports first, at most 5; null when none.")
    pushed_by: Optional[PushedBy] = Field(None, description="FCC: this number as the callback number in other numbers' calls; null when it never was one.")


class PhoneFacts(_Strict):
    e164: Optional[str] = None
    country: Optional[str] = None
    number_type: Optional[NumberType] = None
    original_carrier: Optional[str] = None
    line_type: Optional[NumberType] = None
    complaints: Optional[PhoneComplaints] = Field(None, description="FTC and FCC complaints; null when the directory could not be read.")
    honeypot_hits_30d: Optional[int] = None
    listed: Optional[bool] = None
    public_listing: Optional[Literal["business", "personal", "none", "unknown"]] = Field(
        None, description="Never a person's name.")
    indexable: Optional[bool] = Field(None, description="The number has a directory page: it meets the index rule (two independent signals) and is inside the current wave.")
    target_countries: Optional[List[str]] = Field(None, description=_TARGETS)
    list_count: Optional[int] = Field(None, description=_LISTS)


class Operator(_Strict):
    name: str
    brand_id: Optional[str] = None


class ShortcodeFacts(_Strict):
    code: Optional[str] = None
    country: Optional[str] = None
    status: Optional[Literal["legitimate", "mixed", "scam", "unknown"]] = None
    operator: Optional[Operator] = None
    legit_uses: Optional[List[str]] = None
    reports_30d: Optional[int] = None


class EmailFacts(_Strict):
    domain: Optional[str] = Field(None, description="Entity id of the address's domain.")
    mx: Optional[bool] = None
    disposable: Optional[bool] = None
    free_provider: Optional[bool] = None
    role_account: Optional[bool] = None
    disposable_status: Optional[Literal["receiving", "dead", "unknown"]] = Field(None, description=(
        "For a throwaway provider: receiving (it still takes mail: a mail host that resolves, or the domain's own "
        "address when it has no MX, RFC 5321), dead (the name is gone, declares it takes no mail with a null MX, "
        "RFC 7505, or has no mail host that resolves), unknown (not re-checked yet). Null when not disposable."))
    disposable_checked_at: Optional[str] = Field(None, description=(
        "When the throwaway domain was last re-checked, RFC 3339 UTC; null when not disposable or not checked yet."))


class WalletFacts(_Strict):
    chain: Optional[Literal["btc", "eth", "trx", "ltc", "sol", "xmr", "dash"]] = Field(None, description=(
        "From the address format; eth is any EVM address (the same address exists on every EVM chain)."))
    caip2: Optional[str] = Field(None, description="CAIP-2 chain id where the chain is fixed (bip122:..., solana:...).")
    listed: Optional[bool] = None
    sanctioned: Optional[bool] = Field(None, description="On a US sanctions list (added 1.1.0).")
    reports: Optional[int] = None
    list_count: Optional[int] = Field(None, description=_LISTS)


class PaymentHandleFacts(_Strict):
    provider: Optional[Literal["cashapp", "venmo", "zelle", "paypal", "other"]] = None
    handle: Optional[str] = Field(None, description="The handle, lowercase, without $ or @.")
    reports: Optional[int] = None
    status: Optional[HandleStatus] = Field(None, description=(
        "From the service's public profile page (E8, added 2026-10-29): active (a profile is shown), inactive (the "
        "service shows the profile as not active: PayPal only), not_found (no profile under this name: never "
        "made, or closed), refused (the service refused our read), unknown (no answer we can read). Existence and "
        "status only: never the name, photo or anything else on the page. Null for a provider we do not read."))
    status_checked_at: Optional[str] = Field(None, description="When the profile page was read, RFC 3339 UTC.")


class IbanBank(_Strict):
    """The bank a national bank-code list names for the IBAN's bank code. Never the account holder."""
    name: str = Field(..., description="The bank's name as its country's list gives it.")
    bic: Optional[str] = Field(None, description="Its BIC (ISO 9362) from the same list, or null when the list has none.")
    source: Optional[str] = Field(None, description=(
        "The publisher's attribution for that list, e.g. 'Source: National Bank of Belgium' (1.6.0). Show it where the "
        "bank's name is shown."))
    free_at: Optional[str] = Field(None, description=(
        "Where the publisher gives the list free of charge (1.6.0). Several publishers (NBB, Banka Slovenije, Banco de "
        "Espana, the ECB) require that a buyer is told the data is free at the source each time it is provided."))


class IbanFacts(_Strict):
    """An IBAN (task E11, added 2026-10-29): the account number's validity and its bank, never its holder."""
    country: Optional[str] = Field(None, description="ISO 3166-1 alpha-2 from the IBAN's first two letters.")
    sepa: Optional[bool] = Field(None, description=(
        "The SWIFT IBAN registry marks the country as a SEPA country; null for a country the registry does not list."))
    valid: Optional[bool] = Field(None, description=(
        "ISO 13616: a registry country, its length, its BBAN structure and the check digits (MOD 97-10)."))
    invalid_reason: Optional[Literal["country", "length", "structure", "checksum"]] = Field(None, description=(
        "The first check that failed; null when valid."))
    print_form: Optional[str] = Field(None, description="The print format: groups of four separated by a space.")
    bank_code: Optional[str] = Field(None, description=(
        "The bank identifier at its country's position in the BBAN (SWIFT IBAN registry); null unless valid."))
    branch_code: Optional[str] = Field(None, description=(
        "The branch identifier where the country has one; null unless valid."))
    bank: Optional[IbanBank] = Field(None, description=(
        "The bank its country's national list names for bank_code; null when we hold no list for the country (the "
        "entity's bank_codes dataset then has in_coverage false) or the code is not in it."))
    listed: Optional[bool] = Field(None, description="On Bewary's scam list: a community report named this account.")
    list_count: Optional[int] = Field(None, description=_LISTS)


class AppFacts(_Strict):
    """A mobile app on the Apple App Store or Google Play (task E12, added 2026-10-29)."""
    store: Optional[Literal["apple", "google_play"]] = None
    app_id: Optional[str] = Field(None, description=(
        "Apple: the numeric App Store id. Google Play: the package name (application id)."))
    status: Optional[Literal["live", "removed", "not_found", "unknown"]] = Field(None, description=(
        "live: the store lists it now. removed: we saw it live before and the store no longer lists it. not_found: "
        "the store has no such app and we never saw it live. unknown: the store could not be read (degraded)."))
    name: Optional[str] = Field(None, description="The store's title for the app (from the last live sighting).")
    developer: Optional[str] = Field(None, description=(
        "The developer or seller name the store publishes (from the last live sighting)."))
    rating_count: Optional[int] = Field(None, description="How many ratings the store shows (Apple: in that storefront).")
    storefront: Optional[str] = Field(None, description="Apple: the App Store country read, ISO 3166-1 alpha-2.")
    first_seen: Optional[str] = Field(None, description="The first time we saw it live, RFC 3339 UTC.")
    last_seen: Optional[str] = Field(None, description="The last time we saw it live, RFC 3339 UTC.")
    missing_since: Optional[str] = Field(None, description=(
        "The first check that found it gone after a live sighting, RFC 3339 UTC; null while live."))


Facts = Union[UrlFacts, DomainFacts, IpFacts, PhoneFacts, ShortcodeFacts, EmailFacts, WalletFacts,
              PaymentHandleFacts, IbanFacts, AppFacts]


class Entity(BaseModel):
    id: str = Field(..., description="e1, e2 and so on, unique in the response.")
    type: EntityType
    value: str
    normalized: str
    role: Role
    verdict: Verdict
    p_scam: float = Field(..., ge=0, le=1)
    risk_score: int = Field(..., ge=0, le=100)
    reason_codes: List[str]
    facts: Facts = Field(..., description="Typed facts for the entity's type. Every key present, null when unknown.")
    related: List[str] = Field(..., description="Ids of linked entities: a link's domain, a redirect target.")
    datasets: List["DatasetRef"] = Field(..., description="Every dataset checked for this entity, with its geography. in_coverage false means the dataset does not cover this entity (a US-only dataset and a non-US number), so its silence is not a clean result.")


class DatasetRef(BaseModel):
    dataset: DatasetId
    coverage: Coverage = Field(..., description="WW: worldwide. A country code (US, DE): the data describes only that country's numbers, codes or complaints.")
    in_coverage: bool


class Signal(BaseModel):
    code: str = Field(..., description="A stable reason code, https://bewary.app/docs/reason-codes")
    entity: str = Field(..., description="The entity id, or \"message\".")
    severity: Severity
    weight: float = Field(..., description="Signed contribution: positive toward scam, negative reassuring.")
    source_class: SourceClass
    p: Optional[float] = Field(None, description="Model probability when source_class is model.")
    dataset: DatasetId = Field(..., description="The dataset the signal came from, https://bewary.app/data")
    coverage: Coverage = Field(..., description="That dataset's geography: WW (worldwide) or a country code (US, DE).")
    evidence: Dict[str, Any]


class Brand(BaseModel):
    id: Optional[str] = None
    name: str


class Classification(BaseModel):
    artifact_type: str = Field(..., description="What the message is: delivery_notice, account_alert, otp_or_2fa_code and so on.")
    stance: Literal["inbound_solicitation", "user_own_record", "ambiguous"]
    ask: Literal["none", "pay", "click_link", "call_back", "give_credentials", "give_code",
                 "install_app", "reply", "other"]
    scam_type: Optional[str] = Field(None, description="Null until the calibrated classifier ships.")
    impersonated_brand: Optional[Brand] = None
    probabilities: Dict[str, Dict[str, float]] = Field(default_factory=dict)


class InputInfo(BaseModel):
    kind: Literal["lookup", "message", "screenshot"]
    channel: Channel
    language: Optional[str] = None
    region: Optional[str] = None


class ModelsUsed(BaseModel):
    model_config = ConfigDict(extra="allow")
    judge: Optional[str] = None
    vision: Optional[str] = None
    verify: Optional[str] = None
    deep_tools: Optional[str] = None
    calibration: Optional[str] = None


class Explanation(BaseModel):
    text: str
    language: str


class Envelope(BaseModel):
    """The answer of every lookup and check."""
    schema_version: str = Field(..., examples=["1.8.0"])
    request_id: str = Field(..., examples=["req_01M5JTGK1BGJZ05TT8ZM9TJK0J"])
    checked_at: str = Field(..., description="RFC 3339 UTC.")
    engine: str
    models: ModelsUsed
    input: InputInfo
    verdict: Verdict
    p_scam: float = Field(..., ge=0, le=1, description="Probability that the input is part of a scam.")
    risk_score: int = Field(..., ge=0, le=100)
    confidence: float = Field(..., ge=0, le=1)
    classification: Optional[Classification] = None
    reason_codes: List[str] = Field(..., description="The codes that decided the verdict, strongest first, at most 5.")
    entities: List[Entity]
    signals: List[Signal]
    degraded: bool
    degraded_reasons: List[DegradedReason]
    cached: bool
    explanation: Optional[Explanation] = None


class ItemError(BaseModel):
    code: Literal["invalid_input", "unsupported_type"]


class BatchError(BaseModel):
    error: ItemError


class BatchResponse(BaseModel):
    schema_version: str
    request_id: str
    checked_at: str
    credits: int = Field(..., description="Credits charged: one per valid item.")
    results: List[Union[Envelope, BatchError]] = Field(..., description="In the order of items.")


class AssessEvent(BaseModel):
    type: Literal["account.onboarding", "account.update"] = Field(
        ..., description="When the contact points were given (a new seller or profile) or changed.")
    id: Optional[str] = Field(None, max_length=128, description="Yours; echoed back.")


class AssessReason(BaseModel):
    code: str = Field(..., description="A stable reason code, https://bewary.app/docs/reason-codes")
    pointer: str = Field(..., description="RFC 6901 JSON pointer of the request field it came from, e.g. "
                                          "/account/website or /account/links/2.")
    severity: Severity
    source_class: SourceClass


class AssessField(BaseModel):
    pointer: str = Field(..., description="RFC 6901 JSON pointer of the request field.")
    type: Literal["url", "email", "phone", "wallet"]
    value: str
    verdict: Verdict
    p_scam: float = Field(..., ge=0, le=1)
    reason_codes: List[str]
    entities: List[Entity] = Field(..., description="The field's lookup answer, primary first (a website's URL, "
                                                    "then its domain and any redirect host).")
    signals: List[Signal]


class AssessFieldError(BaseModel):
    pointer: str
    error: ItemError


class AssessResponse(BaseModel):
    """POST /v1/assess (API-CONTRACT section 2.1, 1.3.0)."""
    schema_version: str
    request_id: str
    checked_at: str = Field(..., description="RFC 3339 UTC.")
    engine: str
    models: ModelsUsed
    event: AssessEvent
    decision: Literal["deny", "review", "pass"] = Field(..., description=(
        "deny when a field's verdict is scam, review when one is suspicious, otherwise pass. It follows the field "
        "verdicts only: pass means nothing Bewary holds names these contact points, not that the account is safe."))
    p_scam: float = Field(..., ge=0, le=1, description="The highest field's.")
    reasons: List[AssessReason] = Field(..., description=(
        "Every signal of severity medium or above on any field, strongest first. Cautions are listed and never "
        "change decision."))
    fields: List[Union[AssessField, AssessFieldError]] = Field(..., description=(
        "In request order: website, email, phone, links, wallet."))
    credits: int = Field(..., description="Lookups charged: one per valid field.")


# ── GET /v1/domain/{host}/related (API-CONTRACT section 2.6, D3) ──

RelationKind = Literal["ip", "ip_net", "asn", "name_servers", "certificate", "registration", "page_template",
                       "tracking_id"]
Strength = Literal["strong", "medium", "weak"]
Exclusion = Literal["cdn", "shared", "dns_provider", "parking", "platform", "not_routable", "legitimate_overlap",
                    "generic_page"]
_OBSERVED = "RFC 3339 UTC. For a certificate, when we logged it; for a registration, the registration time."


class RelatedSelf(_Strict):
    value: str = Field(..., description="The domain asked about, canonical.")
    registrable_domain: str = Field(..., description="Its owner boundary (Public Suffix List with its private section).")
    verdict: Verdict = Field(..., description="From what Bewary holds without a new lookup: lists, the name rule, "
                                              "stored protective-DNS refusals. GET /v1/domain/{host} reads more.")
    p_scam: float = Field(..., ge=0, le=1)
    reason_codes: List[str]
    listed: bool = Field(..., description="On Bewary's scam list.")
    known_site: bool = Field(..., description="A top-100K site or a business's official website.")


class Pivot(_Strict):
    kind: RelationKind
    value: str = Field(..., description=("ip: an IPv4 address; ip_net: its /24; asn: AS<number>; name_servers: the "
                                         "zone's name servers, sorted, comma-joined; certificate: the serial number in "
                                         "hex; registration: the registrar; page_template: the page's structure "
                                         "hash; tracking_id: <kind>:<id>, e.g. ga4:G-ABC123 or tgbot:123456789."))
    domains: Optional[int] = Field(..., description=(
        "Other registrable domains in Bewary's data that share it; null when it was excluded before they were "
        "counted (a CDN address, an ASN holding more than 300 of our addresses)."))
    domains_capped: bool = Field(..., description="More share it than the cut for its kind: domains is that cut + 1.")
    used: bool = Field(..., description="It relates the domain to at least one other domain below.")
    excluded: Optional[Exclusion] = Field(..., description=(
        "Why it was not pivoted on: cdn (a CDN network), shared (more domains share it than the measured cut, or more "
        "ordinary sites than listed domains are behind it), dns_provider (name servers of a DNS provider), parking "
        "(a parking service's name servers or network), platform (the zone of a hosting platform the domain is a "
        "tenant of), "
        "not_routable, legitimate_overlap (also held by a known site, a brand's own domain or, for a page template "
        "or tracking id, a parked host), generic_page (also seen on an error page or a bot check)."))
    first_observed: Optional[str] = Field(..., description=_OBSERVED)
    last_observed: Optional[str] = Field(..., description=_OBSERVED)
    current: Optional[bool] = Field(..., description="DNS kinds: the domain's latest observation still holds it.")


class Relation(_Strict):
    kind: RelationKind
    value: str = Field(..., description="The shared value, as in pivots.")
    strength: Strength = Field(..., description=(
        "strong: few domains share it and the two held it at the same time; medium; weak: a /24, an ASN, a "
        "registration minute, or a value many share. A link, never a merge."))
    domains: int = Field(..., description="Other registrable domains sharing this value (the asked domain excluded).")
    first_observed: Optional[str] = Field(..., description="When the related domain was first seen with it. " + _OBSERVED)
    last_observed: Optional[str] = Field(..., description="When it was last seen with it. " + _OBSERVED)
    current: Optional[bool] = Field(..., description="DNS kinds: the related domain still holds it.")
    concurrent: Optional[bool] = Field(..., description="DNS kinds: the two domains held it at the same time.")


class RelatedDomain(_Strict):
    domain: str = Field(..., description="The related registrable domain.")
    hosts: List[str] = Field(..., description="Up to 5 of its hosts that carry the relations.")
    verdict: Verdict = Field(..., description="Its own verdict, from what Bewary holds (as for the asked domain).")
    p_scam: float = Field(..., ge=0, le=1)
    reason_codes: List[str]
    listed: bool
    known_site: bool
    strength: Strength = Field(..., description="Its strongest relation.")
    relations: List[Relation] = Field(..., description="Strongest first.")


class Campaign(_Strict):
    id: str = Field(..., description="cmp_ and 16 hex: stable, the same kit is the same id in every answer.")
    kind: Literal["page_template", "tracking_id"]
    value: str = Field(..., description="The shared template hash, or the kit's Telegram bot id (tgbot:<id>).")
    domains: int = Field(..., description="Registrable domains whose pages showed it, the asked one included.")
    listed: int = Field(..., description="Of those, on Bewary's scam list: at least 2 and at least half.")
    first_observed: Optional[str] = Field(..., description="RFC 3339 UTC: the first page read that showed it.")
    last_observed: Optional[str] = Field(..., description="RFC 3339 UTC: the latest.")


class RelatedResponse(BaseModel):
    """GET /v1/domain/{host}/related (API-CONTRACT section 2.6)."""
    schema_version: str
    request_id: str
    checked_at: str = Field(..., description="RFC 3339 UTC.")
    engine: str
    domain: RelatedSelf
    dns_observed: Literal["stored", "live", "none"] = Field(..., description=(
        "stored: from Bewary's hourly re-check history; live: resolved for this call (not a domain Bewary tracks); "
        "none: it did not resolve in time."))
    pivots: List[Pivot] = Field(..., description="Every value of the domain's that was considered, used or not.")
    related: List[RelatedDomain] = Field(..., description="This page, strongest first.")
    page: int
    per_page: int
    total: int = Field(..., description="Related domains over all pages (at most 500).")
    has_more: bool
    truncated: bool = Field(..., description="More than 500 related domains: only the strongest 500 are paged.")
    campaigns: List[Campaign] = Field(..., description=(
        "Kits this domain is part of (D4): one page template or one kit's Telegram bot shown by 2+ listed domains, "
        "listed being at least half of those showing it, never on a known, brand-owned or parked host."))
    credits: int


class ErrorDetail(BaseModel):
    code: Literal["invalid_input", "unsupported_type", "unauthorized", "forbidden", "quota_exceeded", "rate_limited",
                  "not_found", "input_too_large", "internal", "degraded_unavailable", "gone"]   # gone: 1.7.0
    message: str
    request_id: Optional[str] = None


class ErrorResponse(BaseModel):
    error: ErrorDetail


class KeyCreated(BaseModel):
    id: str
    key: str = Field(..., description="Shown once. Bewary keeps only a hash.")
    note: str


class KeyInfo(BaseModel):
    id: str
    name: Optional[str] = None
    created_at: Optional[str] = None
    last_used_at: Optional[str] = None
    revoked: bool
    bulk: bool = Field(False, description="The key may download the bulk files and read the TAXII collection.")


class KeyList(BaseModel):
    keys: List[KeyInfo]


class CreditState(BaseModel):
    period: str = Field(..., description="The current UTC month, YYYY-MM.")
    monthly_free: int = Field(..., description="Free credits every key gets each UTC month; they reset.")
    monthly_used: int = Field(..., description="Free credits used this month.")
    prepaid: int = Field(..., description="Prepaid credits from packs. They never expire and are spent after the free ones.")
    remaining: int = Field(..., description="What can be spent now: free credits left this month plus prepaid.")
    billing_account: bool = Field(False, description="The account has bought a pack: its receipts and invoices are in the billing portal (POST /v1/billing/portal).")


class UsageRow(BaseModel):
    request_id: Optional[str] = None
    endpoint: str
    credits: int
    status: Union[int, str]
    at: Optional[str] = None


class AccountResponse(BaseModel):
    credits: CreditState
    recent: List[UsageRow]


# ── the number directory (DIRECTORY-CONTRACT.md): free, cached an hour ─────────────────────────

class AreaCodeSummary(BaseModel):
    npa: str
    numbers: int = Field(..., description="Numbers with a complaint in the last 13 months.")
    complaints: int
    indexable: int = Field(..., description="How many of the area code's numbers have a page (inside the current wave), callback numbers included.")
    qualifying: int = Field(..., description="How many of the area code's numbers meet the index rule, callback numbers included.")


class AreaCodeIndex(BaseModel):
    as_of: str = Field(..., description="RFC 3339 UTC.")
    area_codes: List[AreaCodeSummary]


class DirectoryNumber(BaseModel):
    e164: str
    complaints: int
    days: int
    last_at: str = Field(..., description="Last complaint day, 00:00Z.")
    top_subject: Optional[str] = None
    indexable: bool


class AreaCodeSources(BaseModel):
    ftc: int
    fcc: int


class AreaCodeTotals(BaseModel):
    numbers: int
    complaints: int
    subjects: Dict[str, int]
    sources: AreaCodeSources


class MonthCount(BaseModel):
    month: str = Field(..., description="YYYY-MM.")
    complaints: int


class Exchange(BaseModel):
    nxx: str = Field(..., description="The exchange: the three digits after the area code.")
    numbers: int
    complaints: int


class AreaCallback(BaseModel):
    e164: str
    reports: int = Field(..., description="Complaints that named it as the callback number.")
    callers: int = Field(..., description="Distinct caller IDs that pushed it.")
    indexable: bool


class AreaCodePage(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    npa: str
    as_of: str
    from_: str = Field(..., alias="from", description="First day the directory has data for, YYYY-MM-DD.")
    to: str = Field(..., description="Last day the directory has data for, YYYY-MM-DD.")
    totals: AreaCodeTotals
    monthly: List[MonthCount] = Field(..., description="Complaints per calendar month, the 13 months ending with the current one (partial), oldest first, months with none included.")
    hours: Dict[str, int] = Field(..., description='Complaints by the hour of the call, "0" to "23", every hour present.')
    exchanges: List[Exchange] = Field(..., description="The exchanges with the most complaints, at most 5.")
    callbacks: List[AreaCallback] = Field(..., description="The area code's numbers named as callback numbers in FCC complaints, most reports first, at most 10.")
    numbers: List[DirectoryNumber] = Field(..., description="Most reported first: numbers with a page, then by complaints, then the latest report. At most 200.")


class AreaNeighbors(BaseModel):
    npa: str
    numbers: List[DirectoryNumber] = Field(..., description="Other reported numbers in the area code, most reported first, at most 12.")


class NumberPage(BaseModel):
    e164: str
    as_of: str
    indexable: bool = Field(..., description="The index rule, inside the current wave: the page may be indexed.")
    lookup: Envelope = Field(..., description="What GET /v1/phone/{e164} answers an anonymous caller.")
    area: AreaNeighbors


class IndexableNumbers(BaseModel):
    total: int = Field(..., description="Indexable numbers: the index rule, inside the current wave (at most DIRECTORY_INDEX_CAP).")
    page: int
    per_page: int
    numbers: List[str]


# ── watches, events, webhooks (F4, API-CONTRACT section 2.2, EVENTS.md) ───────────────────────

WatchEntityType = Literal["domain", "phone", "wallet", "brand"]
EventType = Literal["domain.ip_changed", "domain.ns_changed", "domain.down", "domain.up", "domain.parked",
                    "domain.listed", "domain.delisted", "domain.reregistered", "domain.expiring", "domain.expired",
                    "domain.held", "phone.listed", "phone.delisted", "wallet.listed", "wallet.delisted",
                    "wallet.sanctioned", "brand.lookalike_registered", "brand.lookalike_cert",
                    "brand.lookalike_live"]
_TS = "RFC 3339 UTC."


class WatchEntity(_Strict):
    type: WatchEntityType = Field(..., description="What is watched: a domain or host, an E.164 phone number, a "
                                                   "wallet address, or a brand id of the lookalike registry.")
    value: str = Field(..., min_length=1, max_length=320, description="As in the lookup endpoints. Stored in "
                                                                      "canonical form (the answer shows it).")


class WatchIn(BaseModel):
    entity: WatchEntity
    events: Optional[List[EventType]] = Field(None, description="The event types to produce; omitted or empty: "
                                                                "every event type of the entity type.")


class Watch(BaseModel):
    id: str = Field(..., description="wch_ + a ULID.")
    entity: WatchEntity
    events: List[EventType] = Field(..., description="The event types this watch produces.")
    status: Literal["active", "paused"] = Field(..., description="paused: the balance could not pay a day; a "
                                                                 "top-up resumes it and produces the gap.")
    created_at: str = Field(..., description=_TS)
    paid_until: str = Field(..., description="Events are produced for changes up to this time. " + _TS)
    paused_at: Optional[str] = None
    last_event_at: Optional[str] = None
    credits_per_day: int = Field(..., description="What a day of this watch costs (PRICING section 3.1).")


class WatchCreated(Watch):
    created: bool = Field(..., description="false: a live watch on this entity existed; its event types were "
                                           "updated and nothing was charged.")
    credits: int = Field(..., description="Credits charged by this call: the first day of a new watch.")


class WatchDetail(Watch):
    events_30d: int = Field(..., description="Events this watch produced in the last 30 days.")
    undelivered_30d: int = Field(..., description="Of those, events a webhook endpoint could not take after "
                                                  "every retry (dead deliveries). They stay in GET /v1/events.")
    pending_deliveries: int = Field(..., description="Events waiting for a webhook attempt or a retry.")


class WatchList(BaseModel):
    watches: List[Watch]


class Deleted(BaseModel):
    id: str
    deleted: bool


class EventState(_Strict):
    """`before` and `after` of an event: only the fields the change is about are present."""
    status: Optional[Literal["live", "gone", "broken", "parked"]] = Field(None, description="DNS status.")
    a: Optional[List[str]] = Field(None, description="IPv4 addresses, sorted.")
    ns: Optional[List[str]] = Field(None, description="Name servers of the zone, sorted.")
    registered_at: Optional[str] = Field(None, description="The registry's registration date; null: the registry "
                                                           "has no registration. " + _TS)
    expires_at: Optional[str] = Field(None, description="The registry's expiry date. " + _TS)
    within_days: Optional[Literal[7, 30]] = Field(None, description="domain.expiring: the threshold crossed.")
    hold: Optional[Hold] = Field(None, description=(
        "Who holds the name out of the DNS (E4's daily registry pass): none, registry, registrar or both."))
    listed: Optional[bool] = Field(None, description="On a Bewary scam list, as the lookup's `listed` fact.")
    sanctioned: Optional[bool] = None
    domain: Optional[str] = Field(None, description="Brand events: the lookalike domain.")
    technique: Optional[Literal["brand_word", "typo", "brand_tld", "homoglyph", "mixed_script"]] = Field(
        None, description="Brand events: how the name imitates the brand: next to a scam word, one letter off, on an "
                          "abused TLD, spelled with look-alike characters (1.5.0: the last two and brand_tld).")
    origin: Optional[Literal["cert", "zone"]] = Field(None, description="Brand events: first seen in a "
                                                                        "certificate or in a zone file.")


class Event(_Strict):
    id: str = Field(..., description="evt_ + a ULID; the webhook-id header of its deliveries.")
    type: EventType
    at: str = Field(..., description="When Bewary observed the change. " + _TS)
    watch_id: str
    entity: WatchEntity
    before: Optional[EventState] = None
    after: Optional[EventState] = None
    datasets: List[DatasetId] = Field(..., description="The datasets the change was read from (section 6.1).")


class EventPage(BaseModel):
    schema_version: str
    events: List[Event] = Field(..., description="Oldest first.")
    next_cursor: str = Field(..., description="Pass as `cursor` for the events after these; returned even when "
                                              "no event came, so a poller keeps its place.")
    has_more: bool


class WebhookIn(BaseModel):
    url: str = Field(..., max_length=2000, description="https only, port 443 or 1024 and up; the host must "
                                                       "resolve to public addresses only.")
    event_types: Optional[List[EventType]] = Field(None, description="Deliver only these; omitted or empty: every "
                                                                     "event of the account's watches.")


class Webhook(BaseModel):
    id: str = Field(..., description="whk_ + a ULID.")
    url: str
    event_types: List[EventType] = Field(..., description="Empty: every event.")
    status: Literal["enabled", "disabled"]
    disabled_reason: Optional[Literal["failing", "app_disconnected"]] = Field(
        None, description="failing: every delivery failed for 5 days; app_disconnected: the app that made it over "
                          "OAuth was disconnected (1.8.0). POST .../enable turns it back on.")
    created_at: str
    last_success_at: Optional[str] = None
    last_failure_at: Optional[str] = None
    failing_since: Optional[str] = Field(None, description="The first failed attempt since the last success.")


class WebhookCreated(Webhook):
    secret: str = Field(..., description="whsec_...: signs every delivery (EVENTS.md). Shown once.")


class WebhookList(BaseModel):
    webhooks: List[Webhook]


class Delivery(BaseModel):
    event_id: str
    event_type: EventType
    status: Literal["pending", "delivering", "succeeded", "dead", "cancelled"] = Field(..., description=(
        "pending: waiting for its next attempt; dead: every retry failed; cancelled: the endpoint was deleted or "
        "disabled first."))
    attempts: int
    last_status: Optional[int] = Field(None, description="The HTTP status of the last attempt.")
    error: Optional[Literal["http_status", "timeout", "network", "blocked", "secret_unavailable",
                            "endpoint_removed", "internal"]] = Field(None, description=(
        "Why the last attempt failed. blocked: the URL no longer resolves to public addresses only."))
    next_attempt_at: Optional[str] = None
    delivered_at: Optional[str] = None
    created_at: str


class WebhookDetail(Webhook):
    deliveries: List[Delivery] = Field(..., description="The last 50, newest first.")


def validate_envelope(data: dict) -> Envelope:
    """Validate an envelope, with each entity's facts checked against ITS type's model (the plain
    union would accept any facts model whose keys happen to fit)."""
    env = Envelope.model_validate(data)
    by_type = {"url": UrlFacts, "domain": DomainFacts, "ip": IpFacts, "phone": PhoneFacts,
               "shortcode": ShortcodeFacts, "email": EmailFacts, "wallet": WalletFacts,
               "payment_handle": PaymentHandleFacts, "iban": IbanFacts, "app": AppFacts}
    for ent in data.get("entities", []):
        by_type[ent["type"]].model_validate(ent["facts"])
    return env


class DatasetInfo(BaseModel):
    id: DatasetId
    name: str
    coverage: Coverage = Field(..., description="WW: worldwide. A country code (US, DE): that country's data only.")
    kind: Literal["scam_list", "reference", "own_traffic", "model"]
    entity_types: List[str]
    active_entries: Optional[int] = Field(None, description="Active entries now, for scam lists; null otherwise.")
    refreshed_at: Optional[str] = Field(None, description="RFC 3339 UTC of the last refresh, where tracked.")
    target_countries: Optional[Dict[str, int]] = Field(None, description=(
        "Active entries aimed at each country (ISO 3166-1 alpha-2), from the national lists that name them; entries "
        "no national list names are not counted here. Null when none."))
    entity_countries: Optional[Dict[str, int]] = Field(None, description=(
        "Phone datasets: active entries by the number's own country. bank_codes: the bank codes held per country (an "
        "IBAN from another country is outside it). Null otherwise."))
    checked_entries: Optional[int] = Field(None, description=(
        "Domain datasets: active entries the hourly re-check has read so far (a full pass takes about a week). "
        "email_providers: throwaway-mail domains the re-check has read (a full pass takes about two days)."))
    live_entries: Optional[int] = Field(None, description=(
        "Of checked_entries, those that still resolve (live or parked); for email_providers, the throwaway domains "
        "that still receive mail. The rest are kept as history."))
    quarantined_entries: Optional[int] = Field(None, description=(
        "Scam lists: entries held back from every answer because the list had a bad day (far more new entries than "
        "usual, or many top sites and shorteners among them). Kept, not served, until the list's next clean day or "
        "a person lifts the hold. Null when none."))


class DatasetList(BaseModel):
    schema_version: str
    datasets: List[DatasetInfo]


class CalibrationBand(SiteBand):
    p_scam_site: float = Field(..., ge=0, le=1, description="The probability served for a score in this band.")


class SiteCalibration(BaseModel):
    """GET /v1/calibration/site (1.4.0): how the domain verdict's score maps to the probability `site.p_scam_site`
    serves, and what each band's labelled hosts were (docs/b2b/PRECISION.md)."""
    schema_version: str
    version: str = Field(..., description="The fit, e.g. site-cal-20261030-702; `site.calibration` names it.")
    fitted_at: str = Field(..., description="When it was fitted, RFC 3339 UTC.")
    population: str = Field(..., description="Which hosts it applies to.")
    labels: str = Field(..., description="How the labelled hosts were labelled.")
    points: int = Field(..., ge=0, description="Labelled hosts in the fit.")
    scam_sites: int = Field(..., ge=0, description="How many of them were scam or compromised sites.")
    confidence: float = Field(..., description="The confidence of every band's interval.")
    bands: List[CalibrationBand]
Entity.model_rebuild()


# ── bulk delivery (F7, docs/b2b/BULK.md) ────────────────────────────────────────────────────────────────────────────

BulkFormatId = Literal["csv", "rpz", "stix"]
BulkKind = Literal["domain", "url", "phone", "wallet"]


class BulkVersion(BaseModel):
    id: int = Field(..., description="Increases with every change; the RPZ serial. Pass it as `since` for a delta.")
    created_at: str = Field(..., description="RFC 3339 UTC.")
    entries: int = Field(..., description="Entries in this version.")
    added: int
    changed: int
    removed: int


class BulkFormat(BaseModel):
    format: BulkFormatId
    media_type: str
    types: List[BulkKind] = Field(..., description="Entity types the format carries.")
    full: str = Field(..., description="Path of the full file.")
    delta: Optional[str] = Field(None, description="Path of a delta after a version, or null (RPZ is served whole).")


class BulkPrice(BaseModel):
    credits_per_day: int = Field(..., description="One day pass per account per UTC day covers every format, full and "
                                                  "delta, and the TAXII collection.")
    pass_today: bool = Field(..., description="This account already holds today's pass.")


class BulkManifest(BaseModel):
    """GET /v1/bulk: what the bulk files hold now. Free with any key."""
    schema_version: str
    policy: str = Field(..., description="The licence policy version the entries passed (app/licences.py).")
    version: Optional[BulkVersion] = Field(None, description="The latest version, or null before the first build.")
    versions: List[BulkVersion] = Field(..., description="Recent versions, newest first; deltas reach back 30 days.")
    types: Dict[str, int] = Field(..., description="Current entries by entity type.")
    formats: List[BulkFormat]
    price: BulkPrice
    key_enabled: bool = Field(..., description="This key may download the files (PATCH /v1/keys/{id}).")
    notices: str = Field(..., description="Licence notices that travel with the files.")


# ── backtests (F6, docs/b2b/BULK.md section 7) ──────────────────────────────────────────────────────────────────────

BacktestType = Literal["domain", "url", "phone", "email", "wallet"]
BacktestOutcome = Literal["caught_before", "caught_after", "caught", "flagged", "missed", "before_history"]
BacktestAnswer = Literal["listed", "sanctioned", "caution", "caller_id", "not_listed"]


class BacktestItemIn(BaseModel):
    type: Optional[BacktestType] = Field(None, description="Inferred from the value when absent.")
    value: str = Field(..., max_length=4000)
    reported_at: Optional[str] = Field(None, description="When you learned it was bad: RFC 3339 or YYYY-MM-DD (UTC).")


class BacktestIn(BaseModel):
    """The JSON form of a backtest upload; text/csv and application/x-ndjson carry the same three fields."""
    items: List[BacktestItemIn] = Field(..., min_length=1, max_length=10_000)


class BacktestCreated(BaseModel):
    id: str = Field(..., description="bt_ + a ULID.")
    status: Literal["queued", "running", "done", "failed"]
    items: int
    valid: int
    invalid: int = Field(..., description="Items that are not a value of their type: reported, never charged.")
    free_items: int = Field(..., description="Items covered by this month's free allowance.")
    credits: int = Field(..., description="Credits charged: one per valid item past the free allowance.")
    report: str = Field(..., description="Path of the report.")


class BacktestResult(BaseModel):
    index: int = Field(..., description="The item's position in the upload.")
    type: BacktestType
    value: str = Field(..., description="As uploaded.")
    normalized: str = Field(..., description="Our canonical form (the email's domain for an email).")
    reported_at: Optional[str] = Field(None, description="Your date, RFC 3339 UTC.")
    outcome: BacktestOutcome = Field(..., description=(
        "caught_before: on your date we listed it. caught_after: we listed it only later. caught (no date): we "
        "listed it. flagged: only a caution or a caller-ID report. missed. before_history: your date is before our "
        "data for this type begins."))
    answer_then: Optional[BacktestAnswer] = Field(None, description="What a lookup on your date answered.")
    answer_now: BacktestAnswer = Field(..., description="What a lookup answers now.")
    first_seen: Optional[str] = Field(None, description="The earliest we saw it at all, a certificate included.")
    first_listed: Optional[str] = Field(None, description="When we first asserted it (listed or sanctioned).")
    lead_hours: Optional[float] = Field(None, description=(
        "Your date minus our listing, in hours: positive when we had it first, negative when we listed it later."))
    severity: Optional[Severity] = None
    reason_code: Optional[str] = Field(None, description="LISTED_BLOCKLIST, WALLET_SANCTIONED, LISTED_CAUTION or "
                                                         "PHONE_REPORTED_CALLER_ID.")


class BacktestItemError(BaseModel):
    index: int
    error: ItemError


class LeadStats(BaseModel):
    count: int
    median: float
    p25: float
    p75: float
    max: float


class BacktestLeads(BaseModel):
    caught_before: Optional[LeadStats] = None
    caught_after: Optional[LeadStats] = None


class BacktestTypeSummary(BaseModel):
    items: int
    outcomes: Dict[str, int]
    listed_now: int


class BacktestSummary(BaseModel):
    items: int
    valid: int
    invalid: int
    outcomes: Dict[str, int] = Field(..., description="Items by outcome.")
    listed_now: int = Field(..., description="Items a lookup asserts now (listed or sanctioned).")
    lead_hours: BacktestLeads
    by_type: Dict[str, BacktestTypeSummary]


class HistoryStart(BaseModel):
    from_: Optional[str] = Field(None, alias="from", description="Our earliest record for this type, RFC 3339 UTC.")
    model_config = ConfigDict(populate_by_name=True)


class BacktestReport(BaseModel):
    """GET /v1/backtest/{id}. Kept 7 days after it is ready; the upload itself is deleted when the report is
    written."""
    id: str
    status: Literal["queued", "running", "done", "failed"]
    created_at: str
    finished_at: Optional[str] = None
    expires_at: Optional[str] = None
    items: int
    valid: int
    free_items: int
    credits: int
    history: Optional[Dict[str, HistoryStart]] = Field(None, description="Where our data for each type begins.")
    summary: Optional[BacktestSummary] = None
    results: List[Union[BacktestResult, BacktestItemError]] = Field(..., description="In upload order; empty until "
                                                                                     "the report is done.")


# ── billing settings (F11: auto top-up and the low-balance email) ───────────────────────────────────────────────────

PackId = Literal["pack_20", "pack_100", "pack_500", "pack_2000"]


class AutoTopup(BaseModel):
    enabled: bool = Field(False, description="Off by default. Needs a saved card (POST /v1/billing/card).")
    pack: Optional[PackId] = Field(None, description="The pack bought each time.")
    threshold: Optional[int] = Field(None, ge=0, description="Buy when a call takes the balance under this many "
                                                             "credits. Below the pack's credits.")


class LowBalanceEmail(BaseModel):
    enabled: bool = Field(True, description="On by default; sent once each time the balance falls under the "
                                            "threshold after having been at or over it.")
    threshold: int = Field(2_000, ge=0)


class CardInfo(BaseModel):
    brand: Optional[str] = None
    last4: Optional[str] = None


class LastTopUp(BaseModel):
    status: Literal["pending", "processing", "succeeded", "failed"]
    pack: str
    error: Optional[str] = Field(None, description="Stripe's decline code when it failed, e.g. card_declined.")
    created_at: str
    finished_at: Optional[str] = None


class BillingSettings(BaseModel):
    auto_topup: AutoTopup
    low_balance_email: LowBalanceEmail
    card: Optional[CardInfo] = Field(None, description="The saved card for auto top-up, or null.")
    last_top_up: Optional[LastTopUp] = None


class BillingSettingsIn(BaseModel):
    auto_topup: Optional[AutoTopup] = None
    low_balance_email: Optional[LowBalanceEmail] = None
