/** Directives for one cache audience (browser or CDN). Whole seconds. */
export interface CacheDirectives {
  maxAge?: number
  staleWhileRevalidate?: number
  staleIfError?: number
  sMaxage?: number
  noStore?: boolean
  noCache?: boolean
  private?: boolean
  mustRevalidate?: boolean
  immutable?: boolean
}

/** Which CDN header name carries the CDN directives. */
export type CdnProfile = 'cloudflare' | 'generic' | 'fastly'

export interface CacheHeaderInput {
  browser?: CacheDirectives
  cdn?: CacheDirectives
  profile?: CdnProfile
  tags?: string[]
}

export type CacheHeaderErrorCode =
  | 'cdn_smaxage_with_stale_while_revalidate'
  | 'private_browser_with_cdn'
  | 'invalid_cache_tag'
  | 'too_many_cache_tags'
  | 'cache_tags_too_long'
  | 'invalid_cache_directive'
  | 'invalid_cdn_profile'

export class CacheHeaderError extends Error {
  readonly code: CacheHeaderErrorCode
  readonly details: Record<string, unknown>

  constructor(code: CacheHeaderErrorCode, message: string, details: Record<string, unknown> = {}) {
    super(message)
    this.name = 'CacheHeaderError'
    this.code = code
    this.details = details
  }
}

const CACHE_TAG_MAX_COUNT = 1000
const CACHE_TAG_MAX_LENGTH = 16 * 1024

const CDN_HEADER_BY_PROFILE: Record<CdnProfile, string> = {
  cloudflare: 'Cloudflare-CDN-Cache-Control',
  generic: 'CDN-Cache-Control',
  fastly: 'Surrogate-Control',
}

export function cacheHeaders(input: CacheHeaderInput): Record<string, string> {
  const headers: Record<string, string> = {}
  const profile = input.profile ?? 'cloudflare'
  const cdnHeaderName = CDN_HEADER_BY_PROFILE[profile]
  if (cdnHeaderName === undefined) {
    throw new CacheHeaderError('invalid_cdn_profile', 'Unsupported CDN cache header profile.', { profile })
  }

  if (input.cdn?.sMaxage !== undefined && input.cdn.staleWhileRevalidate !== undefined) {
    throw new CacheHeaderError(
      'cdn_smaxage_with_stale_while_revalidate',
      'CDN s-maxage cannot be combined with stale-while-revalidate.',
      { cdn: input.cdn },
    )
  }

  if (input.cdn !== undefined && (input.browser?.private === true || input.browser?.noStore === true)) {
    throw new CacheHeaderError(
      'private_browser_with_cdn',
      'Browser private or no-store directives cannot be combined with CDN directives.',
      { browser: input.browser, cdn: input.cdn },
    )
  }

  if (input.browser !== undefined) {
    const browser = serializeDirectives(input.browser, 'browser')
    if (browser !== '') headers['Cache-Control'] = browser
  } else if (input.cdn !== undefined) {
    headers['Cache-Control'] = 'max-age=0, must-revalidate'
  }

  if (input.cdn !== undefined) {
    const cdn = serializeDirectives(input.cdn, 'cdn')
    if (cdn !== '') headers[cdnHeaderName] = cdn
  }

  if (input.tags !== undefined && input.tags.length > 0) {
    headers['Cache-Tag'] = serializeTags(input.tags)
  }

  return headers
}

export function etagFrom(hash: string, opts?: { weak?: boolean }): string {
  return `${opts?.weak === true ? 'W/' : ''}"${hash}"`
}

export function notModified(ifNoneMatch: string | null, etag: string): boolean {
  if (ifNoneMatch === null) return false
  const trimmed = ifNoneMatch.trim()
  if (trimmed === '*') return true

  const target = normalizeEtag(etag)
  return splitEtags(trimmed)
    .map((candidate) => normalizeEtag(candidate.trim()))
    .some((candidate) => candidate !== '' && candidate === target)
}

function serializeDirectives(directives: CacheDirectives, audience: 'browser' | 'cdn'): string {
  const parts: string[] = []
  if (directives.noStore === true) parts.push('no-store')
  if (directives.noCache === true) parts.push('no-cache')
  if (directives.private === true) parts.push('private')
  addSeconds(parts, 'max-age', directives.maxAge)
  if (audience === 'cdn') addSeconds(parts, 's-maxage', directives.sMaxage)
  addSeconds(parts, 'stale-while-revalidate', directives.staleWhileRevalidate)
  addSeconds(parts, 'stale-if-error', directives.staleIfError)
  if (directives.mustRevalidate === true) parts.push('must-revalidate')
  if (directives.immutable === true) parts.push('immutable')
  return parts.join(', ')
}

function addSeconds(parts: string[], name: string, value: number | undefined): void {
  if (value === undefined) return
  if (!Number.isInteger(value) || value < 0) {
    throw new CacheHeaderError('invalid_cache_directive', 'Cache directive seconds must be a non-negative integer.', {
      directive: name,
      value,
    })
  }
  parts.push(`${name}=${value}`)
}

function serializeTags(tags: string[]): string {
  if (tags.length > CACHE_TAG_MAX_COUNT) {
    throw new CacheHeaderError('too_many_cache_tags', 'Cache-Tag count exceeds the Cloudflare cap.', {
      count: tags.length,
      max: CACHE_TAG_MAX_COUNT,
    })
  }

  for (const tag of tags) {
    if (tag === '' || /\s/.test(tag)) {
      throw new CacheHeaderError('invalid_cache_tag', 'Cache tags must be non-empty and contain no spaces.', { tag })
    }
  }

  const joined = tags.join(',')
  if (joined.length > CACHE_TAG_MAX_LENGTH) {
    throw new CacheHeaderError('cache_tags_too_long', 'Cache-Tag header exceeds the Cloudflare length cap.', {
      length: joined.length,
      max: CACHE_TAG_MAX_LENGTH,
    })
  }

  return joined
}

function normalizeEtag(value: string): string {
  const withoutWeakPrefix = value.startsWith('W/') ? value.slice(2) : value
  return withoutWeakPrefix.trim()
}

function splitEtags(value: string): string[] {
  const tags: string[] = []
  let current = ''
  let inQuotes = false

  for (let index = 0; index < value.length; index += 1) {
    const char = value[index]
    if (char === '"') inQuotes = !inQuotes
    if (char === ',' && !inQuotes) {
      tags.push(current)
      current = ''
    } else {
      current += char
    }
  }

  tags.push(current)
  return tags
}
