// Tapestry history walk-back. `useInfiniteQuery` over canonical 30-minute
// pages — each page is a path-keyed cache hit on the server (immutable for
// past pages), so backfill across cold-starts is essentially free.

import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef, useSyncExternalStore } from 'react'
import { ContextForActiveAccount } from 'src/lib/api-context'
import { queryKeys } from 'src/lib/queries/keys'
import { AccountStore } from 'src/state/accountStore'
import {
  fetchTapestrySince,
  MAX_SINCE_MINUTES,
  TapestrySinceNotSupportedError,
  TapestrySinceTooOldError,
} from './since'
import {
  currentPage,
  formatBucket,
  nextPage,
  PAGE_MINUTES,
  parseBucket,
  previousPage,
} from './window'

type BucketRow = { bucket: string; letters: string }

type TapestryPage = {
  page: string
  rows: BucketRow[]
}

/**
 * Walks back through 30-minute pages for `instance`. The eager walker
 * (`useEagerBackfill`) covers the first 24 h; user-driven scroll
 * pagination extends further on demand up to the 30-day retention window
 * (`maxPages: 1440` = 30 d × 48 pages/d). The 24 h / 30 d split keeps
 * cold-start traffic bounded on quiet instances (see `MAX_BACKFILL_PAGES`)
 * while still letting motivated scroll-back reach the full window. Past
 * pages are served `Cache-Control: immutable, max-age=86400` from the
 * nginx fastcgi cache, so even a full walk-back is a sequence of CDN
 * hits — no PHP-FPM per request, ~5 KB worst-case wire across 1440 empty
 * pages.
 *
 * v1 walks one direction only: the live tail (`useTapestryPoll`) handles
 * "newer than the seeded current page", so `getPreviousPageParam` stays
 * undefined.
 */
export function useTapestryPages(instance: string) {
  // Subscribe to AccountStore so a sign-out triggers a re-render that flips
  // `enabled` off — without this, the hook keeps the query active across the
  // active-account flip and React Query's `queryClient.clear()` observer-
  // rewire refires the queryFn before the Tapestry screen's no-user
  // `<Redirect />` unmounts the subtree. The queryFn's own guard is the
  // belt-and-suspenders second line for any race the `enabled` flip can't
  // close.
  const activeId = useSyncExternalStore(
    AccountStore.subscribe,
    AccountStore.activeId,
    AccountStore.activeId
  )
  return useInfiniteQuery({
    queryKey: queryKeys.tapestry.pages(instance),
    initialPageParam: currentPage(),
    queryFn: async ({ pageParam }: { pageParam: string }): Promise<TapestryPage> => {
      if (!AccountStore.activeId()) return { page: pageParam, rows: [] }
      const ctx = ContextForActiveAccount()
      const res = await ctx.request(`/api/v1/tapestry/page/${pageParam}`, {
        method: 'GET',
      })
      const rows = (await res.json()) as BucketRow[]
      return { page: pageParam, rows }
    },
    getNextPageParam: (last) => previousPage(last.page),
    // Forward gap-fill via `fetchPreviousPage`. Cap at `currentPage()`
    // so the walker stops at "now" rather than emitting future page
    // params. `useTapestryGapFill` prefers the bulk `since` endpoint
    // and only falls back to this per-page walker when the server
    // hasn't deployed `since` yet.
    getPreviousPageParam: (first) => {
      const next = nextPage(first.page)
      return next <= currentPage() ? next : undefined
    },
    maxPages: 1440,
    // Past pages are immutable on the server (`max-age=86400` from the
    // nginx fastcgi cache); the current minute is handled by
    // `useTapestryPoll`, and the newest cached page's lag-versus-now is
    // closed by `useTapestryGapFill`'s single bulk `since` request on
    // mount. So none of the cached pages need to refetch on tab-switch.
    // Without this, every re-entry re-fetches all retained pages —
    // measured at 48 redundant GETs per tab return on a 24 h backfill.
    staleTime: Number.POSITIVE_INFINITY,
    enabled: instance.length > 0 && !!activeId,
  })
}

// Eager backfill ceilings. Quiet instances return mostly-empty pages
// (`TapestryStore::readRange` only emits populated buckets), so the screen
// can mount with too little content to overflow the viewport — without
// overflow there's nothing for the user to scroll, and the screen's
// onScroll-driven pagination never gets a chance to take over.
//
// Threshold semantics depend on what the consumer measures. The
// continuous-weave wall measures characters (`MIN_LENGTH_FOR_VIEWPORT`);
// the legacy per-row layout measured rendered rows (`MIN_ROWS_FOR_VIEWPORT`).
// Either way, the loop stops once the consumer reports enough content
// for the user to scroll, and the scroll-driven pagination takes over.
//
// `MAX_BACKFILL_PAGES = 48` caps the eager walker at 24 h of history (48 ×
// 30-minute pages). On a quiet instance whose oldest populated bucket sits
// further back than 24 h, the wall renders what fits in the last day and
// the user reaches older content by scrolling (onScroll-driven pagination
// is uncapped by this constant — `maxPages: 1440` on the underlying query
// is the real ceiling). The previous 1440-page eager ceiling let a quiet
// instance accumulate ~13 days of pages across tab re-entries (cache
// persists; each remount continues the walk from where the prior one
// stopped) — measured at 603 page GETs in 30 min during a §6.5 session,
// tripping the server's 600/hour rate limit.
export const MIN_ROWS_FOR_VIEWPORT = 30
// Rough viewport-fill estimate for the wall: ~20 chars per minute on a
// modestly active instance × ~30 minutes visible. Quiet instances will
// eagerly walk further until the wall overflows or the page cap fires.
export const MIN_LENGTH_FOR_VIEWPORT = 600
export const MAX_BACKFILL_PAGES = 48

type TapestryPagesQuery = ReturnType<typeof useTapestryPages>

/**
 * Cold-launch forward gap-fill cap. Matches the server's 48 h
 * `MAX_SINCE_MINUTES` and the watch's 48 h staleness threshold — a
 * cache older than this is too stale to be worth catching up by
 * gap-fill, so we throw it away and let the eager backfill restart
 * from `currentPage`.
 */
export const MAX_GAP_PAGES = MAX_SINCE_MINUTES / PAGE_MINUTES // 96

/**
 * Cold-launch gap-fill. On mount (and after a cache rehydration that
 * leaves the persisted `pages[0]` behind the wall clock), close the
 * gap between the newest cached bucket and `now` in a single bulk
 * `since` request. Falls back to the per-page forward walk (via
 * `fetchPreviousPage`) when the server's `since` route isn't yet
 * deployed — and drops the cache entirely when the gap exceeds the
 * 48 h cap.
 *
 * Idempotent across re-renders: a ref-guard prevents re-running for
 * the same `(host, newestCachedBucket)` pair. Account / host switches
 * reset the guard so a new instance gets its own gap-fill pass.
 */
export function useTapestryGapFill(query: TapestryPagesQuery, host: string) {
  const queryClient = useQueryClient()
  const ranForRef = useRef<string | null>(null)

  useEffect(() => {
    if (!host) return
    if (!query.isSuccess) return

    const pages = query.data?.pages
    if (!pages || pages.length === 0) return

    // Newest cached bucket across all pages. Pages are sorted newest-
    // first by the backward walker (`getNextPageParam = previousPage`),
    // so `pages[0]` is the freshest — but its rows aren't required to
    // be sorted, so scan.
    let newestBucket: string | null = null
    for (const row of pages[0].rows) {
      if (!newestBucket || row.bucket > newestBucket) newestBucket = row.bucket
    }
    // Empty newest page can happen on a fresh-but-quiet instance —
    // nothing to gap-fill against.
    if (!newestBucket) {
      ranForRef.current = `${host}:empty`
      return
    }

    const ranKey = `${host}:${newestBucket}`
    if (ranForRef.current === ranKey) return
    ranForRef.current = ranKey

    const cur = currentPage()
    const newestPage = pageForBucket(newestBucket)
    if (newestPage === cur) return // already caught up

    const gapPages = pageDelta(newestPage, cur)
    if (gapPages > MAX_GAP_PAGES) {
      queryClient.removeQueries({ queryKey: queryKeys.tapestry.pages(host) })
      return
    }

    void runGapFill(queryClient, host, newestBucket, query)
  }, [host, query.isSuccess, query.data?.pages, queryClient, query])
}

/** Number of 30-minute pages between two page strings, `to - from`. */
function pageDelta(from: string, to: string): number {
  const a = parseBucket(from)
  const b = parseBucket(to)
  if (!a || !b) return Number.POSITIVE_INFINITY
  return Math.round((b.getTime() - a.getTime()) / (PAGE_MINUTES * 60_000))
}

/** Floor a 12-digit minute bucket to its 30-minute page boundary. */
function pageForBucket(bucket: string): string {
  const d = parseBucket(bucket)
  if (!d) throw new Error(`pageForBucket: invalid bucket "${bucket}"`)
  const minute = d.getUTCMinutes()
  const flooredMinute = minute - (minute % PAGE_MINUTES)
  const floored = new Date(
    Date.UTC(
      d.getUTCFullYear(),
      d.getUTCMonth(),
      d.getUTCDate(),
      d.getUTCHours(),
      flooredMinute,
      0,
      0
    )
  )
  return formatBucket(floored)
}

async function runGapFill(
  queryClient: ReturnType<typeof useQueryClient>,
  host: string,
  newestBucket: string,
  query: TapestryPagesQuery
): Promise<void> {
  let fetchedRows: BucketRow[]
  try {
    fetchedRows = await fetchTapestrySince(newestBucket)
  } catch (err) {
    if (err instanceof TapestrySinceTooOldError) {
      // Server-side cap rejects the request. The threshold-check
      // ahead of this call should have caught it, but server and
      // client clocks can disagree by a few minutes around the
      // boundary — defensively drop the cache.
      queryClient.removeQueries({ queryKey: queryKeys.tapestry.pages(host) })
      return
    }
    if (err instanceof TapestrySinceNotSupportedError) {
      // Older server. Fall back to forward page-by-page walk via
      // `fetchPreviousPage` — bounded by `MAX_GAP_PAGES` since
      // `getPreviousPageParam` returns undefined past `currentPage`.
      let walked = 0
      while (
        query.hasPreviousPage &&
        !query.isFetchingPreviousPage &&
        walked < MAX_GAP_PAGES
      ) {
        await query.fetchPreviousPage()
        walked++
      }
      return
    }
    // Other failures (network, 401, 5xx) — bail; live-tail polling
    // and the existing eager-backfill keep the screen useful.
    return
  }

  if (fetchedRows.length === 0) return
  mergeRowsIntoCache(queryClient, host, fetchedRows)
}

/**
 * Merge a flat list of bucket rows into the persisted infinite-query
 * cache for `host`. Existing pages are flattened to a deduped map by
 * bucket, new rows take precedence (they're fresher), then the result
 * is regrouped into pages and written back. Pages stay sorted newest-
 * first to keep the `getNextPageParam` walker's invariant.
 */
function mergeRowsIntoCache(
  queryClient: ReturnType<typeof useQueryClient>,
  host: string,
  newRows: BucketRow[]
): void {
  queryClient.setQueryData(
    queryKeys.tapestry.pages(host),
    (old: { pages: TapestryPage[]; pageParams: string[] } | undefined) => {
      if (!old) return old

      const byBucket = new Map<string, BucketRow>()
      for (const page of old.pages) {
        for (const row of page.rows) byBucket.set(row.bucket, row)
      }
      for (const row of newRows) byBucket.set(row.bucket, row)

      const pages = pagesFromRows(Array.from(byBucket.values()))
      return {
        pages,
        // `pageParams` must stay in sync with `pages` — React Query
        // uses it to drive the next fetch's pageParam.
        pageParams: pages.map((p) => p.page),
      }
    }
  )
}

function pagesFromRows(rows: BucketRow[]): TapestryPage[] {
  const byPage = new Map<string, BucketRow[]>()
  for (const row of rows) {
    const page = pageForBucket(row.bucket)
    const existing = byPage.get(page)
    if (existing) existing.push(row)
    else byPage.set(page, [row])
  }
  return Array.from(byPage.entries())
    .sort(([a], [b]) => (a < b ? 1 : a > b ? -1 : 0))
    .map(([page, rows]) => ({ page, rows }))
}

/**
 * Drive `fetchNextPage()` until the rendered content overflows the
 * viewport or a hard page ceiling is hit. Re-runs after each settle (the
 * deps cover `isFetchingNextPage` flipping false), so the loop walks one
 * page at a time and stops on its own when any guard fires.
 *
 * `currentSize` is the consumer's measure of "how much content is
 * rendered" — characters for the continuous-weave wall, item count for
 * legacy list shapes. `threshold` is the floor below which the eager
 * loop keeps walking (defaults to `MIN_ROWS_FOR_VIEWPORT` for the
 * legacy item-count call sites).
 */
export function useEagerBackfill(
  query: TapestryPagesQuery,
  currentSize: number,
  threshold: number = MIN_ROWS_FOR_VIEWPORT
) {
  const { isSuccess, isFetchingNextPage, hasNextPage, fetchNextPage, data } = query
  const pagesFetched = data?.pages?.length ?? 0
  useEffect(() => {
    if (!isSuccess) return
    if (currentSize >= threshold) return
    if (pagesFetched >= MAX_BACKFILL_PAGES) return
    if (isFetchingNextPage) return
    if (!hasNextPage) return
    fetchNextPage()
  }, [
    currentSize,
    threshold,
    isSuccess,
    isFetchingNextPage,
    pagesFetched,
    hasNextPage,
    fetchNextPage,
  ])
}
