import {
  idempotencyKey,
  type ChargeRequest,
  type ChargeResult,
  type PaymentProvider,
  type ProviderEvent,
  type ProviderFactory,
  type RefundRequest,
  type RefundResult,
} from './index.js'
import { InvalidAmountError, WebhookVerificationError } from './errors.js'

const SUMIT_API_BASE = 'https://api.sumit.co.il'

export type SumitCredentials = {
  CompanyID: number
  APIKey: string
}

export type SumitVendorChargeItem = {
  companyId: number
  apiKey: string
  itemName: string
  /** Integer minor-units (agorot). */
  amountMinor: number
}

export type SumitCreds = {
  platformCompanyId: number
  platformApiKey: string
  webhookSecret: string
  baseUrl?: string
  fetch?: typeof fetch
}

export type SumitRefundVendorItem = {
  companyId: number
  apiKey: string
  amountMinor: number
  documentId: number
}

export type SumitRefundExtras = {
  customerName?: string
  vendorItems: SumitRefundVendorItem[]
  platformDocumentId: number
  platformAmountMinor: number
}

export type SumitRefundRequest = RefundRequest & {
  sumit?: SumitRefundExtras
}

/**
 * Structured, charge-time-only Sumit inputs. These ride a TYPED request
 * extension — never `metadata` (which is scalar round-trip only): the
 * single-use card token, the multivendor split, and customer identity are
 * provider-shaped and never round-tripped through a webhook.
 */
export type SumitChargeExtras = {
  singleUseToken: string
  vendorItems?: SumitVendorChargeItem[]
  /** Integer minor-units; defaults to charge total minus the vendor legs. */
  platformAmountMinor?: number
  platformItemName?: string
  customerName?: string
  customerEmail?: string
  customerPhone?: string
}

export type SumitChargeRequest = ChargeRequest & {
  sumit?: SumitChargeExtras
}

type SumitEnvelope<T> = {
  Status: number
  Data: T
  Message?: string
}

type SumitVendorResult = {
  CompanyID: number
  Payment: {
    PaymentID: string
    Amount: number
  }
  DocumentID: number
  DocumentDownloadURL: string
}

type MultiVendorChargeResponse = {
  Vendors: SumitVendorResult[]
}

type SingleChargeResponse = {
  PaymentID: string
  DocumentID: number
  DocumentDownloadURL: string
}

export class SumitApiError extends Error {
  constructor(
    readonly path: string,
    readonly status: number,
    readonly sumitMessage: string,
    readonly raw?: unknown,
  ) {
    super(`SUMIT ${path} status=${status}: ${sumitMessage}`)
    this.name = 'SumitApiError'
  }
}

/** One refund leg that completed before the failure (for precise resume). */
export type SumitRefundLegOutcome = {
  scope: 'vendor' | 'platform'
  companyId: number
  amountMinor: number
  externalId: string
  paymentId: string
}

/**
 * A multi-leg Sumit refund failed PART-WAY. Sumit does NOT dedup negative charges
 * server-side (idempotency is the caller's DB-layer job — see the sumit-api skill),
 * so a blind FULL retry would re-issue the `completedLegs` and double-refund. This
 * structured error carries exactly which legs already succeeded so the caller resumes
 * the REMAINING legs only. `cause` is the underlying leg failure.
 */
export class SumitRefundPartialFailureError extends Error {
  override readonly name = 'SumitRefundPartialFailureError'
  readonly code = 'SUMIT_REFUND_PARTIAL_FAILURE' as const

  constructor(
    readonly chargeKey: string,
    readonly refundId: string,
    readonly completedLegs: readonly SumitRefundLegOutcome[],
    override readonly cause: unknown,
  ) {
    super(
      `Sumit refund ${chargeKey}/${refundId} failed after ${completedLegs.length} completed leg(s); ` +
        'retry the REMAINING legs only — completed legs are NOT deduped server-side',
    )
  }
}

/** Structural guard — cross-package `instanceof` is unreliable under dedup (CLAUDE.md §6). */
export function isSumitRefundPartialFailureError(
  err: unknown,
): err is SumitRefundPartialFailureError {
  return (
    typeof err === 'object' &&
    err !== null &&
    (err as { code?: unknown }).code === 'SUMIT_REFUND_PARTIAL_FAILURE'
  )
}

/** Convert a major-unit decimal string/number to integer minor-units without float multiply. */
export function majorDecimalToMinorUnits(value: string | number): number {
  const raw = typeof value === 'number' ? formatMajorFromNumber(value) : value.trim()
  if (!/^-?\d+(\.\d+)?$/.test(raw)) {
    throw new Error(`invalid major-unit amount: ${value}`)
  }
  const negative = raw.startsWith('-')
  const unsigned = negative ? raw.slice(1) : raw
  const [wholePart, fracPart = ''] = unsigned.split('.')
  // Digits beyond 2 decimals are sub-minor-unit money: silently truncating them
  // drifts the ledger, so fail loud (trailing zeros are exact and accepted).
  if (fracPart.length > 2 && /[^0]/.test(fracPart.slice(2))) {
    throw new InvalidAmountError(`sub-minor-unit precision in major amount: ${value}`, {
      value,
      field: 'majorDecimalToMinorUnits',
    })
  }
  const whole = Number.parseInt(wholePart || '0', 10)
  const fracDigits = (fracPart + '00').slice(0, 2)
  const frac = Number.parseInt(fracDigits, 10)
  const minor = whole * 100 + frac
  return negative ? -minor : minor
}

function formatMajorFromNumber(value: number): string {
  if (!Number.isFinite(value)) {
    throw new Error(`invalid major-unit amount: ${value}`)
  }
  const negative = value < 0
  const abs = Math.abs(value)
  let whole = Math.trunc(abs)
  let frac = Math.round((abs - whole) * 100)
  // A fraction ≥ .995 rounds to 100: without the carry this emits e.g. "19.100",
  // which re-parses as 19.10 — a silent digit blowup, not a rounding.
  if (frac === 100) {
    whole += 1
    frac = 0
  }
  const sign = negative ? '-' : ''
  if (frac === 0) return `${sign}${whole}`
  return `${sign}${whole}.${String(frac).padStart(2, '0')}`
}

/** Convert integer minor-units to Sumit UnitPrice decimal without float division. */
export function minorUnitsToUnitPrice(minor: number): number {
  if (!Number.isInteger(minor)) {
    throw new Error(`minorUnitsToUnitPrice: expected integer minor-units, got ${minor}`)
  }
  const negative = minor < 0
  const abs = Math.abs(minor)
  const whole = Math.trunc(abs / 100)
  const frac = abs % 100
  if (frac === 0) return negative ? -whole : whole
  const price = Number.parseFloat(`${negative ? '-' : ''}${whole}.${String(frac).padStart(2, '0')}`)
  return price
}

// R3: hoist the encoder — never reconstruct a native per call.
const TEXT_ENCODER = new TextEncoder()

async function timingSafeEqual(a: string, b: string): Promise<boolean> {
  const [ad, bd] = await Promise.all([
    crypto.subtle.digest('SHA-256', TEXT_ENCODER.encode(a)),
    crypto.subtle.digest('SHA-256', TEXT_ENCODER.encode(b)),
  ])
  const av = new Uint8Array(ad)
  const bv = new Uint8Array(bd)
  let diff = 0
  for (let i = 0; i < av.length; i++) diff |= av[i]! ^ bv[i]!
  return diff === 0
}

async function sumitFetch<T>(
  path: string,
  body: unknown,
  fetchImpl: typeof fetch,
  baseUrl: string,
): Promise<T> {
  const url = `${baseUrl}${path}`
  const res = await fetchImpl(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  })

  if (!res.ok) {
    throw new SumitApiError(path, res.status, `HTTP ${res.status} ${res.statusText}`)
  }

  const envelope = (await res.json()) as SumitEnvelope<T>
  if (envelope.Status !== 0) {
    throw new SumitApiError(
      path,
      envelope.Status,
      envelope.Message ?? 'Unknown SUMIT error',
      envelope,
    )
  }

  return envelope.Data
}

function extractEventId(payload: Record<string, unknown>): string {
  const id =
    payload.eventId ??
    payload.EventID ??
    payload.id ??
    (payload.type && payload.subscriptionId
      ? `${String(payload.type)}:${String(payload.subscriptionId)}`
      : undefined)
  if (!id) {
    throw new Error('Sumit webhook payload missing event id')
  }
  return String(id)
}

export const sumit: ProviderFactory = (creds: unknown): PaymentProvider => {
  const {
    platformCompanyId,
    platformApiKey,
    webhookSecret,
    baseUrl = SUMIT_API_BASE,
    fetch: fetchImpl = fetch,
  } = creds as SumitCreds

  // Fail-CLOSED at construction: an undefined/empty webhookSecret would make
  // `parseWebhook` accept an empty `x-webhook-secret` header (both sides empty →
  // timing-safe compare returns equal), silently bypassing verification. The
  // `string` type does not bind at runtime (factory takes `unknown`), so guard here.
  if (typeof webhookSecret !== 'string' || webhookSecret.length === 0) {
    throw new Error('Sumit creds require a non-empty webhookSecret')
  }

  const platformCreds: SumitCredentials = {
    CompanyID: platformCompanyId,
    APIKey: platformApiKey,
  }

  return {
    provider: 'sumit',
    emitsInvoiceOnCharge: true,

    async charge(req: ChargeRequest): Promise<ChargeResult> {
      const normalizedCurrency = req.currency.toLowerCase()
      if (normalizedCurrency !== 'ils') {
        throw new Error(`Sumit settles ILS only, got ${req.currency}`)
      }

      const extras = (req as SumitChargeRequest).sumit
      if (!extras) {
        throw new Error('Sumit charge requires sumit extras on the request')
      }
      const { singleUseToken } = extras
      if (!singleUseToken) {
        throw new Error('Sumit charge requires sumit.singleUseToken')
      }

      const vendorItems = extras.vendorItems ?? []
      // A negative vendor leg posts a negative UnitPrice — Sumit would CREDIT
      // out of that vendor's account on a live charge. Same class as the
      // negative platform-leg guard below.
      for (const item of vendorItems) {
        if (!Number.isInteger(item.amountMinor) || item.amountMinor < 0) {
          throw new InvalidAmountError(
            `Sumit charge vendor leg must be a non-negative integer minor-units value, got ${item.amountMinor}`,
            { value: item.amountMinor, field: 'vendorItems[].amountMinor' },
          )
        }
      }
      const vendorSum = vendorItems.reduce((sum, item) => sum + item.amountMinor, 0)
      const platformAmountMinor = extras.platformAmountMinor ?? req.amount - vendorSum

      // A negative platform leg (vendor split over-allocates the charge total)
      // would post a negative UnitPrice — Sumit would CREDIT the platform on a
      // live charge. Reject; the platform leg is a fee, never a credit.
      if (!Number.isInteger(platformAmountMinor) || platformAmountMinor < 0) {
        throw new Error(
          `Sumit charge platform leg must be a non-negative integer minor-units value, got ${platformAmountMinor}`,
        )
      }

      // Sumit charges Σ Items, NOT req.amount — an explicit platformAmountMinor
      // that mismatches would silently charge the buyer a different figure than
      // the order total the caller settled on. Cross-check at charge time.
      if (vendorSum + platformAmountMinor !== req.amount) {
        throw new InvalidAmountError(
          `Sumit charge legs must sum to the charge amount: vendor ${vendorSum} + platform ${platformAmountMinor} !== ${req.amount}`,
          { value: vendorSum + platformAmountMinor, field: 'vendorSum+platformAmountMinor' },
        )
      }

      const sumitVendorItems = vendorItems.map((item) => ({
        CompanyID: item.companyId,
        APIKey: item.apiKey,
        Item: { Name: item.itemName },
        UnitPrice: minorUnitsToUnitPrice(item.amountMinor),
        Quantity: 1,
      }))

      const platformChargeItem = {
        CompanyID: platformCreds.CompanyID,
        APIKey: platformCreds.APIKey,
        Item: { Name: extras.platformItemName ?? 'Platform fee' },
        UnitPrice: minorUnitsToUnitPrice(platformAmountMinor),
        Quantity: 1,
      }

      const body = {
        SingleUseToken: singleUseToken,
        Customer: {
          Name: extras.customerName ?? 'Customer',
          EmailAddress: extras.customerEmail ?? '',
          ...(extras.customerPhone ? { Phone: extras.customerPhone } : {}),
        },
        Items: [...sumitVendorItems, platformChargeItem],
        ExternalIdentifier: req.chargeKey,
        SendDocumentByEmail: true,
        DocumentType: 1,
        VATIncluded: true,
        Payments_Count: 1,
      }

      const data = await sumitFetch<MultiVendorChargeResponse>(
        '/billing/payments/multivendorcharge/',
        body,
        fetchImpl,
        baseUrl,
      )

      // Select the platform leg by CompanyID IDENTITY, not by array position.
      // Sumit documents `Data.Vendors` order as matching `Items` order, but
      // binding the platform PaymentID to a positional index silently mis-attributes
      // the providerRef if that ordering ever drifts. The platform leg is appended
      // last; resolve it by identity at the expected tail, falling back to the tail
      // index only when no CompanyID match exists (platform not echoed as a vendor row).
      const platformVendor =
        data.Vendors.find((v) => v.CompanyID === platformCreds.CompanyID) ??
        data.Vendors[vendorItems.length]
      if (!platformVendor) {
        throw new Error('Sumit multivendorcharge response missing platform vendor entry')
      }

      const documentUrls = data.Vendors.map((v) => v.DocumentDownloadURL).filter(Boolean)
      const amount = data.Vendors.reduce(
        (sum, vendor) => sum + majorDecimalToMinorUnits(vendor.Payment.Amount),
        0,
      )

      return {
        kind: 'settled',
        chargeKey: req.chargeKey,
        providerRef: platformVendor.Payment.PaymentID,
        documentUrls,
        amount,
        currency: 'ILS',
      }
    },

    async refund(req: RefundRequest): Promise<RefundResult> {
      const extras = (req as SumitRefundRequest).sumit
      if (!extras) {
        throw new Error('Sumit refund requires sumit extras on the request')
      }

      const customerName = extras.customerName ?? 'Customer'
      let totalRefunded = 0
      let primaryProviderRef = ''
      const completedLegs: SumitRefundLegOutcome[] = []

      async function refundLeg(
        creds: SumitCredentials,
        amountMinor: number,
        externalId: string,
      ): Promise<string> {
        if (amountMinor <= 0) return ''
        const body = {
          Credentials: creds,
          Customer: { Name: customerName, EmailAddress: '' },
          Item: { Name: 'Refund' },
          UnitPrice: minorUnitsToUnitPrice(-amountMinor),
          Quantity: 1,
          ExternalIdentifier: externalId,
          SendDocumentByEmail: false,
          DocumentType: 1,
          VATIncluded: true,
        }
        const data = await sumitFetch<SingleChargeResponse>(
          '/billing/payments/charge/',
          body,
          fetchImpl,
          baseUrl,
        )
        totalRefunded += amountMinor
        return data.PaymentID
      }

      async function cancelDoc(creds: SumitCredentials, documentId: number): Promise<void> {
        try {
          await sumitFetch<unknown>(
            '/accounting/documents/cancel/',
            {
              Credentials: creds,
              DocumentID: documentId,
              Description: `Refund for ${req.chargeKey}`,
            },
            fetchImpl,
            baseUrl,
          )
        } catch {
          // best-effort doc-cancel
        }
      }

      // Drive each negative-charge leg; on a mid-sequence failure surface a
      // structured partial-failure error carrying the legs that ALREADY succeeded.
      // Sumit does not dedup negative charges, so a blind full-retry would
      // double-refund the completed legs — the caller must resume the rest only.
      try {
        for (const item of extras.vendorItems) {
          if (item.amountMinor <= 0) continue
          // documentId disambiguates two legs of the SAME vendor — identical leg
          // keys would make completedLegs ambiguous on a partial-failure resume.
          const legKey = idempotencyKey([
            'refund',
            'vendor',
            req.chargeKey,
            req.refundId,
            String(item.companyId),
            String(item.documentId),
          ])
          const paymentId = await refundLeg(
            { CompanyID: item.companyId, APIKey: item.apiKey },
            item.amountMinor,
            legKey,
          )
          if (paymentId) {
            completedLegs.push({
              scope: 'vendor',
              companyId: item.companyId,
              amountMinor: item.amountMinor,
              externalId: legKey,
              paymentId,
            })
          }
          if (!primaryProviderRef && paymentId) primaryProviderRef = paymentId
          await cancelDoc({ CompanyID: item.companyId, APIKey: item.apiKey }, item.documentId)
        }

        if (extras.platformAmountMinor > 0) {
          const legKey = idempotencyKey(['refund', 'platform', req.chargeKey, req.refundId])
          const paymentId = await refundLeg(platformCreds, extras.platformAmountMinor, legKey)
          if (paymentId) {
            completedLegs.push({
              scope: 'platform',
              companyId: platformCreds.CompanyID,
              amountMinor: extras.platformAmountMinor,
              externalId: legKey,
              paymentId,
            })
          }
          if (!primaryProviderRef && paymentId) primaryProviderRef = paymentId
        }
      } catch (err) {
        throw new SumitRefundPartialFailureError(
          req.chargeKey,
          req.refundId,
          completedLegs,
          err,
        )
      }

      await cancelDoc(platformCreds, extras.platformDocumentId)

      return {
        kind: 'refunded',
        refundKey: req.refundKey,
        chargeKey: req.chargeKey,
        providerRef: primaryProviderRef,
        amount: totalRefunded,
        currency: 'ILS',
      }
    },

    async parseWebhook(raw: string, headers: Headers): Promise<ProviderEvent> {
      const incoming = headers.get('x-webhook-secret')
      if (!incoming || !(await timingSafeEqual(incoming, webhookSecret))) {
        throw new WebhookVerificationError('invalid x-webhook-secret')
      }

      const payload = JSON.parse(raw) as Record<string, unknown>
      return {
        eventId: extractEventId(payload),
        kind: 'other',
        raw: payload,
      }
    },
  }
}
