#!/usr/bin/env python3
"""
Seed freight contracts across every flow variant, driven end-to-end over HTTP.

For each flow the script logs in, creates a DRAFT contract, and pushes it through
the lifecycle up to and INCLUDING both signatures (customer sign + staff
counter-sign). It STOPS after the staff counter-sign — no clearance, no booking.

Flow dimensions (3 x 2 x 2 x 2 = 24 combos, but only the 20 real ones are built):
  movement : intercity(DOMESTIC) | import(IMPORT) | export(EXPORT)
  kind     : one-time(ONE_TIME)  | general(GENERAL)
  customs  : without | with        (customsClearingEnabled)
  freight  : bulk(BULK) | container(CONTAINER)

intercity + customs is dropped (DOMESTIC ignores customs → no real combo), which
removes 4 dead combos and leaves 20 flows (16 working + 4 customs+bulk whose
break is downstream in clearance). Each is created twice → 40 contracts on `all`.

CLI (filter by movement, pass one or many):
    python create_contracts.py                 # all 20 flows
    python create_contracts.py all             # all 20 flows
    python create_contracts.py intercity       # only DOMESTIC flows
    python create_contracts.py import          # only IMPORT flows
    python create_contracts.py import export   # IMPORT + EXPORT flows

Config comes from .env (see .env.example). Requires: requests, psycopg,
python-dotenv (see requirements.txt).
"""
from __future__ import annotations

import base64
import os
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

import psycopg
import requests
from dotenv import load_dotenv

HERE = Path(__file__).resolve().parent
load_dotenv(HERE / ".env")

# --------------------------------------------------------------------------- #
# Config
# --------------------------------------------------------------------------- #
API_URL = os.getenv("FREIGHT_API_URL", "http://localhost:3001/api").rstrip("/")

CUSTOMER_EMAIL = os.getenv("CUSTOMER_EMAIL", "")
CUSTOMER_PASSWORD = os.getenv("CUSTOMER_PASSWORD", "")
ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "")
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "")

# Phone the sign-OTP is sent to and read back from Postgres. Resolved at runtime
# from the customer's own IAM profile (GET /api/me → phoneNumber). OTP_PHONE is an
# optional override / fallback used only when the customer has no phone on file.
# The sign endpoint keys the OTP purely on this number, so it just has to be the
# same value for "send" and "read".
OTP_PHONE = os.getenv("OTP_PHONE", "")
OTP_PHONE_FALLBACK = os.getenv("OTP_PHONE_FALLBACK", "251900000000")

# DB connection used ONLY to read the plaintext sign-OTP from freight.otp_verifications.
DB_HOST = os.getenv("DB_HOST", "localhost")
DB_PORT = os.getenv("DB_PORT", "5432")
DB_NAME = os.getenv("DB_NAME", "edr_dev")
DB_USER = os.getenv("DB_USER", "postgres")
DB_PASSWORD = os.getenv("DB_PASSWORD", "")
DB_SCHEMA = os.getenv("DB_SCHEMA", "freight")

WAAFI = HERE / "waafi.jpeg"

VALIDITY_DAYS = int(os.getenv("VALIDITY_DAYS", "365"))
CONTRACTS_PER_FLOW = int(os.getenv("CONTRACTS_PER_FLOW", "2"))
REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", "60"))


# --------------------------------------------------------------------------- #
# Flow matrix — the 20 real flows
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class Flow:
    movement: str          # intercity | import | export
    trade_direction: str   # DOMESTIC | IMPORT | EXPORT
    kind: str              # ONE_TIME | GENERAL
    customs: bool          # customsClearingEnabled
    freight: str           # BULK | CONTAINER

    @property
    def label(self) -> str:
        return (
            f"{self.movement}+{'general' if self.kind == 'GENERAL' else 'one-time'}"
            f"+{'with' if self.customs else 'no'}-customs"
            f"+{self.freight.lower()}"
        )


def build_flow_matrix() -> list[Flow]:
    movements = [
        ("intercity", "DOMESTIC"),
        ("import", "IMPORT"),
        ("export", "EXPORT"),
    ]
    kinds = ["ONE_TIME", "GENERAL"]
    freights = ["BULK", "CONTAINER"]

    flows: list[Flow] = []
    for movement, direction in movements:
        # DOMESTIC ignores customs (no clearance gate) → customs=True is not a
        # real combo. Only build without-customs for intercity.
        customs_options = [False] if direction == "DOMESTIC" else [False, True]
        for kind in kinds:
            for customs in customs_options:
                for freight in freights:
                    flows.append(Flow(movement, direction, kind, customs, freight))
    return flows


# --------------------------------------------------------------------------- #
# HTTP client
# --------------------------------------------------------------------------- #
class ApiError(RuntimeError):
    def __init__(self, method: str, path: str, resp: requests.Response):
        body = resp.text
        try:
            body = resp.json()
        except Exception:
            pass
        super().__init__(f"{method} {path} -> {resp.status_code}: {body}")
        self.status_code = resp.status_code


class Client:
    """Thin wrapper that carries a bearer token."""

    def __init__(self, name: str, token: str | None = None):
        self.name = name
        self.token = token

    def _headers(self, extra: dict[str, str] | None = None) -> dict[str, str]:
        h: dict[str, str] = {}
        if self.token:
            h["Authorization"] = f"Bearer {self.token}"
        if extra:
            h.update(extra)
        return h

    def get(self, path: str, params: dict | None = None) -> Any:
        r = requests.get(
            f"{API_URL}{path}",
            headers=self._headers(),
            params=params,
            timeout=REQUEST_TIMEOUT,
        )
        if not r.ok:
            raise ApiError("GET", path, r)
        return r.json() if r.content else None

    def post_json(self, path: str, body: dict | None = None) -> Any:
        r = requests.post(
            f"{API_URL}{path}",
            headers=self._headers({"Content-Type": "application/json"}),
            json=body or {},
            timeout=REQUEST_TIMEOUT,
        )
        if not r.ok:
            raise ApiError("POST", path, r)
        return r.json() if r.content else None

    def post_multipart(
        self, path: str, data: dict[str, str], files: list[tuple] | None = None
    ) -> Any:
        r = requests.post(
            f"{API_URL}{path}",
            headers=self._headers(),  # requests sets multipart Content-Type
            data=data,
            files=files or [],
            timeout=REQUEST_TIMEOUT,
        )
        if not r.ok:
            raise ApiError("POST", path, r)
        return r.json() if r.content else None


def login(email: str, password: str, who: str) -> Client:
    r = requests.post(
        f"{API_URL}/auth/login",
        json={"email": email, "password": password},
        timeout=REQUEST_TIMEOUT,
    )
    if not r.ok:
        raise ApiError("POST", "/auth/login", r)
    payload = r.json()
    if payload.get("mfaRequired"):
        raise RuntimeError(
            f"{who} login requires MFA — this script cannot complete an MFA login. "
            "Disable MFA for the seed account or supply a non-MFA account."
        )
    token = payload.get("token")
    if not token:
        raise RuntimeError(f"{who} login returned no token: {payload}")
    return Client(who, token)


# --------------------------------------------------------------------------- #
# OTP — send + read from Postgres
# --------------------------------------------------------------------------- #
def resolve_otp_phone(customer: Client) -> str:
    """Phone the sign-OTP is sent to. Prefer the customer's own IAM profile phone
    (GET /api/me → phoneNumber); fall back to OTP_PHONE, then OTP_PHONE_FALLBACK.
    The value only has to be consistent between send + DB read."""
    phone = ""
    try:
        me = customer.get("/me") or {}
        phone = (me.get("phoneNumber") or "").strip()
    except Exception:
        pass
    phone = phone or OTP_PHONE or OTP_PHONE_FALLBACK
    if not phone:
        raise RuntimeError(
            "Could not resolve an OTP phone (customer has none, and neither "
            "OTP_PHONE nor OTP_PHONE_FALLBACK is set)."
        )
    return phone


def send_otp(customer: Client, phone: str) -> None:
    # POST /api/otp/send is @Public — no token needed, but sending one is harmless.
    customer.post_json("/otp/send", {"phone": phone})


def read_otp_from_db(phone: str) -> str:
    """Read the freshest plaintext OTP for `phone` from freight.otp_verifications."""
    dsn = (
        f"host={DB_HOST} port={DB_PORT} dbname={DB_NAME} "
        f"user={DB_USER} password={DB_PASSWORD}"
    )
    with psycopg.connect(dsn) as conn:
        with conn.cursor() as cur:
            cur.execute(
                f'SELECT otp FROM "{DB_SCHEMA}".otp_verifications '
                "WHERE phone = %s ORDER BY updated_at DESC LIMIT 1",
                (phone,),
            )
            row = cur.fetchone()
    if not row:
        raise RuntimeError(f"No OTP row found for phone {phone} in {DB_SCHEMA}.otp_verifications")
    return str(row[0])


# --------------------------------------------------------------------------- #
# Reference-data lookups (yards / service types / cargo types)
# --------------------------------------------------------------------------- #
@dataclass
class RefData:
    yards: list[dict] = field(default_factory=list)
    service_types: list[dict] = field(default_factory=list)
    cargo_types: list[dict] = field(default_factory=list)


def _as_items(resp: Any) -> list[dict]:
    if isinstance(resp, list):
        return resp
    if isinstance(resp, dict):
        return resp.get("items") or resp.get("data") or []
    return []


def load_ref_data(client: Client) -> RefData:
    ref = RefData(
        yards=_as_items(client.get("/yards")),
        service_types=_as_items(client.get("/service-types")),
        cargo_types=_as_items(client.get("/cargo-types")),
    )
    if len(ref.yards) < 2:
        raise RuntimeError(f"Need >=2 yards, got {len(ref.yards)}. Seed yards first.")
    if not ref.service_types:
        raise RuntimeError("No service types found. Seed service types first.")
    if not ref.cargo_types:
        raise RuntimeError("No cargo types found. Seed cargo types first.")
    return ref


def pick_service_type(ref: RefData, wants_customs: bool) -> str:
    """Prefer a service type whose includesCustoms matches the flow's customs need."""
    for st in ref.service_types:
        if bool(st.get("includesCustoms")) == wants_customs:
            return st["id"]
    # Fall back to any — customsClearingEnabled on the contract still drives the flow.
    return ref.service_types[0]["id"]


# --------------------------------------------------------------------------- #
# Contract payload builder
# --------------------------------------------------------------------------- #
def build_create_payload(flow: Flow, ref: RefData, idx: int) -> dict[str, str]:
    """Return multipart form fields. Booleans as 'true'/'false' strings; nested
    arrays as JSON strings (implicit conversion is off in the API)."""
    import json

    origin = ref.yards[0]["id"]
    destination = ref.yards[1]["id"]
    service_type_id = pick_service_type(ref, flow.customs)

    # Cargo scope: CONTAINER -> >=1 size row; BULK -> exactly one cargo-type row.
    if flow.freight == "CONTAINER":
        cargo_scope = [{"containerSize": "20ft"}]
        if flow.kind == "GENERAL":
            cargo_scope[0]["quantityCap"] = 10
    else:  # BULK
        cargo_scope = [{"cargoTypeId": ref.cargo_types[0]["id"]}]
        if flow.kind == "GENERAL":
            cargo_scope[0]["quantityCap"] = 1000

    # Routes: ONE_TIME -> exactly 1; GENERAL -> 1..N (one is fine).
    routes = [{"originYardId": origin, "destinationYardId": destination, "sortOrder": 0}]

    fields: dict[str, str] = {
        "contractKind": flow.kind,
        "tradeDirection": flow.trade_direction,
        "freightType": flow.freight,
        "serviceTypeId": service_type_id,
        "paymentCurrency": "ETB",
        "customsClearingEnabled": "true" if flow.customs else "false",
        "contractType": "SPOT",
        "cargoScope": json.dumps(cargo_scope),
        "routes": json.dumps(routes),
    }
    if flow.customs:
        fields["customsClearingAgent"] = "Seed Agent"
    return fields


def signature_b64() -> str:
    return base64.b64encode(WAAFI.read_bytes()).decode()


# --------------------------------------------------------------------------- #
# Lifecycle driver — create → submit → accept → approve → generate → sign x2
# --------------------------------------------------------------------------- #
def waafi_file_tuple(field_name: str) -> tuple:
    return (field_name, (WAAFI.name, WAAFI.read_bytes(), "image/jpeg"))


def drive_flow(
    flow: Flow, idx: int, customer: Client, admin: Client, ref: RefData, otp_phone: str
) -> dict[str, Any]:
    result: dict[str, Any] = {"flow": flow.label, "n": idx, "status": None}

    # S1 — create (customer, multipart, waafi attached as intake doc)
    fields = build_create_payload(flow, ref, idx)
    contract = customer.post_multipart(
        "/contracts", data=fields, files=[waafi_file_tuple("intake_document")]
    )
    cid = contract["id"]
    result["contractId"] = cid
    result["reference"] = contract.get("reference")

    # S2 — submit (customer). May go to PRICE_CHANGED_PENDING_CONFIRM → confirm.
    contract = customer.post_json(f"/contracts/{cid}/submit")
    if (contract or {}).get("status") == "PRICE_CHANGED_PENDING_CONFIRM":
        contract = customer.post_json(f"/contracts/{cid}/confirm-submit")

    # S3 — staff accept (admin) → PENDING_APPROVAL + approval chain
    admin.post_json(f"/contracts/{cid}/staff/accept", {"validityDays": VALIDITY_DAYS})

    # S4 — approve every pending step IN ORDER with its exact requiredRole (admin)
    approve_all_steps(admin, cid)

    # S5 — generate contract document (admin) → CONTRACT_READY
    admin.post_json(f"/contracts/{cid}/contract/generate")

    # S6 — customer sign (needs OTP) → SIGNED_CUSTOMER
    send_otp(customer, otp_phone)
    time.sleep(1.0)  # let the OTP row land
    otp = read_otp_from_db(otp_phone)
    customer.post_json(
        f"/contracts/{cid}/contract/sign",
        {
            "role": "CUSTOMER",
            "signatureImageBase64": signature_b64(),
            "signerDisplayName": "Seed Customer",
            "consentText": "I agree.",
            "otp": otp,
            "otpPhone": otp_phone,
        },
    )

    # S7 — staff counter-sign (admin) → FULLY_EXECUTED / CONTRACT_ACTIVE /
    #       AWAITING_CLEARANCE_DOCUMENTS depending on dimension. STOP HERE.
    signed = admin.post_json(
        f"/contracts/{cid}/contract/sign",
        {
            "role": "STAFF",
            "signatureImageBase64": signature_b64(),
            "signerDisplayName": "Seed Staff",
            "consentText": "Countersigned.",
        },
    )
    result["status"] = (signed or {}).get("status")
    return result


def approve_all_steps(admin: Client, cid: str) -> None:
    """Read the contract, approve each PENDING approval step in order. Superadmin
    can approve any role, but the endpoint still checks step.requiredRole == body,
    so we echo the step's own requiredRole back."""
    guard = 0
    while True:
        guard += 1
        if guard > 12:
            raise RuntimeError(f"Approval loop exceeded 12 iterations for {cid}")
        contract = admin.get(f"/contracts/{cid}")
        steps = contract.get("approvalSteps") or []
        pending = [s for s in steps if s.get("status") == "PENDING"]
        if not pending:
            return
        # findNextPendingApprovalStep orders by sequence; sort the same way.
        pending.sort(key=lambda s: s.get("sequence", s.get("sortOrder", 0)))
        step = pending[0]
        admin.post_json(
            f"/contracts/{cid}/approval-steps/{step['id']}/approve",
            {"requiredRole": step["requiredRole"]},
        )


# --------------------------------------------------------------------------- #
# Main
# --------------------------------------------------------------------------- #
VALID_FILTERS = {"all", "intercity", "import", "export"}


def parse_filters(argv: list[str]) -> set[str]:
    args = [a.lower() for a in argv[1:]]
    if not args or "all" in args:
        return {"intercity", "import", "export"}
    unknown = set(args) - VALID_FILTERS
    if unknown:
        raise SystemExit(
            f"Unknown filter(s): {', '.join(sorted(unknown))}. "
            f"Valid: {', '.join(sorted(VALID_FILTERS))}"
        )
    return set(args)


def require_config() -> None:
    missing = [
        name
        for name, val in [
            ("CUSTOMER_EMAIL", CUSTOMER_EMAIL),
            ("CUSTOMER_PASSWORD", CUSTOMER_PASSWORD),
            ("ADMIN_EMAIL", ADMIN_EMAIL),
            ("ADMIN_PASSWORD", ADMIN_PASSWORD),
        ]
        if not val
    ]
    if missing:
        raise SystemExit(f"Missing required .env keys: {', '.join(missing)}")
    if not WAAFI.exists():
        raise SystemExit(f"Missing signature/upload image: {WAAFI}")


def main() -> None:
    require_config()
    wanted = parse_filters(sys.argv)

    flows = [f for f in build_flow_matrix() if f.movement in wanted]
    total = len(flows) * CONTRACTS_PER_FLOW
    print(f"API      : {API_URL}")
    print(f"Filters  : {', '.join(sorted(wanted))}")
    print(f"Flows    : {len(flows)}  x {CONTRACTS_PER_FLOW} = {total} contracts\n")

    print("Logging in...")
    customer = login(CUSTOMER_EMAIL, CUSTOMER_PASSWORD, "customer")
    admin = login(ADMIN_EMAIL, ADMIN_PASSWORD, "admin")

    otp_phone = resolve_otp_phone(customer)
    print(f"OTP phone: {otp_phone}")

    print("Loading reference data...")
    ref = load_ref_data(admin)

    results: list[dict] = []
    for flow in flows:
        for n in range(1, CONTRACTS_PER_FLOW + 1):
            tag = f"[{flow.label} #{n}]"
            try:
                res = drive_flow(flow, n, customer, admin, ref, otp_phone)
                print(f"  OK   {tag} {res['reference']} -> {res['status']}")
                results.append(res)
            except Exception as exc:  # noqa: BLE001 — report and continue
                print(f"  FAIL {tag} {exc}")
                results.append({"flow": flow.label, "n": n, "error": str(exc)})

    ok = [r for r in results if not r.get("error")]
    bad = [r for r in results if r.get("error")]
    print(f"\nDone. {len(ok)} created, {len(bad)} failed, {total} attempted.")
    if bad:
        sys.exit(1)


if __name__ == "__main__":
    main()
