import type {ApiErrorCode} from '@crumbs/core/schemas'

export class ApiError extends Error {
  constructor(
    readonly status: number,
    readonly code?: string,
  ) {
    super(code ?? `HTTP ${status}`)
    this.name = 'ApiError'
  }
}

// reads a non-ok response body for its error code (plain text or {error})
export const toApiError = async (res: {
  status: number
  text: () => Promise<string>
}): Promise<ApiError> => {
  const text = await res.text().catch(() => '')
  let code = text
  try {
    const parsed = JSON.parse(text)
    if (typeof parsed?.error === 'string') code = parsed.error
  } catch {}
  return new ApiError(res.status, code || undefined)
}

type ResponseLike = {
  ok: boolean
  status: number
  text: () => Promise<string>
  json: () => Promise<unknown>
}

// resolve an rpc call to its ok-branch json, or throw a coded ApiError read
// from the body — the one path from a response to data, so every failure
// carries its code and cleanError's friendly copy can trigger
export const unwrap = async <T extends ResponseLike>(
  res: T | Promise<T>,
): Promise<Awaited<ReturnType<Exclude<T, {ok: false}>['json']>>> => {
  const resolved = await res
  if (!resolved.ok) throw await toApiError(resolved)
  return (await resolved.json()) as Awaited<
    ReturnType<Exclude<T, {ok: false}>['json']>
  >
}

// whether an error is a coded api failure with one of the given statuses —
// the catch-site test for "this outcome is terminal, not a retryable failure"
export const isApiStatus = (
  err: unknown,
  ...statuses: number[]
): err is ApiError =>
  err instanceof ApiError && statuses.includes(err.status)

// run a replayed write whose given status means "already done" (a delete that
// 404s, an add that 409s): the tolerated outcome resolves as success — false —
// so the queued write is dropped, never retried; anything else propagates
export const tolerateStatus = async (
  status: number,
  run: () => Promise<unknown>,
): Promise<boolean> => {
  try {
    await run()
    return true
  } catch (err) {
    if (!isApiStatus(err, status)) throw err
    return false
  }
}

const GENERIC_COPY = 'Something went wrong. Try again.'

const NETWORK_COPY =
  'You appear to be offline. Check your connection and try again.'

// keys are checked against the api's error-code vocabulary, while the wire
// code stays a plain string lookup (unknown codes fall through to generic)
const FRIENDLY: Partial<Record<string, string>> = {
  not_friends: 'Only friends can see this.',
  // dead-letter surfaces show these where the user decides retry-vs-discard
  handle_taken: 'That handle is already taken.',
  id_taken: 'This change conflicts with existing data.',
  invalid_llm_key:
    'Your API key was rejected. Update it in settings.',
  llm_rate_limited:
    'The service is busy right now. Try again in a moment.',
  llm_unavailable:
    'The service is busy right now. Try again in a moment.',
  image_too_large: 'That screengrab is too large to read.',
} satisfies Partial<Record<ApiErrorCode, string>>

// a request that never reached a server: fetch rejects with a TypeError
// offline. the outbox reads this as "no verdict — wait for reconnect", never
// as a terminal failure, so an offline mutation is never dropped
export const isNetworkError = (err: unknown): boolean =>
  err instanceof TypeError &&
  /network request failed|failed to fetch/i.test(err.message)

export const cleanError = (
  err: unknown,
  fallback: string = GENERIC_COPY,
): string => {
  if (isNetworkError(err)) return NETWORK_COPY
  if (err instanceof ApiError && err.code)
    return FRIENDLY[err.code] ?? fallback
  return fallback
}
