/**
 * `@platform-modules/billing` — thin money-seam (provider axis + webhook ingest + settle/refund funnels).
 */
import type { Querier, Schema } from '@platform-modules/db'
import type { AppendEntryInput, AppendEntryResult } from '@platform-modules/ledger'
import {
  ChargeIntentPendingError,
  InvalidAmountError,
  RefundExceedsPaidError,
  WebhookVerificationError,
} from './errors.js'

// ── Provider wire shapes ─────────────────────────────────────────────────────

export type ChargeRequest = {
  chargeKey: string
  amount: number
  currency: string
  metadata?: Record<string, string>
}

export type ChargeResult =
  | {
      kind: 'requires_client_action'
      chargeKey: string
      providerRef: string
      clientSecret: string
    }
  | {
      kind: 'settled'
      chargeKey: string
      providerRef: string
      amount: number
      currency: string
      documentUrls?: string[]
    }

export type RefundRequest = {
  refundKey: string
  chargeKey: string
  refundId: string
  amount: number
}

export type RefundResult =
  | {
      kind: 'refunded'
      refundKey: string
      chargeKey: string
      providerRef: string
      amount: number
      currency: string
    }
  | { kind: 'pending' }

export type ProviderEvent =
  | {
      eventId: string
      kind: 'settlement'
      chargeKey: string
      providerRef: string
      amount: number
      currency: string
      documentUrls?: string[]
      subscriptionId?: string
      period?: string
      amountMinor?: bigint
    }
  | {
      eventId: string
      kind: 'refund'
      refundKey: string
      chargeKey: string
      providerRef: string
      amount: number
      currency: string
    }
  | { eventId: string; kind: 'other'; raw: unknown }

export interface PaymentProvider {
  readonly provider: string
  readonly emitsInvoiceOnCharge: boolean
  charge(req: ChargeRequest): Promise<ChargeResult>
  refund(req: RefundRequest): Promise<RefundResult>
  parseWebhook(raw: string, headers: Headers): Promise<ProviderEvent>
}

// ── DedupStore — M5 atomic single-winner claim (host-implemented) ─────────────

/**
 * Host-implemented webhook event dedup. NOT a have-I-seen-it boolean — a
 * single-statement atomic single-winner claim over the provider event id.
 *
 * SQL recipe (host-owned table, billing-owned contract):
 * ```sql
 * INSERT INTO webhook_events (event_id, claimed_at)
 * VALUES ($1, now())
 * ON CONFLICT (event_id) DO UPDATE SET claimed_at = now()
 *   WHERE webhook_events.processed_at IS NULL
 *     AND (webhook_events.claimed_at IS NULL
 *          OR webhook_events.claimed_at < now() - INTERVAL '5 min')
 * RETURNING event_id;
 * ```
 * Empty `RETURNING` ⇒ `'lost'`. `markProcessed` sets `processed_at` ONLY after
 * all side-effects (module post + host dispatch) succeed.
 *
 * TTL corollary (host contract): the winner's post + dispatch MUST complete well
 * within the claim reclaim TTL — a `dispatch` running longer than the window lets
 * a concurrent redelivery win a FRESH claim and double-fire dispatch; keep dispatch
 * fast (enqueue to jobs/outbox, never do slow work inline), or widen the window
 * past worst-case dispatch latency.
 */
export interface DedupStore {
  claim(eventId: string): Promise<'won' | 'lost'>
  markProcessed(eventId: string): Promise<void>
}

// ── IntentStore — M1/M2 durable charge-intent claim (host-implemented) ────────

/**
 * Host-implemented durable charge-intent record that closes the sync
 * charge→ledger window (spec floor #6 rewrite). Without it `settleCharge` ran
 * `provider.charge()` then the ledger post — a crash in between left the card
 * charged with NO durable record (the MONEY-001 silent charge-without-record,
 * money.md M1). The ledger entry cannot serve as the pending marker: floor #4
 * makes a ledger entry MEAN received money, and it lands only AFTER the charge.
 *
 * The fix mirrors `DedupStore` — the host owns the TABLE, billing owns the
 * CONTRACT + SQL recipe + TTL. A durable `pending` intent is written BEFORE the
 * provider call, so a crash leaves a RECOVERABLE intent, never a silent charge.
 *
 * SQL recipe (host-owned table, billing-owned contract):
 * ```sql
 * -- claimIntent: single-statement atomic single-winner over charge_key.
 * INSERT INTO charge_intents (charge_key, amount, currency, status, claimed_at)
 * VALUES ($1, $2, $3, 'pending', now())
 * ON CONFLICT (charge_key) DO NOTHING
 * RETURNING status;
 * --   non-empty RETURNING ('pending') ⇒ 'won' (this caller inserted it).
 * --   empty RETURNING ⇒ a row already exists → SELECT its status:
 * --     status='settled' ⇒ return 'settled' (M2 resolution short-circuit, no-op).
 * --     status='pending' ⇒ return 'pending' (a concurrent in-flight intent OR a
 * --                        prior crash) — do NOT charge again; hand to reconcile.
 * ```
 *
 * RECOVERY DISCRIMINATOR vs `DedupStore`: a duplicate WEBHOOK self-heals because
 * the provider RE-DELIVERS it. A crashed inline CHARGE has nothing re-driving it,
 * so it needs an ACTIVE sweep that ENUMERATES unresolved intents —
 * `listUnsettledIntents`, the method `DedupStore` deliberately lacks. The host
 * crons it and calls `reconcileCharge` on each stale `pending` intent.
 */
export interface IntentStore {
  /**
   * Phase-1 claim. `'won'` ⇒ proceed to charge. `'settled'` ⇒ a resolution
   * already exists ⇒ caller short-circuits (idempotent re-entry). `'pending'`
   * ⇒ a concurrent/crashed in-flight intent ⇒ do NOT charge again.
   */
  claimIntent(
    chargeKey: string,
    intent: { amount: number; currency: string },
  ): Promise<'won' | 'pending' | 'settled'>
  /** Persist the provider id the instant `charge()` returns → M4 local settle from stored ref. */
  recordProviderRef(chargeKey: string, providerRef: string): Promise<void>
  /** Flip to settled AFTER `confirmSettlement` posts → `claimIntent` returns `'settled'` on replay. */
  markIntentSettled(chargeKey: string): Promise<void>
  /** The ACTIVE-sweep enumerator (the discriminator vs `DedupStore`). */
  listUnsettledIntents(
    olderThanMs: number,
  ): Promise<Array<{ chargeKey: string; providerRef?: string; amount: number; currency: string }>>
}

// ── Ledger bag ───────────────────────────────────────────────────────────────

export type LedgerSeam = {
  appendEntry: <S extends Schema = Schema>(
    tx: Querier<S>,
    input: AppendEntryInput,
  ) => Promise<AppendEntryResult>
}

export type LedgerDbBag<S extends Schema = Schema> = {
  ledger: LedgerSeam
  db: Querier<S>
}

// ── Money helpers ────────────────────────────────────────────────────────────

/**
 * Deterministic idempotency key — stable parts only; never Date.now/random/UUID.
 * Each part is percent-encoded before joining so a host-supplied part containing
 * the `:` separator cannot collide two distinct part-vectors onto one key
 * (e.g. `['refund','ord','1:r']` vs `['refund','ord:1','r']`) — that collision
 * would silently no-op the second `appendEntry` (`inserted:false`) and overstate
 * the ledger. Encoding keeps `join(':')` injective over arbitrary parts.
 */
export function idempotencyKey(parts: readonly string[]): string {
  return parts.map(encodeURIComponent).join(':')
}

/** Integer minor-units → bigint (M7). Unit normalization is the adapter's job. */
export function toMinorUnits(amount: number): bigint {
  if (!Number.isInteger(amount)) {
    throw new Error(`toMinorUnits: amount must be an integer minor-units value, got ${amount}`)
  }
  return BigInt(amount)
}

// ── Settlement funnel ────────────────────────────────────────────────────────

export type SettlementInput = {
  amount: number
  currency: string
  providerRef?: string
  documentUrls?: string[]
}

export async function confirmSettlement<S extends Schema>(
  chargeKey: string,
  settlement: SettlementInput,
  { ledger, db }: LedgerDbBag<S>,
): Promise<void> {
  // A settlement credits the ledger; a negative delta through this funnel would
  // be a silent debit (refunds go through confirmRefund). toMinorUnits already
  // rejects non-integers.
  if (settlement.amount < 0) {
    throw new InvalidAmountError(
      `confirmSettlement: settlement amount must be non-negative, got ${settlement.amount}`,
      { value: settlement.amount, field: 'settlement.amount' },
    )
  }
  const key = idempotencyKey(['charge', chargeKey])
  await ledger.appendEntry(db, {
    key,
    delta: toMinorUnits(settlement.amount),
    currency: settlement.currency,
    reason: `settlement:${chargeKey}`,
  })
}

// ── Refund funnel ────────────────────────────────────────────────────────────

export type RefundConfirmInput = {
  chargeKey: string
  amount: number
  currency: string
  providerRef?: string
}

export async function confirmRefund<S extends Schema>(
  refundKey: string,
  refund: RefundConfirmInput,
  { ledger, db }: LedgerDbBag<S>,
): Promise<void> {
  // M8: reject only the violation billing can see WITHOUT reading ledger state —
  // a non-positive / non-integer amount. Settled-charge existence + the amount cap
  // are HOST preconditions (the host is the refundable-amount authority; it owns the
  // order record). Billing does NOT query ledger tables — reaching around the
  // `appendEntry` seam to read `ledgerEntries` is a swap-survival break (spec floor #8).
  if (!Number.isInteger(refund.amount) || refund.amount <= 0) {
    throw new RefundExceedsPaidError('refund amount must be a positive integer minor-units value', {
      chargeKey: refund.chargeKey,
      amount: refund.amount,
    })
  }

  await ledger.appendEntry(db, {
    key: refundKey,
    delta: -toMinorUnits(refund.amount),
    currency: refund.currency,
    reason: `refund:${refund.chargeKey}:${refundKey}`,
  })
}

// ── Charge / refund orchestration ────────────────────────────────────────────

export type SettleChargeDeps<S extends Schema = Schema> = LedgerDbBag<S> & {
  provider: PaymentProvider
  /** Required (floor #6): the durable charge-intent claim that closes the charge→ledger window. */
  intentStore: IntentStore
}

/**
 * M1/M2/M4 three-phase charge: a durable intent is claimed BEFORE the provider
 * call, so a crash between charge and ledger-post leaves a RECOVERABLE `pending`
 * intent rather than a silent charge-without-record (spec floor #6).
 *
 * Order: claimIntent → provider.charge → recordProviderRef → confirmSettlement
 * → markIntentSettled.
 *
 * A `'settled'` claim is the idempotent replay short-circuit — the charge was
 * already settled, so this returns without re-calling the provider (never a
 * double-charge). A `'pending'` claim means a concurrent in-flight attempt OR a
 * prior crash already owns this chargeKey; this call does NOT charge again —
 * recovery is `reconcileCharge`, driven by the host's `listUnsettledIntents`
 * sweep. The provider is called ONLY on a fresh `'won'` claim.
 */
export async function settleCharge<S extends Schema>(
  req: ChargeRequest,
  { provider, ledger, db, intentStore }: SettleChargeDeps<S>,
): Promise<ChargeResult> {
  // Phase 1 — durable claim BEFORE any provider effect.
  const claim = await intentStore.claimIntent(req.chargeKey, {
    amount: req.amount,
    currency: req.currency,
  })
  if (claim === 'settled') {
    // M2 resolution short-circuit: already settled → idempotent no-op.
    return {
      kind: 'settled',
      chargeKey: req.chargeKey,
      providerRef: '',
      amount: req.amount,
      currency: req.currency,
    }
  }
  if (claim === 'pending') {
    // A concurrent/crashed in-flight intent owns this chargeKey. Charging again
    // risks a double-charge (Sumit dedups only ~60s); recovery is reconcileCharge
    // off the host's listUnsettledIntents sweep, never a blind re-charge here.
    throw new ChargeIntentPendingError(req.chargeKey)
  }

  // Phase 2 — provider call, no durable-store lock held (M1: outside any tx).
  const result = await provider.charge(req)

  if (result.kind === 'settled') {
    // Persist the provider ref the instant charge returns → the common sub-window
    // (charge ok, settle crashed) heals from the STORED ref, zero further provider
    // calls (M4). Best-effort: a failure here is non-fatal to settlement (the
    // resolver fallback still recovers), so it must not strand a real charge.
    try {
      await intentStore.recordProviderRef(result.chargeKey, result.providerRef)
    } catch {
      // swallow — settlement proceeds; reconcile's resolver covers a missing ref.
    }
    // Phase 3 — ledger post (idempotent on its own key), then flip the intent.
    await confirmSettlement(
      result.chargeKey,
      {
        amount: result.amount,
        currency: result.currency,
        providerRef: result.providerRef,
        documentUrls: result.documentUrls,
      },
      { ledger, db },
    )
    await intentStore.markIntentSettled(result.chargeKey)
  }
  return result
}

// ── Charge-intent recovery (M4 active sweep — never re-charges) ───────────────

/**
 * The host's read of a charge's prior outcome, by chargeKey. Mirrors the
 * `resolvePaymentIntentId` creds-resolver: billing needs a provider fact it does
 * not hold (did the charge actually go through, and for how much), so the host —
 * which owns the order record AND the provider read — supplies it. Returns `null`
 * when the outcome is not yet knowable (leave the intent `pending` for a later
 * sweep). `reconcileCharge` NEVER re-calls `provider.charge` (M4: settle from the
 * stored/resolved ref, zero provider charge calls — re-charge is unsafe past the
 * ~60s Sumit dedup window).
 */
export type ResolveChargeOutcome = (
  chargeKey: string,
) => Promise<{ settled: boolean; providerRef: string; amount: number; currency: string } | null>

export type ReconcileChargeDeps<S extends Schema = Schema> = LedgerDbBag<S> & {
  intentStore: IntentStore
  resolveChargeOutcome: ResolveChargeOutcome
}

/**
 * Recovery for ONE stale `pending` charge intent (the host crons
 * `listUnsettledIntents` and calls this per entry). Reads the prior outcome via
 * the host resolver; if the charge settled, posts the idempotent
 * `confirmSettlement` and flips the intent — never re-charging. An unsettled /
 * unknown (`null`) outcome leaves the intent `pending` for the next sweep.
 * Returns the action taken (for host observability).
 */
export async function reconcileCharge<S extends Schema>(
  chargeKey: string,
  { intentStore, resolveChargeOutcome, ledger, db }: ReconcileChargeDeps<S>,
): Promise<'settled' | 'unresolved'> {
  const outcome = await resolveChargeOutcome(chargeKey)
  if (!outcome || !outcome.settled) {
    return 'unresolved'
  }
  // Idempotent on the namespaced charge key (floor #4): a concurrent settle or a
  // prior reconcile that already posted is a no-op here.
  await confirmSettlement(
    chargeKey,
    { amount: outcome.amount, currency: outcome.currency, providerRef: outcome.providerRef },
    { ledger, db },
  )
  await intentStore.markIntentSettled(chargeKey)
  return 'settled'
}

export type RefundChargeDeps<S extends Schema = Schema> = LedgerDbBag<S> & {
  provider: PaymentProvider
}

export async function refundCharge<S extends Schema>(
  req: RefundRequest,
  { provider, ledger, db }: RefundChargeDeps<S>,
): Promise<RefundResult> {
  const result = await provider.refund(req)
  if (result.kind === 'refunded') {
    await confirmRefund(
      result.refundKey,
      {
        chargeKey: result.chargeKey,
        amount: result.amount,
        currency: result.currency,
        providerRef: result.providerRef,
      },
      { ledger, db },
    )
  }
  return result
}

// ── Webhook ingest ───────────────────────────────────────────────────────────

/**
 * Host-implemented side-effects for a won, non-duplicate webhook event
 * (fulfilment / status / notify). HOST PRECONDITION — `dispatch` MUST be
 * IDEMPOTENT keyed by `event.eventId`: a `markProcessed` failure leaves the
 * claim's `processed_at` NULL, so a post-TTL redelivery re-wins the claim and
 * re-fires `dispatch`. The "keep dispatch fast within the TTL window"
 * (`DedupStore`) contract covers only the CONCURRENT-redelivery double-fire; the
 * post-TTL re-fire is covered ONLY by eventId-keyed dispatch idempotency (enqueue
 * to jobs/outbox keyed on `event.eventId`, never do non-idempotent work inline).
 */
export type WebhookDispatch = (event: ProviderEvent) => Promise<void>

export type IngestWebhookDeps<S extends Schema = Schema> = LedgerDbBag<S> & {
  provider: PaymentProvider
  dedupStore: DedupStore
  dispatch: WebhookDispatch
}

export async function ingestWebhook<S extends Schema>(
  req: Request,
  { provider, dedupStore, dispatch, ledger, db }: IngestWebhookDeps<S>,
): Promise<Response> {
  if (req.bodyUsed) {
    // A host misconfiguration, not a forgery: 500 keeps the provider retrying so
    // events deliver once fixed; 400 would permanently drop every event in the
    // misconfig window (400 is reserved for the typed verification failure).
    return new Response('webhook body already consumed — pass raw unparsed bytes', {
      status: 500,
    })
  }

  let raw: string
  try {
    raw = await req.text()
  } catch {
    return new Response('failed to read webhook body', { status: 500 })
  }

  let event: ProviderEvent
  try {
    event = await provider.parseWebhook(raw, req.headers)
  } catch (err) {
    const status = err instanceof WebhookVerificationError ? 400 : 500
    return new Response(
      err instanceof WebhookVerificationError
        ? 'webhook verification failed'
        : 'webhook processing failed',
      { status },
    )
  }

  let claim: 'won' | 'lost'
  try {
    claim = await dedupStore.claim(event.eventId)
  } catch {
    // Claim runs BEFORE any non-idempotent effect — a throw here means no effect
    // ran, so 500 re-runs clean (same reasoning as the parseWebhook recovery path).
    // ingestWebhook owns its Response: never let a claim throw escape (a host that
    // maps an uncaught throw to a non-5xx → provider sees success → no retry → drop).
    return new Response('webhook claim failed', { status: 500 })
  }
  if (claim === 'lost') {
    return new Response(null, { status: 200 })
  }

  try {
    // MODULE-OWNED post (settlement/refund reversal) — runs before dispatch. A
    // transient failure (ledger/DB hiccup) is CAUGHT → 500, claim left UNMARKED
    // (TTL-heals; the post is idempotent on its key → a post-TTL redelivery reposts).
    // ingestWebhook NEVER propagates an unmapped throw — host-framework-independent.
    if (event.kind === 'settlement') {
      await confirmSettlement(
        event.chargeKey,
        {
          amount: event.amount,
          currency: event.currency,
          providerRef: event.providerRef,
          documentUrls: event.documentUrls,
        },
        { ledger, db },
      )
    } else if (event.kind === 'refund') {
      await confirmRefund(
        event.refundKey,
        {
          chargeKey: event.chargeKey,
          amount: event.amount,
          currency: event.currency,
          providerRef: event.providerRef,
        },
        { ledger, db },
      )
    }
  } catch {
    // Any throw from the module-owned post ⇒ 500, the safe retryable default.
    return new Response('webhook post failed', { status: 500 })
  }

  try {
    await dispatch(event)
  } catch {
    // M6: dispatch threw after its effect — leave claim unmarked (TTL-heals).
    return new Response('webhook handler failed after dispatch', { status: 500 })
  }

  try {
    await dedupStore.markProcessed(event.eventId)
  } catch {
    // markProcessed runs AFTER post+dispatch already succeeded ⇒ the event WAS
    // fully processed, so 200 is truthful. 500 is strictly worse: the provider's
    // fast retry re-enters, claim() finds the still-recent claim → 'lost' → 200 at
    // the lost-branch BEFORE markProcessed is reached → repairs nothing, just emits
    // spurious retry + 500-noise on a completed event. The processed_at-stays-NULL
    // residual (a post-TTL redelivery re-fires dispatch) is covered by dispatch
    // IDEMPOTENCY keyed by event id, not this Response. Completes the never-throws
    // invariant: ingestWebhook owns its Response on every await, including the last.
    return new Response(null, { status: 200 })
  }
  return new Response(null, { status: 200 })
}

// ── Provider factory ─────────────────────────────────────────────────────────

export type BillingProviderName = 'stripe' | 'sumit'

export type ProviderFactory = (creds: unknown) => PaymentProvider

export type ProviderFactories = Partial<Record<BillingProviderName, ProviderFactory>>

let providerFactories: ProviderFactories = {}

export function setProviderFactories(factories: ProviderFactories): void {
  providerFactories = { ...providerFactories, ...factories }
}

export function getProvider(name: string, creds: unknown): PaymentProvider {
  const factory = providerFactories[name as BillingProviderName]
  if (!factory) {
    throw new Error(
      `No provider factory registered for "${name}". Import @platform-modules/billing/${name} and register via setProviderFactories.`,
    )
  }
  return factory(creds)
}

export {
  ChargeIntentPendingError,
  InvalidAmountError,
  RefundExceedsPaidError,
  RefundFailedError,
  WebhookVerificationError,
} from './errors.js'
