#!/usr/bin/env python3
"""
cert_probe.py — Global Payments "Base Boarding" CERT sandbox probe.

Purpose
-------
Resolve the CERT-confirmation items (PRD-HWONB-001 §13.1, C-01…C-28) empirically
by exercising the live CERT API end-to-end and capturing the real responses:

    token mint  ->  GET lookups  ->  create application  ->  each section  ->
    fees (nulled rate-card)  ->  transmit  ->  status / activity

It is a *diagnostic* harness, not production code. Every call — success or
failure — is recorded, because the error bodies are themselves the answers
(e.g. which enum value a POST rejects).

Credentials
-----------
Never hard-coded. Read from the environment:

    export GP_CERT_KEY=...        # the "Key"    from HUBWALLET - CERT.txt
    export GP_CERT_SECRET=...     # the "Secret" from HUBWALLET - CERT.txt

Fallback (best-effort, local dev only): if the env vars are unset the script
parses `docs/onboarding/HUBWALLET - CERT.txt` relative to the repo root.

The Secret, its SHA-512 digest, and the Bearer token are NEVER written to the
findings file or stdout — they are redacted.

Output
------
A redacted JSON findings file (default: <tempdir>/cert_probe_findings.json,
override with GP_CERT_FINDINGS). One record per call:
{id, phase, c_items, method, url, request(redacted), status, response, note}.

Re-use
------
Set GP_CERT_APP_ID to reuse an existing CERT application instead of creating a
new one (idempotency / avoid piling up sandbox records).

Usage
-----
    GP_CERT_KEY=... GP_CERT_SECRET=... \
      /var/www/html/hubwallet/.venv/bin/python hubwallet-api/scripts/cert_probe.py
"""
from __future__ import annotations

import hashlib
import json
import os
import sys
import tempfile
import uuid
from pathlib import Path

import httpx

BASE = os.environ.get(
    "GP_CERT_BASE", "https://apigeex.basecrm.cert.globalpay.com/basecrm"
)
GP_VERSION = "2024-03-07"
TIMEOUT = 30.0

FINDINGS: list[dict] = []
FINDINGS_PATH = Path(
    os.environ.get(
        "GP_CERT_FINDINGS", os.path.join(tempfile.gettempdir(), "cert_probe_findings.json")
    )
)

# Candidate create-application tuples extracted from the real CERT Postman
# collection (Base Boarding API - Starter Collection). Tried in order until one
# returns 201 with an appId. leadSource PPTF/PPSTW require a prospectId.
CREATE_APP_CANDIDATES = [
    {"partner": "12_CDDDD", "association": "013454", "leadSource": "STW",
     "salesRep": 43, "merchantApplicationType": "NEW_MERCHANT"},
    {"partner": "TEST833", "association": "086386", "leadSource": "TF",
     "salesRep": 46, "merchantApplicationType": "NEW_MERCHANT"},
    {"partner": "PBA", "association": "010004", "leadSource": "PPSTW",
     "prospectId": "14566879", "salesRep": 10, "merchantApplicationType": "NEW_MERCHANT"},
    {"partner": "TEST_PARTNER", "association": "TEST_ASSOCIATION", "leadSource": "PPSTW",
     "prospectId": "JKJLK9898", "salesRep": 55, "merchantApplicationType": "NEW_MERCHANT"},
]


# --------------------------------------------------------------------------- #
# credentials
# --------------------------------------------------------------------------- #
def load_creds() -> tuple[str, str]:
    key = os.environ.get("GP_CERT_KEY")
    secret = os.environ.get("GP_CERT_SECRET")
    if key and secret:
        return key.strip(), secret.strip()
    # fallback: parse the plaintext CERT file relative to repo root
    repo = Path(__file__).resolve().parents[2]
    cert = repo / "docs" / "onboarding" / "HUBWALLET - CERT.txt"
    if cert.exists():
        k = s = None
        for line in cert.read_text().splitlines():
            if line.lower().startswith("key:"):
                k = line.split(":", 1)[1].strip()
            elif line.lower().startswith("secret:"):
                s = line.split(":", 1)[1].strip()
        if k and s:
            print("[creds] using fallback CERT.txt (set GP_CERT_KEY/SECRET to override)")
            return k, s
    sys.exit("ERROR: set GP_CERT_KEY and GP_CERT_SECRET (or provide CERT.txt).")


# --------------------------------------------------------------------------- #
# recording
# --------------------------------------------------------------------------- #
def _redact_headers(h: dict) -> dict:
    out = dict(h)
    if "Authorization" in out:
        out["Authorization"] = "Bearer <redacted>"
    return out


def _redact_body(body):
    if isinstance(body, dict):
        red = {}
        for k, v in body.items():
            if k in ("secret", "token", "app_id", "clientId"):
                red[k] = "<redacted>"
            else:
                red[k] = _redact_body(v)
        return red
    if isinstance(body, list):
        return [_redact_body(x) for x in body]
    return body


def record(cid, phase, c_items, method, url, req_body, resp, note=""):
    try:
        rj = resp.json()
    except Exception:
        rj = {"_non_json_bytes": len(resp.content), "_text_head": resp.text[:300]}
    rec = {
        "id": cid,
        "phase": phase,
        "c_items": c_items,
        "method": method,
        "url": url.replace(BASE, ""),
        "request": _redact_body(req_body) if req_body is not None else None,
        "status": resp.status_code,
        "response": _redact_body(rj),  # redacts token/secret/app_id/clientId in responses too
        "note": note,
    }
    FINDINGS.append(rec)
    err = ""
    if isinstance(rj, dict) and rj.get("errorDetails"):
        codes = [e.get("errorCode") or e.get("detailedErrorCode") for e in rj["errorDetails"]]
        err = f"  err={codes}"
    print(f"  [{resp.status_code}] {cid} {method} {url.replace(BASE, '')}{err}")
    return rj


# --------------------------------------------------------------------------- #
# http
# --------------------------------------------------------------------------- #
def auth_headers(token: str) -> dict:
    return {
        "Authorization": f"Bearer {token}",
        "X-Gp-Version": GP_VERSION,
        "Content-Type": "application/json",
    }


# --------------------------------------------------------------------------- #
# token (C-26, C-27)
# --------------------------------------------------------------------------- #
def mint_token(client: httpx.Client, key: str, secret: str) -> str | None:
    for order in ("nonce_secret", "secret_nonce"):
        nonce = uuid.uuid4().hex
        raw = (nonce + secret) if order == "nonce_secret" else (secret + nonce)
        digest = hashlib.sha512(raw.encode()).hexdigest()
        body = {
            "app_id": key,
            "secret": digest,
            "grant_type": "client_credentials",
            "nonce": nonce,
            "interval_to_expire": "1_HOUR",
        }
        r = client.post(f"{BASE}/accesstoken", json=body,
                        headers={"Content-Type": "application/json",
                                 "X-GP-Version": GP_VERSION})
        rj = record(f"token.{order}", "token", ["C-26"], "POST", f"{BASE}/accesstoken",
                    body, r, note=f"hash order = SHA-512({order.replace('_', '+')})")
        if r.status_code in (200, 201) and isinstance(rj, dict) and rj.get("token"):
            print(f"  --> token minted with order: {order}")
            return rj["token"]
    print("  --> TOKEN MINT FAILED with both hash orders; aborting.")
    return None


# --------------------------------------------------------------------------- #
# phase A: GET lookups (C-02, C-04, C-09, C-10, C-12, C-16, C-18)
# --------------------------------------------------------------------------- #
def probe_lookups(client, token, partner, association):
    h = auth_headers(token)
    q = f"?partner={partner}"
    qa = f"?partner={partner}&association={association}"
    master = ["ownershipType", "businessType", "sicCode", "contactType", "country",
              "association", "leadSource", "salesRep"]
    for t in master:
        # salesRep additionally requires association (observed 40251 otherwise)
        url = f"{BASE}/masterRecord/{t}{qa if t == 'salesRep' else q}"
        try:
            record(f"lookup.masterRecord.{t}", "A-lookups",
                   ["C-12"] + (["C-09"] if t == "country" else [])
                   + (["C-10"] if t == "contactType" else []),
                   "GET", url, None, client.get(url, headers=h))
        except Exception as e:  # noqa
            print(f"  [ERR] masterRecord/{t}: {e}")
    # zipcode: the bare /zipcode path returns 40252; probe the masterRecord variant
    for zurl in (f"{BASE}/masterRecord/zipcode?zip=78704", f"{BASE}/zipcode?zip=78704"):
        try:
            record(f"lookup.zipcode[{zurl.split('/basecrm')[1].split('?')[0]}]", "A-lookups",
                   [], "GET", zurl, None, client.get(zurl, headers=h))
        except Exception as e:  # noqa
            print(f"  [ERR] zipcode {zurl}: {e}")
    products = ["standalone/equipment", "standalone/equipment/list", "integrated",
                "trainingAndActivationDetail", "addOns", "geniusAddOns"]
    for t in products:
        url = f"{BASE}/product/{t}{qa}"
        try:
            record(f"lookup.product.{t}", "A-lookups",
                   ["C-16"] + (["C-18"] if "training" in t else []),
                   "GET", url, None, client.get(url, headers=h))
        except Exception as e:  # noqa
            print(f"  [ERR] product/{t}: {e}")
    fees = ["feeProcessingDetail", "feeDetail"]
    for t in fees:
        url = f"{BASE}/feeSchedule/{t}{qa}"
        try:
            record(f"lookup.feeSchedule.{t}", "A-lookups",
                   ["C-02"] if t == "feeProcessingDetail" else [],
                   "GET", url, None, client.get(url, headers=h))
        except Exception as e:  # noqa
            print(f"  [ERR] feeSchedule/{t}: {e}")
    # PlansAndAddOns (C-04): PascalCase returned 40252 — probe casing/path variants
    for path in ("feeSchedule/PlansAndAddOns", "feeSchedule/plansAndAddOns",
                 "feeSchedule/plans/addons", "feeSchedule/plansAndAddons"):
        url = f"{BASE}/{path}{qa}"
        try:
            record(f"lookup.{path}", "A-lookups", ["C-04"], "GET", url, None,
                   client.get(url, headers=h))
        except Exception as e:  # noqa
            print(f"  [ERR] {path}: {e}")
    # geniusAddOns (C-16): probe casing variants (geniusAddOns returned 40252)
    for path in ("product/geniusAddOns", "product/geniusAddons", "product/genius/addOns"):
        url = f"{BASE}/{path}{qa}"
        try:
            record(f"lookup.{path}", "A-lookups", ["C-16"], "GET", url, None,
                   client.get(url, headers=h))
        except Exception as e:  # noqa
            print(f"  [ERR] {path}: {e}")


# --------------------------------------------------------------------------- #
# phase A2: standaloneEquipment filtered lookup — does GP's filtered
# /product/standalone/equipment endpoint surface `application` values for a
# brand+model+industry the unfiltered /list catalog has none for (e.g.
# VERIFONE/Mx915), or does it just validate an `application` already supplied
# (400 40024 without one)? Answers HubWallet onboarding UI bug: the Products &
# Equipment step's POS Application dropdown derives its options purely from
# the unfiltered /list catalog's per-entry `industryApplication` map, with no
# fallback — if that map is empty/missing for the chosen brand+model, the
# dropdown is stuck disabled. This probe determines whether the filtered
# endpoint is a viable server-side workaround.
# --------------------------------------------------------------------------- #
def probe_equipment_catalog(client, token, partner, association):
    h = auth_headers(token)
    brand, model, industry = "VERIFONE", "Mx915", "RETL"
    base_params = f"partner={partner}&association={association}"

    # unfiltered — same shape the frontend actually fetches today
    url = f"{BASE}/product/standalone/equipment/list?{base_params}"
    record("equipcat.unfiltered", "A2-equipcat", ["C-16"], "GET", url, None,
           client.get(url, headers=h),
           note=f"unfiltered catalog — check whether a {brand}/{model} entry "
                f"exists and whether its industryApplication has a '{industry}' key")

    # filtered, brand+model+industry only (no application) — per the client
    # docstring, GP may require `application` too and return 40024 without it
    url = f"{BASE}/product/standalone/equipment?{base_params}&brand={brand}&model={model}&industry={industry}"
    record("equipcat.filtered_no_app", "A2-equipcat", ["C-16"], "GET", url, None,
           client.get(url, headers=h),
           note="filtered by brand+model+industry, application omitted — "
                "does this discover valid applications, or 40024?")


# --------------------------------------------------------------------------- #
# phase A3: full 5-param filtered equipment lookup, matrix over real /list
# devices — resolves the Products & Equipment redesign's Phase 0 requirement
# (docs/onboarding/PRODUCTS_EQUIPMENT_REDESIGN_PLAN.md): per-device
# `communicationWithPOS` (-> connectionMethod letter-code mapping), `emv[]`
# (the only valid pinpadEmvReader combos), `allowedFeature` keys (feature
# checkbox / pinpad-section gating), and the `sourceBillTo` "NA" vs "N/A"
# spelling. Unlike A2 above (one hardcoded VERIFONE/Mx915 guess), this walks
# real devices returned by /list — including INGENICO/DESK5000, the exact
# device from the live failure this redesign fixes.
# --------------------------------------------------------------------------- #
def probe_equipment_catalog_matrix(client, token, partner, association, max_devices=10):
    h = auth_headers(token)
    base_params = f"partner={partner}&association={association}"

    url = f"{BASE}/product/standalone/equipment/list?{base_params}"
    resp = client.get(url, headers=h)
    listing = record("equipcat.matrix.list", "A3-equipcat-matrix", ["C-16"],
                      "GET", url, None, resp,
                      note="unfiltered catalog — source for the 5-tuple matrix below")
    devices = (listing or {}).get("equipment") or []
    if not devices:
        print("  --> no devices in /list response; matrix probe skipped")
        return

    # Force INGENICO/DESK5000 (the live-failure device) to the front if present,
    # so it's never dropped by the max_devices cap.
    devices = sorted(
        devices,
        key=lambda d: 0 if (d.get("brand") == "INGENICO" and d.get("model") == "DESK5000") else 1,
    )

    for device in devices[:max_devices]:
        brand, model = device.get("brand"), device.get("model")
        industry_app = device.get("industryApplication") or {}
        source_bill = device.get("sourceBillTo") or {}
        if not industry_app or not source_bill:
            print(f"  --> skip {brand}/{model}: missing industryApplication or sourceBillTo")
            continue
        industry = next(iter(industry_app))
        applications = industry_app[industry] or []
        source = next(iter(source_bill))
        if not applications:
            print(f"  --> skip {brand}/{model}: no applications for industry {industry!r}")
            continue
        application = applications[0]

        url = (
            f"{BASE}/product/standalone/equipment?{base_params}"
            f"&brand={brand}&model={model}&industry={industry}"
            f"&application={application}&source={source}"
        )
        record(
            f"equipcat.matrix.{brand}.{model}", "A3-equipcat-matrix", ["C-16"],
            "GET", url, None, client.get(url, headers=h),
            note=(
                f"full 5-tuple ({brand}/{model}/{industry}/{application}/{source}) — "
                "check communicationWithPOS, emv[], allowedFeature, batchCloseMethod"
            ),
        )


# --------------------------------------------------------------------------- #
# phase B: create application
# --------------------------------------------------------------------------- #
def create_application(client, token) -> tuple[int | None, dict | None]:
    h = auth_headers(token)
    for cand in CREATE_APP_CANDIDATES:
        url = f"{BASE}/applications"
        try:
            r = client.post(url, json=cand, headers=h)
        except Exception as e:  # noqa
            print(f"  [ERR] create app {cand['partner']}: {e}")
            continue
        rj = record(f"createApp.{cand['partner']}", "B-createApp", ["C-28"], "POST",
                    url, cand, r, note="probing which partner/association tuple CERT accepts")
        if r.status_code in (200, 201) and isinstance(rj, dict) and rj.get("appId"):
            print(f"  --> appId={rj['appId']} via partner={cand['partner']}")
            return rj["appId"], cand
    print("  --> CREATE APP FAILED with all candidate tuples.")
    return None, None


# --------------------------------------------------------------------------- #
# section bodies (realistic seed data)
# --------------------------------------------------------------------------- #
def business_body():
    return {
        "businessDetails": {
            "dbaName": "Riverside Coffee Co", "legalName": "Riverside Coffee LLC",
            "isExistingBusiness": True, "ownershipType": "LLC", "businessType": "RETL",
            "sicCode": "5812", "businessDescription": "Coffee shop",
            "websiteAddress": "https://riverside.example.com", "yearsInBusiness": 5,
            "merchCustServiceNum": {"phoneNumber": "5125550142", "phoneCountryCode": "+1"},
            "chargebackNotificationContact": {"email": "cb@riverside.example.com"},
            "optOutFromElectronicStatement": False,
        },
        "additionalInfo": {f"{m}Flag": True for m in
                           ["jan", "feb", "mar", "apr", "may", "jun",
                            "jul", "aug", "sep", "oct", "nov", "dec"]},
        "applicationIrsDetails": {"taxId": "742019384", "taxExempt": False, "taxIdType": "EIN"},
        "tradeReference": {"accountNumber": "88213", "tradeName": "Austin Roasters",
                           "productSold": "Beans",
                           "contact": {"phoneNumber": "5125550199", "phoneCountryCode": "+1"}},
    }


def processing_body(gov_id="AI", biz_id="BFS", survey_as_object=True):
    proc = {
        "applicationProcessing": {
            "averageTicket": "18", "highestTicket": "150", "monthlyVolume": "42000",
            "businessToBusiness": 5, "cardTypesAccepted": "All", "cardPresent": 100,
            "eCommerce": 0, "manuallyKeyedMoto": 0, "numberOfLocation": 1,
            "shippingLeadTimeDays": 7, "billPriorToShip": False,
        },
        "applicationPatriotAct": {
            "governmentIdType": gov_id, "businessIdType": biz_id,
            "businessIdDateIssued": "2018-06-15", "businessIdPlaceOfIssuance": "TX",
            "businessIdDateExpired": "2030-06-15",
        },
    }
    survey = {"onSiteVisit": True, "inventoryMaintained": True, "inventorySufficient": True,
              "merchandiseMatch": True, "signageCompliant": True, "notes": "ok"}
    proc["applicationMerchantSurvey"] = survey if survey_as_object else "SURVEYED_BY_SALES_REP_INVENTORY_CONFIRMED"
    return proc


def addresses_body(country="USA"):
    dba = {"firstName": "Sarah", "lastName": "Coleman", "contactTitle": "OWN",
           "email": "sarah@riverside.example.com", "addressLine1": "412 Riverside Dr",
           "city": "Austin", "state": "TX", "country": country, "zipCode": "78704",
           "phoneNumber": "5125550142", "phoneCountryCode": "+1"}
    return {"dba": dba, "legal": {"isSameAsDba": True},
            "mailing": {"isSameAsDba": True}, "chargeback": {"isSameAsDba": True}}


def account_body(default=True):
    return {"routingNumber": "011401533", "accountNumber": "4400218873",
            "accountType": "C", "usageTypes": ["D", "M"], "defaultAccount": default}


def owner_body():
    return {"firstName": "Sarah", "lastName": "Coleman", "contactTitle": "O",
            "phoneNumber": "5125550142", "phoneCountryCode": "+1",
            "email": "sarah@riverside.example.com", "ssn": "543-21-8890",
            "streetNumber": "412", "streetName": "Riverside", "streetType": "DR",
            "city": "Austin", "state": "TX", "zipCode": "78704", "country": "USA",
            "dob": "1985-04-12", "ownerPercent": 100, "nonUsCitizen": False,
            "appSigner": True, "personalGuarantor": True,
            "beneficialOwner": True, "individualWithControl": True}


def card_types_body(ebt_both=False):
    b = {"amexRequested": True, "debitRequested": True, "ebtRequested": ebt_both,
         "debitCashBackRequested": False, "amexMarketingMaterials": True,
         "amexAnnualVolumeLimit": False}
    if ebt_both:
        b["ebtService"] = "B"  # C-15: does 'B' require ebtFnsFcsNumber? (omit it here)
    return b


# --------------------------------------------------------------------------- #
# phase C + D: sections, fees, transmit
# --------------------------------------------------------------------------- #
def probe_sections(client, token, app_id):
    h = auth_headers(token)
    base = f"{BASE}/applications/{app_id}"

    def post(cid, c_items, path, body, note=""):
        url = f"{base}{path}"
        try:
            return record(cid, "C-sections", c_items, "POST", url, body,
                          client.post(url, json=body, headers=h), note=note)
        except Exception as e:  # noqa
            print(f"  [ERR] {cid}: {e}")
            return None

    # business (must precede attachments — C-? ordering)
    post("business", [], "/business", business_body())

    # processingInformation — enum probes (C-05) + survey shape (C-07)
    post("processing.pdf_enums", ["C-05"], "/processingInformation",
         processing_body(gov_id="AI", biz_id="BFS"),
         note="PDF/prototype enum candidate AI/BFS")
    post("processing.alt_enums", ["C-05"], "/processingInformation",
         processing_body(gov_id="TPV", biz_id="TR"),
         note="alt enum candidate TPV/TR")
    post("processing.survey_string", ["C-07"], "/processingInformation",
         processing_body(survey_as_object=False),
         note="applicationMerchantSurvey as bare string")

    # addresses — envelope + country (C-11, C-09)
    post("addresses.usa", ["C-11", "C-09"], "/addresses", addresses_body("USA"),
         note="country=USA")
    post("addresses.us", ["C-09"], "/addresses", addresses_body("US"),
         note="country=US")

    # accounts — create + 4th to test cap (C-23)
    for i in range(1, 5):
        post(f"account.{i}", ["C-23"] if i == 4 else [], "/accounts",
             {**account_body(default=(i == 1)),
              "accountNumber": f"440021887{i}"},
             note=f"account #{i} (probe max-count on #4)")

    # owners
    post("owner.1", [], "/owners", owner_body())

    # cardTypes — EBT 'B' without FNS number (C-15)
    post("cardTypes.ebtB", ["C-15"], "/cardTypes", card_types_body(ebt_both=True),
         note="ebtService=B, ebtFnsFcsNumber omitted")

    # fees — nulled rate-card (C-01) + processingType casing (C-03)
    fees_null = {
        "processingType": "RETAIL", "pricingPlanId": None, "optionId": None,
        "amexPricingInd": True,
        "baseRates": None, "perItemFees": None, "authorizationFees": None,
        "pinDebitFees": None, "miscellaneousFees": [], "additionalFees": {},
    }
    post("fees.null_ratecard", ["C-01", "C-03"], "/fees", fees_null,
         note="THE rate-card blocker: all rate objects null, processingType=RETAIL")

    # products — standalone from the real catalog (INGENICO/DESK3500, source PN)
    prod = {
        "productType": "standalone", "wirelessActivationRequired": False,
        "connectionMethod": "I", "unsupportedPos": "N",
        "configurations": {"batchCloseMethod": "A", "customerReceipt": "M",
                           "printSize": "S", "multiTerminalStatus": "N", "parentPosNumber": 1},
        "features": {"avsSupport": True, "promptForInvoiceNumber": False,
                     "promptForCorpCard": False, "promptForECommerce": False,
                     "promptForVerificationCode": True, "tipAtTimeOfSale": False,
                     "tipCalculator": False, "emvContact": True, "nfcContactless": True},
        "brand": "INGENICO", "model": "DESK3500", "source": "PN", "billTo": "MER",
        "price": "0.00", "industry": "RETL", "application": "TETRASO",
    }
    post("products.standalone", [], "/products/1", prod, note="real catalog brand/model")
    post("products.source_STR", ["C-17"], "/products/2",
         {**prod, "source": "STR", "model": "DESK5000"}, note="probe source=STR validity")

    # training & activation (C-19): equipShippedTo=NA while CP equipment exists
    post("training.NA", ["C-19"], "/trainingActivation",
         {"merTrainedBy": "Agent", "welcomeKitEmailTo": "DBA", "equipShippedTo": "NA"},
         note="equipShippedTo=NA with a standalone/CP product present")
    post("training.DBA", [], "/trainingActivation",
         {"merTrainedBy": "Agent", "welcomeKitEmailTo": "DBA", "equipShippedTo": "DBA"},
         note="equipShippedTo=DBA (valid path)")


def probe_documents(client, token, app_id):
    """C-20 (docType casing), C-21 (multipart field key), C-22 (DELETE envelope)."""
    base = f"{BASE}/applications/{app_id}"
    hdr = {"Authorization": f"Bearer {token}", "X-Gp-Version": GP_VERSION}
    pdf = b"%PDF-1.4\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF"

    def upload(cid, c_items, field_key, doctype):
        url = f"{base}/attachments/upload?docType={doctype}"
        files = {field_key: (f"probe_{doctype}.pdf", pdf, "application/pdf")}
        try:
            r = client.post(url, files=files, headers=hdr)
            return record(cid, "C-documents", c_items, "POST", url,
                          {"_multipart_field": field_key, "docType": doctype}, r,
                          note=f"multipart key='{field_key}', docType='{doctype}'")
        except Exception as e:  # noqa
            print(f"  [ERR] {cid}: {e}")
            return None

    upload("doc.file.TAX", ["C-21", "C-20"], "file", "TAX")       # key=file, TAX
    upload("doc.documents.TAX", ["C-21"], "documents", "TAX")     # key=documents
    upload("doc.file.Tax", ["C-20"], "file", "Tax")              # docType Tax casing
    upload("doc.file.SMA", [], "file", "SMA")
    # list, then delete (C-22)
    h = auth_headers(token)
    lst = None
    try:
        lst = record("doc.list", "C-documents", [], "GET", f"{base}/attachments", None,
                     client.get(f"{base}/attachments", headers=h))
    except Exception as e:  # noqa
        print(f"  [ERR] doc.list: {e}")
    doc_id = None
    if isinstance(lst, dict):
        atts = lst.get("attachments") or []
        if atts:
            doc_id = atts[0].get("attachmentId") or atts[0].get("docId") or atts[0].get("id")
    if doc_id is not None:
        url = f"{base}/attachments/{doc_id}"
        try:
            record("doc.delete", "C-documents", ["C-22"], "DELETE", url, None,
                   client.delete(url, headers=h), note="real DELETE envelope")
        except Exception as e:  # noqa
            print(f"  [ERR] doc.delete: {e}")


def probe_transmit(client, token, app_id):
    h = auth_headers(token)
    base = f"{BASE}/applications/{app_id}"
    url = f"{base}/transmit"
    try:
        rj = record("transmit", "D-transmit", ["C-01", "C-24", "C-25"], "POST", url, {},
                    client.post(url, json={}, headers=h),
                    note="required-sections gate / MID / sync-vs-async")
    except Exception as e:  # noqa
        print(f"  [ERR] transmit: {e}")
        rj = None
    mid = rj.get("merchantId") if isinstance(rj, dict) else None
    # status
    try:
        record("status", "D-transmit", ["C-24"], "GET", f"{base}/status", None,
               client.get(f"{base}/status", headers=h))
    except Exception as e:  # noqa
        print(f"  [ERR] status: {e}")
    # activity (needs a MID)
    if mid:
        aurl = f"{BASE}/applications/activity?merchantId={mid}"
        try:
            record("activity", "D-transmit", ["C-24"], "GET", aurl, None,
                   client.get(aurl, headers=h))
        except Exception as e:  # noqa
            print(f"  [ERR] activity: {e}")


def standalone_product_body(source="PN"):
    """Full standalone product body matching the real Postman example (create =
    POST /products, no id). Catalog-valid for TEST833: INGENICO/DESK3500, RETL/TETRASO."""
    locked = source in ("MO", "PO")
    return {
        "productType": "standalone", "wirelessActivationRequired": False,
        "connectionMethod": "I", "version": "1",
        "configurations": {"batchCloseMethod": "M", "customerReceipt": "A",
                           "printSize": "M", "multiTerminalStatus": "N"},
        "features": {"avsSupport": False, "promptForInvoiceNumber": False,
                     "promptForCorpCard": False, "promptForECommerce": False,
                     "promptForVerificationCode": False, "tipAtTimeOfSale": False,
                     "tipCalculator": False, "emvContact": True, "nfcContactless": True,
                     "amex": False, "ebt": False, "pinDebit": False, "debitEbtCashback": False},
        "brand": "INGENICO", "model": "DESK3500", "source": source,
        "billTo": "N/A" if locked else "MER", "price": 0 if locked else 10,
        "priceIncludesMarkUp": False, "industry": "RETL", "application": "TETRASO",
        # EmvReader has its own accepted catalog: brand "Ingenico", model "Desk1500"/"Integrated"
        "pinpadEmvReader": {"brand": "Ingenico", "model": "Desk1500", "source": source,
                            "billTo": "N/A" if locked else "MER"},
    }


def real_fees_body(extra_additional=None):
    """Populated fees for plan 30 / option 41 (TransFreedom) using the confirmed
    feeProcessingDetail thresholds. Iterate additionalFees via extra_additional."""
    add = {"earlyTermination": False, "includeDailyDiscount": False, "sameDayACHFlag": False,
           # ensureBill is a NESTED object (GP flattens it as ensureBill.setUpFee in errors)
           "ensureBill": {"setUpFee": 0, "monthlyFee": 0, "deliveryFee": 0}}
    if extra_additional:
        add.update(extra_additional)
    # GP requires these rates > 0.00 (40213). Percentages within 0–4, per-item within 0–0.99.
    # amexBaseRate/amexPerItem NOT allowed when amexPricingInd=false (40008) → omit them.
    return {
        "processingType": "Retail", "pricingPlanId": 30, "optionId": 41,
        "amexPricingInd": False,
        "baseRates": {"actBaseRateOrVsMcBaseRate": 2.5},
        "perItemFees": {"actPerItemOrVsMcPerItem": 0.10},
        "authorizationFees": {"allCardTypes": 0.10, "batchClose": 0.10, "voice": 0.10,
                              "aru": 0.10, "amex": 0.10},
        "pinDebitFees": {"pinDebitPerItemFee": 0.10, "pinDebitRatePercent": 0.5,
                         "pinDebitEBTPerItemFee": 0.10, "pinDebitMonthlyFee": 0.10,
                         "pinDebitApplicationFee": 0.10},
        # miscellaneousFees is mandatory & non-empty (both [] and null → "cannot be empty").
        # One real entry: feeCode 8001 "MONTHLY SERVICE FEE" (from feeDetail.miscFee),
        # Monthly/seasonal → all 12 month flags true.
        "miscellaneousFees": [{
            "feeStartDate": "2026-08-01", "chargeAmount": 5, "feeCode": "8001",
            **{f"fee{m}Flag": True for m in
               ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
                "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]},
        }],
        "additionalFees": add,
    }


def probe_round2(client, token, app_id):
    """Round 2: clean full board to a real transmit + attachments. Resolves
    C-17 (source STR), C-19, C-20/C-21/C-22 (documents), C-01 positive path, C-24, C-25."""
    h = auth_headers(token)
    base = f"{BASE}/applications/{app_id}"

    def post(cid, c_items, path, body, note=""):
        url = f"{base}{path}"
        try:
            return record(cid, "R2", c_items, "POST", url, body,
                          client.post(url, json=body, headers=h), note=note)
        except Exception as e:  # noqa
            print(f"  [ERR] {cid}: {e}")
            return None

    # ordered clean board (business first — sequencing gate N-2)
    post("r2.business", [], "/business", business_body())
    post("r2.processing", [], "/processingInformation", processing_body())
    post("r2.addresses", [], "/addresses", addresses_body("USA"))
    post("r2.account", [], "/accounts", account_body())
    post("r2.owner", [], "/owners", owner_body())
    # clean cardTypes (no EBT, no debit → keeps pinDebitFees out of scope)
    post("r2.cardTypes", [], "/cardTypes",
         {"amexRequested": False, "debitRequested": False, "ebtRequested": False,
          "debitCashBackRequested": False, "amexMarketingMaterials": False,
          "amexAnnualVolumeLimit": False})

    # products — CORRECT path (POST /products, no id) + full body
    pr = post("r2.products", [], "/products", standalone_product_body("PN"),
              note="correct create path + full features")
    term = pr.get("terminalNumber") if isinstance(pr, dict) else None
    print(f"  --> terminalNumber={term}")
    # C-17: source=STR
    post("r2.products.STR", ["C-17"], "/products", standalone_product_body("STR"),
         note="probe source=STR validity")

    # training — C-19: NA should now be rejected (equipment present)
    post("r2.training.NA", ["C-19"], "/trainingActivation",
         {"merTrainedBy": "Agent", "welcomeKitEmailTo": "DBA", "equipShippedTo": "NA"},
         note="equipShippedTo=NA WITH a standalone product present → expect reject")
    post("r2.training.DBA", [], "/trainingActivation",
         {"merTrainedBy": "Agent", "welcomeKitEmailTo": "DBA", "equipShippedTo": "DBA"})

    # real fees (C-01 positive path)
    post("r2.fees", ["C-01"], "/fees", real_fees_body(),
         note="populated rate-card from feeProcessingDetail defaults")


def probe_documents_r2(client, token, app_id):
    """C-20/C-21 (valid combo) + C-22 (DELETE envelope)."""
    base = f"{BASE}/applications/{app_id}"
    hdr = {"Authorization": f"Bearer {token}", "X-Gp-Version": GP_VERSION}
    pdf = b"%PDF-1.4\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF"

    def upload(cid, doctype):
        url = f"{base}/attachments/upload?docType={doctype}"
        files = {"documents": (f"probe_{doctype}.pdf", pdf, "application/pdf")}
        try:
            return record(cid, "R2-docs", ["C-21", "C-20"], "POST", url,
                          {"_multipart_field": "documents", "docType": doctype},
                          client.post(url, files=files, headers=hdr),
                          note=f"key=documents, docType={doctype}")
        except Exception as e:  # noqa
            print(f"  [ERR] {cid}: {e}")
            return None

    upload("r2.doc.SMA", "SMA")
    upload("r2.doc.Tax", "Tax")
    h = auth_headers(token)
    lst = None
    try:
        lst = record("r2.doc.list", "R2-docs", [], "GET", f"{base}/attachments", None,
                     client.get(f"{base}/attachments", headers=h))
    except Exception as e:  # noqa
        print(f"  [ERR] doc.list: {e}")
    doc_id = None
    if isinstance(lst, dict):
        atts = lst.get("attachments") or []
        if atts:
            doc_id = atts[0].get("attachmentId") or atts[0].get("docId") or atts[0].get("id")
    if doc_id is not None:
        url = f"{base}/attachments/{doc_id}"
        try:
            record("r2.doc.delete", "R2-docs", ["C-22"], "DELETE", url, None,
                   client.delete(url, headers=h), note="real DELETE attachment envelope")
        except Exception as e:  # noqa
            print(f"  [ERR] doc.delete: {e}")


# --------------------------------------------------------------------------- #
# main
# --------------------------------------------------------------------------- #
def main():
    key, secret = load_creds()
    print(f"[cert_probe] base={BASE}")
    print(f"[cert_probe] findings -> {FINDINGS_PATH}")
    # This GP stack (Google/Envoy front) speaks HTTP/2 + gzip; httpx over HTTP/1.1
    # fails with "incomplete chunked read", so force HTTP/2 (requires the `h2` pkg).
    with httpx.Client(timeout=TIMEOUT, http2=True) as client:
        print("\n== token ==")
        token = mint_token(client, key, secret)
        if not token:
            dump()
            sys.exit("token mint failed")

        reuse = os.environ.get("GP_CERT_APP_ID")
        if reuse:
            app_id, cand = int(reuse), CREATE_APP_CANDIDATES[0]
            print(f"\n== reusing appId={app_id} ==")
        else:
            print("\n== create application ==")
            app_id, cand = create_application(client, token)

        print("\n== lookups ==")
        partner = (cand or CREATE_APP_CANDIDATES[0])["partner"]
        association = (cand or CREATE_APP_CANDIDATES[0])["association"]
        probe_lookups(client, token, partner, association)

        print("\n== equipment catalog (POS Application dropdown investigation) ==")
        probe_equipment_catalog(client, token, partner, association)

        print("\n== equipment catalog matrix (Products & Equipment redesign, Phase 0) ==")
        probe_equipment_catalog_matrix(client, token, partner, association)

        round2 = os.environ.get("GP_CERT_ROUND2")
        if app_id and round2:
            print("\n== ROUND 2: full board -> products -> real fees -> transmit ==")
            probe_round2(client, token, app_id)
            print("\n== documents (round 2) ==")
            probe_documents_r2(client, token, app_id)
            print("\n== transmit ==")
            probe_transmit(client, token, app_id)
        elif app_id:
            print("\n== sections ==")
            probe_sections(client, token, app_id)
            print("\n== documents ==")
            probe_documents(client, token, app_id)
            print("\n== transmit ==")
            probe_transmit(client, token, app_id)
        else:
            print("no appId — skipping section/transmit probes")
    dump()


def dump():
    FINDINGS_PATH.write_text(json.dumps(FINDINGS, indent=2, default=str))
    print(f"\n[cert_probe] wrote {len(FINDINGS)} records -> {FINDINGS_PATH}")
    # compact per-C-item outcome summary
    print("\n== summary (status per call) ==")
    for r in FINDINGS:
        ci = ",".join(r["c_items"]) if r["c_items"] else "-"
        print(f"  {r['status']:>3}  {r['id']:<28} [{ci}]")


if __name__ == "__main__":
    main()
