import { setTimeout as sleep } from 'node:timers/promises'
import type { Logger } from '@znet/shared'
import { oauth1Header, type OAuth1Credentials } from './oauth1.js'
import { toQuery, type QueryParams, type SearchCriteria } from './search-criteria.js'
import type { SearchResult } from './types.js'

export interface MagentoClientOptions {
  baseUrl: string
  accessToken: string
  /** All four OAuth1 values present → signed requests; otherwise the token is sent as a Bearer token. */
  oauth1?: Partial<OAuth1Credentials>
  timeoutMs?: number
  maxRetries?: number
  logger?: Logger
  /** Injected for tests. */
  fetch?: typeof fetch
}

export interface RequestOptions {
  /** Store view code. `all` = admin/default scope. */
  store?: string
  query?: QueryParams
  /** Magento `fields=` projection to trim payloads, e.g. `items[sku,id],total_count`. */
  fields?: string
}

export class MagentoApiError extends Error {
  constructor(
    public readonly status: number,
    public readonly path: string,
    public readonly body: string,
  ) {
    super(`Magento ${status} on ${path}: ${extractMessage(body)}`)
    this.name = 'MagentoApiError'
  }

  get isNotFound() {
    return this.status === 404
  }
}

function extractMessage(body: string): string {
  try {
    const parsed = JSON.parse(body) as { message?: string; parameters?: Record<string, string> | string[] }
    let msg = parsed.message ?? body
    const params = parsed.parameters
    if (params && !Array.isArray(params)) for (const [k, v] of Object.entries(params)) msg = msg.replace(`%${k}`, v)
    if (Array.isArray(params)) params.forEach((v, i) => (msg = msg.replace(`%${i + 1}`, v)))
    return msg.slice(0, 300)
  } catch {
    return body.slice(0, 300)
  }
}

const RETRYABLE = new Set([408, 425, 429, 500, 502, 503, 504])

/** Read-only Magento REST + storefront GraphQL client. */
export class MagentoClient {
  private readonly fetchImpl: typeof fetch
  private readonly oauth1: OAuth1Credentials | null

  constructor(private readonly opts: MagentoClientOptions) {
    this.fetchImpl = opts.fetch ?? fetch
    const o = opts.oauth1
    this.oauth1 =
      o?.consumerKey && o.consumerSecret && o.accessTokenSecret
        ? { consumerKey: o.consumerKey, consumerSecret: o.consumerSecret, accessToken: opts.accessToken, accessTokenSecret: o.accessTokenSecret }
        : null
  }

  async get<T>(path: string, options: RequestOptions = {}): Promise<T> {
    const url = new URL(`${this.opts.baseUrl}/rest/${options.store ?? 'all'}${path}`)
    for (const [k, v] of Object.entries(options.query ?? {})) url.searchParams.set(k, String(v))
    if (options.fields) url.searchParams.set('fields', options.fields)
    const href = url.toString().replace(/\+/g, '%20')

    const text = await this.send(path, () => ({
      href,
      init: { method: 'GET', headers: { Authorization: this.authHeader('GET', href), Accept: 'application/json' } },
    }))
    return (text ? JSON.parse(text) : undefined) as T
  }

  /** One page of a `searchCriteria` list endpoint. */
  search<T>(path: string, criteria: SearchCriteria, options: Omit<RequestOptions, 'query'> = {}): Promise<SearchResult<T>> {
    return this.get<SearchResult<T>>(path, { ...options, query: toQuery(criteria) })
  }

  /**
   * Iterate every page of a list endpoint. Stops on `total_count`, not on an empty page: Magento
   * returns the *last* page again when `currentPage` overshoots.
   */
  async *paginate<T>(path: string, criteria: SearchCriteria, options: Omit<RequestOptions, 'query'> = {}): AsyncGenerator<T[]> {
    const pageSize = criteria.pageSize ?? 100
    for (let page = 1; ; page++) {
      const res = await this.search<T>(path, { ...criteria, pageSize, currentPage: page }, options)
      const items = res.items ?? []
      if (items.length) yield items
      if (items.length < pageSize || page * pageSize >= (res.total_count ?? 0)) return
    }
  }

  /**
   * Anonymous POST, used only for Magento's admin-token endpoints when checking a staff login.
   * Never sends the integration credentials and never retries (failed logins must not be replayed).
   */
  async postAnonymous<T>(path: string, body: unknown): Promise<T> {
    const href = `${this.opts.baseUrl}/rest${path}`
    const res = await this.fetchImpl(href, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
      body: JSON.stringify(body),
      signal: AbortSignal.timeout(this.opts.timeoutMs ?? 30_000),
    })
    const text = await res.text()
    if (!res.ok) throw new MagentoApiError(res.status, path, text)
    return JSON.parse(text) as T
  }

  /** Storefront GraphQL (public data; `Store` header selects the store view). */
  async graphql<T>(query: string, variables: Record<string, unknown>, store: string): Promise<T> {
    const href = `${this.opts.baseUrl}/graphql`
    const text = await this.send('/graphql', () => ({
      href,
      init: {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Accept: 'application/json', Store: store },
        body: JSON.stringify({ query, variables }),
      },
    }))
    const parsed = JSON.parse(text) as { data?: T; errors?: Array<{ message: string }> }
    if (parsed.errors?.length) throw new MagentoApiError(200, '/graphql', JSON.stringify({ message: parsed.errors.map(e => e.message).join('; ') }))
    return parsed.data as T
  }

  private authHeader(method: string, href: string): string {
    return this.oauth1 ? oauth1Header(method, href, this.oauth1) : `Bearer ${this.opts.accessToken}`
  }

  /** Sends with timeout + exponential backoff on transient failures. `build` re-signs each attempt. */
  private async send(path: string, build: () => { href: string; init: RequestInit }): Promise<string> {
    const maxRetries = this.opts.maxRetries ?? 3
    for (let attempt = 0; ; attempt++) {
      const { href, init } = build()
      const started = Date.now()
      try {
        const res = await this.fetchImpl(href, { ...init, signal: AbortSignal.timeout(this.opts.timeoutMs ?? 30_000) })
        const text = await res.text()
        this.opts.logger?.debug({ path, status: res.status, ms: Date.now() - started }, 'magento request')
        if (res.ok) return text
        const err = new MagentoApiError(res.status, path, text)
        if (!RETRYABLE.has(res.status) || attempt >= maxRetries) throw err
        await sleep(backoff(attempt, res.headers.get('retry-after')))
      } catch (err) {
        if (err instanceof MagentoApiError) throw err
        if (attempt >= maxRetries) throw err
        this.opts.logger?.warn({ path, attempt, err: (err as Error).message }, 'magento request failed, retrying')
        await sleep(backoff(attempt, null))
      }
    }
  }
}

function backoff(attempt: number, retryAfter: string | null): number {
  const hinted = retryAfter ? Number(retryAfter) * 1000 : NaN
  if (Number.isFinite(hinted) && hinted > 0) return Math.min(hinted, 60_000)
  return Math.min(500 * 2 ** attempt + Math.random() * 250, 15_000)
}
