// Mastodon Pass capability discovery + version floor.
//
// Phase 1 declared the floor and the small probe used by `loginPreflightCheck`
// to reject below-floor Mastodon instances. Phase 2 (this module) widens that
// surface into the per-(accountType, host) capability cache the rest of the
// app reads from: status limits, media limits, the VAPID public key the
// Phase 4 push registration consumes, the streaming URL Phase 5 may consume,
// and the opaque `api_versions.mastodon` integer that gates 4.5+ opt-in
// features (quote authoring, async-refresh awareness).
//
// Cache layout: JSON `InstanceCapabilities` blobs at
// `app.capabilities.<accountType>.<host>` (see `appKeys.capabilities`).
// 24 h TTL, refetched best-effort on cold start, account switch, and app
// foreground (AuthProvider drives those events). Reads never block — when
// no cached entry exists, callers fall back to `defaultCapabilities(type)`
// (plan §9.1.11).
//
// Both `/api/v1/instance` (Pixelfed) and `/api/v2/instance` (Mastodon ≥ 4.4)
// are unauthenticated, so the fetch happens through plain `getJsonWithTimeout`
// and does not require an `ApiContext`. That also means we can warm the
// cache for non-active accounts on cold start without minting per-account
// contexts.

import { useEffect, useState } from 'react'
import { getJsonWithTimeout } from 'src/requests'
import { AccountStore, type AccountType } from 'src/state/accountStore'
import { appKeys, appStorage } from 'src/state/storage'
import type { InstanceConfiguration, InstanceV1, InstanceV2 } from './api-types'

// ---------------------------------------------------------------------------
// Mastodon API version floor (plan §1.1, §8.6).
//
// The plan picks 4.4.0 as the minimum supported Mastodon version because that
// is the first release where every best-practice surface this project adopts
// is available without fallback code (PKCE, RFC 8414 metadata discovery, the
// grouped `/api/v2/notifications` endpoint, RFC 8030 standard Web Push, the
// `Deprecation` response header, the `blur` filter action, `Status.quote`,
// password-grant removal, HTTP-basic-on-API removal).
//
// The mapping from the integer in `/api/v2/instance.api_versions.mastodon`
// to the semantic version is **deliberately not published** by Mastodon
// (https://docs.joinmastodon.org/methods/instance/#v2). The integer is opaque
// and intended for probe-then-trust capability gating, not version comparison.
// In practice we capture the integer emitted by a known-floor instance and
// use it as the lower-bound comparator. The constant below is the integer
// emitted by Mastodon 4.4.0 at the time of capture; bump it when the floor
// moves (process documented in plan §8.6 — re-probe a 4.4.0 install, edit the
// constant, ship).
//
// Capture metadata:
//   - source: mastodon.social (plan §8.6 lists it as the canonical capture
//     instance because it is always at-or-above floor; if it has drifted by
//     the time of bringup, spin up `tootsuite/mastodon:v4.4.0` locally and
//     re-capture).
//   - captured: pending — the value below is a placeholder that MUST be
//     replaced with the real captured integer during Phase 1 bringup. The
//     rest of the plan does not depend on the exact value; only on the
//     comparator semantics. See plan §8.6 for the bump procedure. Leaving
//     it deliberately conservative (1) so any 4.3+ instance that emits a
//     value satisfies the gate during bringup; the proper capture is one
//     line of replacement once a real 4.4.0 instance is reachable.
export const MASTODON_API_VERSION_FLOOR = 1

// Mastodon Pass §3.3 / §8.6: opaque integer emitted by Mastodon 4.5.0. Same
// capture-and-bump pattern as the floor — the docs do not publish a mapping,
// so we capture from a known-4.5 instance. 4.5+ unlocks quote authoring
// (`quoted_status_id` + `quote_approval_policy` on `POST /api/v1/statuses`)
// and the `Mastodon-Async-Refresh` response header. Phase 3/5 gate on this
// constant. Placeholder until first real capture; conservative high value
// avoids accidentally enabling 4.5-only writes against 4.4 servers.
export const MASTODON_API_VERSION_AT_4_5_0 = 2

// Plan §1.1: Mastodon 4.4.0 — the human-readable label used in the
// preflight rejection message. Independent of the integer above (which is
// a server-emitted opaque capability); this is the version users see.
export const MASTODON_MIN_VERSION = '4.4.0'

// Capability cache TTL — plan §9.11.51. 24 hours is short enough that an
// admin's mid-cycle bump (e.g. raising max_characters) reaches the client
// quickly, long enough that the network spend is negligible.
const CAPABILITIES_TTL_MS = 24 * 60 * 60 * 1000

// ---------------------------------------------------------------------------
// `/api/v2/instance` shape — Phase 1 read only the bits needed for the
// preflight gate. Phase 2 widens to the full capability projection declared
// in plan §3.3.

export type MastodonInstanceV2Probe = {
  api_versions?: {
    mastodon?: number
  }
}

/**
 * Hit `/api/v2/instance` and read `api_versions.mastodon`. Used by
 * `loginPreflightCheck` to enforce the Mastodon version floor before the
 * OAuth round-trip starts. Returns `null` on any failure (network, JSON
 * parse, missing field) — the caller distinguishes "couldn't probe" from
 * "below floor" because the floor check is `< floor`, and `null` cannot
 * be less-than the floor.
 *
 * 4.4+ guarantees `/api/v2/instance` is reachable even on limited-federation
 * servers (Mastodon PR #34576), so a 404 here is itself a strong signal the
 * server is below floor.
 */
export async function probeMastodonApiVersion(host: string): Promise<number | null> {
  let json: MastodonInstanceV2Probe | undefined
  try {
    const res = await getJsonWithTimeout(
      `https://${host}/api/v2/instance`,
      undefined,
      false,
      undefined,
      5000
    )
    if (!res.ok) return null
    json = (await res.json()) as MastodonInstanceV2Probe
  } catch {
    return null
  }
  const v = json?.api_versions?.mastodon
  return typeof v === 'number' ? v : null
}

/**
 * True iff the integer is at-or-above the captured floor. `null` (probe
 * failed or field missing) is treated as below-floor — Mastodon ≥ 4.3
 * always emits the field, so its absence implies a too-old build (or a
 * fork mis-reporting `software.name === 'mastodon'` without honouring the
 * v2 instance shape — also rejected, by design).
 */
export function isMastodonApiVersionAtOrAboveFloor(v: number | null): boolean {
  return v !== null && v >= MASTODON_API_VERSION_FLOOR
}

// ---------------------------------------------------------------------------
// Capability projection.
//
// One flat shape that both Pixelfed and Mastodon resolve to. Per-type
// detail (Pixelfed's compose-settings call, Mastodon's `/api/v2/instance`
// fields) is reconciled inside `fetchInstanceCapabilities`; downstream
// consumers (Phase 3 composer, Phase 4 push, Phase 5 streaming) read the
// flat fields without re-branching on type.

export type InstanceCapabilities = {
  /** Plan §1.5 #24: never hardcode 500 — instances configure this. */
  maxStatusCharacters: number
  /** Plan §1.5 #24: never hardcode 4. */
  maxMediaAttachments: number
  /** Mastodon counts every URL as this many characters regardless of length
   * (typically 23). Null on Pixelfed (raw-character counting). Plan §9.3.17. */
  charactersReservedPerUrl: number | null
  /** `Account.note` / media `description` byte cap as advertised by the
   * server. Null when the server doesn't publish a value. */
  mediaDescriptionLimit: number | null
  /** Plan §3.3 / §1.5 #21: VAPID public key advertised by the instance for
   * RFC 8030 Web Push. Mastodon 4.4+ always populates; Pixelfed does not
   * (push is via `api/v1.1/push/*` instead). Phase 4 push consumes this. */
  vapidPublicKey: string | null
  /** Mastodon's opaque `api_versions.mastodon` capability integer. Null on
   * Pixelfed. Phase 3 / 5 gate 4.5-only features on this. */
  apiVersionsMastodon: number | null
  /** Plan §1.5 #40: canonical streaming WebSocket URL. Phase 5 (post-MVP)
   * use only — captured here so the cache is one-stop. */
  streamingUrl: string | null
  /** Plan §1.5 #7 (4.5+): which timelines require auth. Mastodon-only. */
  timelinesAccess: InstanceConfiguration['timelines_access'] | null
  /** `instance.configuration.media_attachments.supported_mime_types` —
   * verbatim list of MIME types the server's upload endpoint accepts.
   * Pixelfed admins can opt into `image/webp` / `image/avif` / `image/heic`
   * via admin-settings, so this is the only reliable source of truth for
   * "can this instance accept WebP uploads?". Mastodon always lists
   * `image/webp` (stored as-is, not in `IMAGE_CONVERTIBLE_MIME_TYPES`).
   * Null means "we couldn't read the field" — callers fall back to the
   * universal JPEG-safe default. */
  supportedImageMimeTypes: string[] | null
  /** Cache freshness sentinel — `Date.now()` at the moment of the fetch. A
   * value of 0 indicates a defaults blob never persisted to disk. */
  fetchedAt: number
}

/**
 * Spec defaults — used when the cache is cold and the caller can't or
 * shouldn't block on a network round-trip (plan §9.1.11). The Mastodon
 * shape here matches the typical mastodon.social configuration; Pixelfed's
 * shape leaves URL/description limits as null because the server does not
 * publish them (compose-side limits live in `getComposeSettings()` on
 * Pixelfed and Phase 3 still consumes those there).
 */
export function defaultCapabilities(type: AccountType): InstanceCapabilities {
  return {
    maxStatusCharacters: 500,
    maxMediaAttachments: 4,
    charactersReservedPerUrl: type === 'mastodon' ? 23 : null,
    mediaDescriptionLimit: type === 'mastodon' ? 1500 : null,
    vapidPublicKey: null,
    apiVersionsMastodon: null,
    streamingUrl: null,
    timelinesAccess: null,
    supportedImageMimeTypes: null,
    fetchedAt: 0,
  }
}

// ---------------------------------------------------------------------------
// Cache I/O.

function readCache(host: string, type: AccountType): InstanceCapabilities | null {
  const raw = appStorage.getString(appKeys.capabilities(type, host))
  if (!raw) return null
  try {
    const parsed = JSON.parse(raw) as InstanceCapabilities
    if (typeof parsed.fetchedAt !== 'number') return null
    return parsed
  } catch {
    return null
  }
}

function writeCache(host: string, type: AccountType, caps: InstanceCapabilities): void {
  appStorage.set(appKeys.capabilities(type, host), JSON.stringify(caps))
}

function isStale(caps: InstanceCapabilities | null): boolean {
  if (!caps) return true
  return Date.now() - caps.fetchedAt > CAPABILITIES_TTL_MS
}

/**
 * Synchronous read. Returns the cached entry if present; otherwise the
 * `defaultCapabilities(type)` fallback so callers can rely on a stable
 * shape without null-check ceremony. Callers that want fresh data should
 * pair this with a fire-and-forget `refreshInstanceCapabilitiesIfStale`.
 */
export function getInstanceCapabilities(
  host: string,
  type: AccountType
): InstanceCapabilities {
  return readCache(host, type) ?? defaultCapabilities(type)
}

// ---------------------------------------------------------------------------
// Fetch.

async function fetchMastodonInstanceV2(host: string): Promise<InstanceV2 | null> {
  try {
    const res = await getJsonWithTimeout(
      `https://${host}/api/v2/instance`,
      undefined,
      false,
      undefined,
      5000
    )
    if (!res.ok) return null
    return (await res.json()) as InstanceV2
  } catch {
    return null
  }
}

async function fetchPixelfedInstanceV1(host: string): Promise<InstanceV1 | null> {
  try {
    const res = await getJsonWithTimeout(
      `https://${host}/api/v1/instance`,
      undefined,
      false,
      undefined,
      5000
    )
    if (!res.ok) return null
    return (await res.json()) as InstanceV1
  } catch {
    return null
  }
}

function projectSupportedImageMimeTypes(
  cfg: InstanceConfiguration | undefined
): string[] | null {
  const list = cfg?.media_attachments?.supported_mime_types
  if (!Array.isArray(list)) return null
  // Lowercase + dedupe so downstream `.includes('image/webp')` is reliable
  // regardless of the server's casing. Filter to image/* — the field also
  // carries video/audio MIMEs we don't gate uploads on here.
  const out: string[] = []
  for (const m of list) {
    if (typeof m !== 'string') continue
    const lower = m.toLowerCase()
    if (!lower.startsWith('image/')) continue
    if (!out.includes(lower)) out.push(lower)
  }
  return out.length ? out : null
}

function projectMastodonV2(payload: InstanceV2): InstanceCapabilities {
  const cfg = payload.configuration
  return {
    maxStatusCharacters: cfg?.statuses?.max_characters ?? 500,
    maxMediaAttachments: cfg?.statuses?.max_media_attachments ?? 4,
    charactersReservedPerUrl: cfg?.statuses?.characters_reserved_per_url ?? 23,
    mediaDescriptionLimit: cfg?.media_attachments?.description_limit ?? null,
    vapidPublicKey: cfg?.vapid?.public_key ?? null,
    apiVersionsMastodon:
      typeof payload.api_versions?.mastodon === 'number'
        ? payload.api_versions.mastodon
        : null,
    streamingUrl: cfg?.urls?.streaming ?? null,
    timelinesAccess: cfg?.timelines_access ?? null,
    supportedImageMimeTypes: projectSupportedImageMimeTypes(cfg),
    fetchedAt: Date.now(),
  }
}

function projectPixelfedV1(payload: InstanceV1): InstanceCapabilities {
  const cfg = payload.configuration
  return {
    maxStatusCharacters: cfg?.statuses?.max_characters ?? 500,
    maxMediaAttachments: cfg?.statuses?.max_media_attachments ?? 4,
    // Pixelfed servers don't publish URL-character reservation; Pixelfed
    // counts raw characters, so leave null and let `countStatusCharacters`
    // (plan §9.3.17) decide what to do.
    charactersReservedPerUrl: null,
    mediaDescriptionLimit: null,
    // Pixelfed delivers push via its own `api/v1.1/push/*` flow — no VAPID
    // key on the v1 instance payload.
    vapidPublicKey: null,
    apiVersionsMastodon: null,
    streamingUrl: null,
    timelinesAccess: null,
    supportedImageMimeTypes: projectSupportedImageMimeTypes(cfg),
    fetchedAt: Date.now(),
  }
}

/**
 * Best-effort capability fetch. Resolves `null` on any error — the caller
 * keeps the existing cached entry (or the in-memory defaults) and tries
 * again on the next refresh tick.
 *
 * Does NOT throw. Capability detection sits on the side of "nice to have
 * fresh"; a network outage must not break compose, push, or anything else
 * downstream.
 */
export async function fetchInstanceCapabilities(
  host: string,
  type: AccountType
): Promise<InstanceCapabilities | null> {
  if (type === 'mastodon') {
    const payload = await fetchMastodonInstanceV2(host)
    if (!payload) return null
    const caps = projectMastodonV2(payload)
    writeCache(host, type, caps)
    return caps
  }
  // Pixelfed
  const payload = await fetchPixelfedInstanceV1(host)
  if (!payload) return null
  const caps = projectPixelfedV1(payload)
  writeCache(host, type, caps)
  return caps
}

/**
 * Background refresh hook. Called from cold start, account switch, and
 * AppState `change → 'active'` transitions in AuthProvider. Skips the
 * round-trip if the cached entry is still fresh.
 */
export async function refreshInstanceCapabilitiesIfStale(
  host: string,
  type: AccountType
): Promise<InstanceCapabilities | null> {
  if (!isStale(readCache(host, type))) return null
  return await fetchInstanceCapabilities(host, type)
}

/**
 * Background refresh tied to the registry's active account. Fetches fresh
 * capabilities when stale and patches the active `AccountSummary` so the
 * fast-path `apiVersionsMastodon` gating field stays aligned with the cache.
 * Best-effort throughout — never throws, no-ops when no account is active.
 */
export async function refreshActiveAccountCapabilities(): Promise<void> {
  const account = AccountStore.activeAccount()
  if (!account) return
  const fresh = await refreshInstanceCapabilitiesIfStale(account.instance, account.type)
  if (!fresh) return
  if (
    account.type === 'mastodon' &&
    fresh.apiVersionsMastodon !== null &&
    fresh.apiVersionsMastodon !== account.apiVersionsMastodon
  ) {
    AccountStore.patch(account.accountId, {
      apiVersionsMastodon: fresh.apiVersionsMastodon,
    })
  }
}

/**
 * Backfill capability cache for every signed-in account.
 *
 * The active-account refresh only warms one `(type, host)` slot. The first
 * time the user switches to a previously-cold account, every `useInstanceCapabilities`
 * read returns the protocol's default fallback until a fetch lands.
 * Pre-fetching the rest in the background on cold start eliminates that
 * first-render gap. Per-`(type, host)` cache is deduped — multiple accounts
 * on the same instance share a single fetch.
 *
 * Best-effort throughout — never throws, never blocks. Designed to run from
 * `_layout.tsx`'s boot effects after the active account's refresh has been
 * dispatched.
 */
export async function refreshAllAccountCapabilities(): Promise<void> {
  const accounts = AccountStore.list()
  if (accounts.length <= 1) return
  // Dedupe by (type, host) — two accounts on the same instance only need
  // one round-trip. The cache write notifies both via the appStorage
  // listener so consumers re-read on settle.
  const seen = new Set<string>()
  for (const account of accounts) {
    const key = `${account.type}::${account.instance.toLowerCase()}`
    if (seen.has(key)) continue
    seen.add(key)
    // Fire-and-forget; awaiting sequentially would slow boot for users with
    // many accounts and there's no ordering requirement.
    void refreshInstanceCapabilitiesIfStale(account.instance, account.type).catch(() => {
      /* best-effort */
    })
  }
}

// ---------------------------------------------------------------------------
// Hook for components.

/**
 * Read the active account's capability cache reactively. Returns the cached
 * entry if present; otherwise the `defaultCapabilities(type)` fallback. Re-
 * renders on:
 *   - active-account changes (different host or type → different cache key)
 *   - the corresponding cache key being written (refresh after fetch)
 *
 * Components that don't have a specific account in mind read this; query
 * functions and other non-React paths use `getInstanceCapabilities()`
 * synchronously and let the next render pick up the updated value.
 */
export function useInstanceCapabilities(): InstanceCapabilities {
  const read = (): InstanceCapabilities => {
    const a = AccountStore.activeAccount()
    if (!a) {
      // Pre-auth surfaces shouldn't be calling this hook, but if they do
      // (defensive) the Mastodon shape is the more conservative default.
      return defaultCapabilities('mastodon')
    }
    return getInstanceCapabilities(a.instance, a.type)
  }

  const [caps, setCaps] = useState<InstanceCapabilities>(read)

  useEffect(() => {
    const reread = () => setCaps(read())
    // Cache writes on the active account's (type, host) pair → re-read.
    const capsListener = appStorage.addOnValueChangedListener((changed) => {
      const a = AccountStore.activeAccount()
      if (!a) return
      if (changed === appKeys.capabilities(a.type, a.instance)) reread()
    })
    // Active-account flips → re-key against the new account's cache.
    const accountListener = AccountStore.subscribe(reread)
    return () => {
      capsListener.remove()
      accountListener()
    }
  }, [])

  return caps
}
