// Mastodon ≥ 4.5 `Mastodon-Async-Refresh` response-header primitive.
//
// The server emits the header on a long-running task it has scheduled out-of-
// band (e.g. Halflight's federated `FetchAllRepliesJob` for `/context`,
// upstream Mastodon's hashtag-follow propagation). The header carries enough
// state for the client to poll the originating endpoint until the task
// completes — completion is signalled by the header *no longer appearing*
// on a subsequent fetch (Halflight audit M6 fix; the header is only emitted
// while the job is `running`).
//
// This module is endpoint-agnostic. The parser frozen-grammars the wire
// shape; the hook drives a polling cadence. Consumers (today: the post
// screen's `/context` query in Phase 5) supply the `onPoll` to re-issue the
// originating request and the `onComplete` to fan out cache invalidation.
//
// Header grammar (frozen contract):
//   Mastodon-Async-Refresh: id="<key>", retry=<seconds>[, result_count=<int>]

import { useEffect, useRef } from 'react'

const HEADER_NAME = 'Mastodon-Async-Refresh'

// Defensive bounds on `retry`. Mastodon and Halflight both default to 3 s;
// floor at 1 / ceil at 30 so a malformed or hostile server can't peg the
// client into a tight loop or drag a UI affordance out indefinitely.
const RETRY_FLOOR_SECONDS = 1
const RETRY_CEILING_SECONDS = 30

// Defensive cap on total polling time per (mounted, in-flight) refresh. The
// server's job has its own caps (Halflight: MAX_REPLIES=1000 / MAX_PAGES=500)
// so a real walk is bounded; this is a runaway guard against a misbehaving
// server emitting the header forever.
const DEFAULT_MAX_DURATION_MS = 90_000

export type AsyncRefreshSpec = {
  /** Opaque server key (today: `context:{statusId}:refresh`). Treat as
   *  uninterpreted — the server may sign it in future revisions. */
  id: string
  /** Suggested poll interval in seconds, clamped to [1, 30]. */
  retrySeconds: number
  /** Running-phase counter. Climbs as the server ingests rows; UIs can
   *  surface it as "loading N replies…". Null when the server hasn't seeded
   *  it (older builds, or non-counting refreshes). */
  resultCount: number | null
}

/**
 * Parse the `Mastodon-Async-Refresh` header value. Returns `null` if the
 * header is absent or malformed (missing `id`). Tolerant to attribute order
 * and lowercase header names.
 *
 * Absence on a subsequent fetch is the canonical completion signal — see
 * the Halflight audit's M6 fix. Callers do not need to track a separate
 * "finished" state; flipping `spec` to `null` on the next fetch is enough.
 */
export function parseAsyncRefreshHeader(headers: Headers): AsyncRefreshSpec | null {
  const raw = headers.get(HEADER_NAME) ?? headers.get(HEADER_NAME.toLowerCase())
  if (!raw) return null
  let id: string | null = null
  let retry = 3
  let resultCount: number | null = null
  for (const part of raw.split(',')) {
    const eq = part.indexOf('=')
    if (eq < 0) continue
    const k = part.slice(0, eq).trim()
    const v = part
      .slice(eq + 1)
      .trim()
      .replace(/^"|"$/g, '')
    if (k === 'id') {
      id = v
    } else if (k === 'retry') {
      const n = Number.parseInt(v, 10)
      if (Number.isFinite(n)) {
        retry = Math.min(RETRY_CEILING_SECONDS, Math.max(RETRY_FLOOR_SECONDS, n))
      }
    } else if (k === 'result_count') {
      const n = Number.parseInt(v, 10)
      if (Number.isFinite(n)) resultCount = n
    }
  }
  return id ? { id, retrySeconds: retry, resultCount } : null
}

type UseAsyncRefreshArgs = {
  /** The currently-known spec from the most-recent fetch's headers, or
   *  `null` when no refresh is in flight. */
  spec: AsyncRefreshSpec | null
  /** Fires every `retrySeconds` while `spec` is non-null. The caller is
   *  expected to issue the same request that produced `spec` — when the
   *  follow-up returns no header, `spec` flips to `null` and `onComplete`
   *  fires once. */
  onPoll: () => void
  /** Fires once when `spec` transitions from non-null to `null` (i.e. the
   *  server stopped emitting the header). */
  onComplete?: () => void
  /** Cap on total polling time. Default 90 s. Once exceeded, polling stops
   *  but `onComplete` does NOT fire — the spec is still considered live
   *  from the server's perspective; we just stop chasing it. */
  maxDurationMs?: number
}

/**
 * Drives polling while a `Mastodon-Async-Refresh` is in flight. Endpoint-
 * agnostic — caller decides what `onPoll` does (re-fetch the originating
 * query, refetch a peer query, …) and what `onComplete` does (invalidate
 * queries, surface a toast, …).
 *
 * `onPoll` and `onComplete` are read from a ref on every fire, so callers
 * don't have to wrap them in `useCallback` — only the spec drives the
 * effect lifecycle.
 */
export function useAsyncRefresh({
  spec,
  onPoll,
  onComplete,
  maxDurationMs = DEFAULT_MAX_DURATION_MS,
}: UseAsyncRefreshArgs): void {
  const startedAt = useRef<number | null>(null)
  const wasActive = useRef(false)
  const handlers = useRef({ onPoll, onComplete })
  handlers.current = { onPoll, onComplete }

  useEffect(() => {
    if (!spec) {
      if (wasActive.current) {
        wasActive.current = false
        startedAt.current = null
        handlers.current.onComplete?.()
      }
      return
    }
    if (!wasActive.current) {
      wasActive.current = true
      startedAt.current = Date.now()
    }
    const elapsed = Date.now() - (startedAt.current ?? Date.now())
    if (elapsed >= maxDurationMs) return
    const timer = setTimeout(() => handlers.current.onPoll(), spec.retrySeconds * 1000)
    return () => clearTimeout(timer)
    // `spec.id` + `spec.resultCount` together capture every meaningful
    // change: a new id means a new refresh started; a climbing count means
    // we're mid-walk and should keep polling.
  }, [spec?.id, spec?.resultCount, maxDurationMs])
}
