// Protocol-agnostic API surface.
//
// Endpoints in this module behave identically on Pixelfed and Mastodon (or
// the small divergences are absorbed by ApiContext's wire-level flags). The
// protocol-parity audit of these endpoints found:
//   - Status CRUD / actions (statuses/{id}, favourite, bookmark, reblog),
//     follow / block / mute, search, timelines (home / public), v2 media,
//     polls, multi-fetch — verified Mastodon-API parity.
//   - `getStatusContextById`: shape parity; the `Mastodon-Async-Refresh`
//     header path is implemented by both servers when applicable.
//   - `getAccountStatusesById`: param semantics differ — Pixelfed honours
//     `only_media`/`media_type` server-side, Mastodon ignores. The
//     client-side `isMediaPost` filter in `src/lib/queries/posts.ts:156` is
//     gated on `accountType === 'pixelfed'` to absorb this, so the existing
//     path is sound. Document explicitly so a future widen doesn't break it.
//   - `getCustomEmojis`: same shape; Pixelfed emits extra `pf_*` fields
//     consumers ignore.
// Operations that genuinely diverge (`lookupAccount`, `fetchHashtag*`,
// `getNotificationUnreadCount`, `searchAccounts`) live in the protocol
// adapter — see `src/lib/protocol/SPEC.md`.
//
// Wire-level differences common to every endpoint (the `_pe=1` query, the
// `X-PIXELFED-APP` header, the User-Agent) are gated inside `ApiContext`
// itself — none of the helpers in this file reach for them directly.
// Callers continue using `ContextForActiveAccount()`; the per-type narrowing
// helpers are reserved for `pixelfed.ts` / `mastodon.ts`.

import { objectToForm } from 'src/requests'
import { AccountStore } from 'src/state/accountStore'
import { parseLinkHeader } from 'src/utils'
import { ContextForActiveAccount } from '../api-context'
import type {
  Account,
  PaginatedStatus,
  Relationship,
  RelationshipFromFollowAPIResponse,
  Status,
  UploadV2Params,
  UploadV2ResponseOrError,
} from '../api-types'
import { type AsyncRefreshSpec, parseAsyncRefreshHeader } from '../asyncRefresh'

// --- Paginated fetch helpers (use ApiContext for auth) ---

export async function fetchPaginatedData(url: string) {
  const api = ContextForActiveAccount()
  const response = await api.fetchUrl(url)

  const data = await response.json()
  const linkHeader = response.headers.get('Link')
  const links = parseLinkHeader(linkHeader)
  // Belt-and-braces against any server that returns a `next` cursor
  // identical to the URL we just requested (pixelfed/pixelfed#5698 and
  // friends). `parseLinkHeader` already drops `?…&max_id=`-empty links;
  // this guard catches non-empty but non-advancing cursors too.
  const nextPage = links?.next === url ? undefined : links?.next

  return { data, nextPage, prevPage: links?.prev }
}

// Like `fetchPaginatedData` but also surfaces the small set of response
// headers the app acts on. Today: only `Mastodon-Async-Refresh` (used by
// `/context` to drive the federated-replies polling lifecycle in
// `useAsyncRefresh`). Add to the return shape as new consumers come online.
//
// Deliberately a sibling of `fetchPaginatedData` rather than a global widen —
// most paginated endpoints don't care about the header, and threading a
// fourth field through every queryFn return shape would force every cache
// consumer to widen its return type. Endpoints opt in by switching helper.
export async function fetchWithMeta(url: string) {
  const api = ContextForActiveAccount()
  const response = await api.fetchUrl(url)

  const data = await response.json()
  const linkHeader = response.headers.get('Link')
  const links = parseLinkHeader(linkHeader)
  const asyncRefresh = parseAsyncRefreshHeader(response.headers)
  const nextPage = links?.next === url ? undefined : links?.next

  return {
    data,
    nextPage,
    prevPage: links?.prev,
    asyncRefresh,
  }
}

export async function fetchCursorPagination(url: string) {
  const api = ContextForActiveAccount()
  const response = await api.fetchUrl(url)
  const data = await response.json()

  return {
    data: data.data,
    nextPage: data?.links?.next,
    prevPage: data?.links?.prev,
  }
}

export async function fetchData(url: string) {
  const api = ContextForActiveAccount()
  const response = await api.fetchUrl(url)
  return await response.json()
}

// --- Search ---
//
// `api/v2/search` is identical on Pixelfed and Mastodon — same query params,
// same envelope (`{ statuses, accounts, hashtags }`). The mixed-type
// search-bar query stays here because there's no per-protocol divergence to
// mediate. For *programmatic* account lookups (the "Search for X" recovery
// affordance, post-detail mention resolution, the URL-paste preview) use
// `activeProtocol().searchAccounts()` — that's the canonical entry the
// adapter owns, with rate-limit-friendly caching wired in via lookupCache.

export async function searchQuery(query: string) {
  if (!query || query.trim() === '') return []
  const api = ContextForActiveAccount()
  const data = await api.get('api/v2/search', {
    q: query,
    resolve: 1,
  })
  const mapd = [
    ...data.statuses.slice(0, 1).map((a: any) => {
      a._type = 'status'
      return a
    }),
    ...data.accounts.slice(0, 2).map((a: any) => {
      a._type = 'account'
      return a
    }),
    ...data.hashtags.slice(0, 6).map((a: any) => {
      a._type = 'hashtag'
      return a
    }),
    ...data.accounts.slice(2).map((a: any) => {
      a._type = 'account'
      return a
    }),
    ...data.hashtags.slice(6).map((a: any) => {
      a._type = 'hashtag'
      return a
    }),
  ]
  return mapd
}

export async function searchHashtagQuery(query: string) {
  if (!query || query.trim() === '') return []
  const api = ContextForActiveAccount()
  const data = await api.get('api/v2/search', {
    q: query,
    resolve: 1,
  })
  return data.hashtags
}

// Resolves a remote URL through the active instance. Returns the raw v2
// search payload — callers pick the status/account/hashtag they care about.
export async function resolveUrlViaSearch(url: string) {
  const api = ContextForActiveAccount()
  return await api.get('api/v2/search', { q: url, resolve: 1 })
}

// --- Feeds ---

export async function fetchHomeFeed({
  pageParam = false,
}: {
  pageParam?: string | false
}): Promise<PaginatedStatus> {
  if (pageParam) {
    return await fetchPaginatedData(pageParam + '&limit=20')
  }
  const instance = AccountStore.activeAccountOrThrow().instance
  const url = `https://${instance}/api/v1/timelines/home?limit=20`
  return await fetchPaginatedData(url)
}

export async function fetchNetworkFeed({
  pageParam = false,
  remoteFilter = 'all',
}: {
  pageParam?: string | false
  remoteFilter?: 'all' | 'followed'
}): Promise<PaginatedStatus> {
  if (pageParam) {
    return await fetchPaginatedData(pageParam + '&limit=20')
  }
  const instance = AccountStore.activeAccountOrThrow().instance
  const base = `https://${instance}/api/v1/timelines/public?limit=20&remote=1`
  // Halflight opt-in: when the server advertises
  // `network_timeline_followed`, the client can ask for the follower-scoped
  // slice via `remote_filter=followed`. Stock Pixelfed ignores the param.
  // Mastodon ignores it too (both are vanilla `/api/v1/timelines/public`).
  const url = remoteFilter === 'followed' ? `${base}&remote_filter=followed` : base
  return await fetchPaginatedData(url)
}

export async function getLocalTimelinePosts() {
  const api = ContextForActiveAccount()
  return (await api.get('api/v1/timelines/public', { limit: 40, local: 1 })) as Array<
    Record<string, any>
  >
}

// --- Accounts ---

// `pageUrl` is the full URL emitted by the Link header on the previous
// page (mirrored into `LinkPaginatedResponse.nextPage` by `fetchPaginatedData`).
// Use it directly — the prior `?cursor=<full-url>` concatenation produced a
// garbage URL the server ignored, so page 2+ never loaded.
export async function getAccountFollowers(id: string, pageUrl: string) {
  if (pageUrl) return await fetchPaginatedData(pageUrl)
  const instance = AccountStore.activeAccountOrThrow().instance
  return await fetchPaginatedData(
    `https://${instance}/api/v1/accounts/${id}/followers?limit=20`
  )
}

export async function getAccountFollowing(id: string, pageUrl: string) {
  if (pageUrl) return await fetchPaginatedData(pageUrl)
  const instance = AccountStore.activeAccountOrThrow().instance
  return await fetchPaginatedData(
    `https://${instance}/api/v1/accounts/${id}/following?limit=20`
  )
}

export async function getStatusById(id: string) {
  const api = ContextForActiveAccount()
  return await api.get(`api/v1/statuses/${id}`)
}

export async function getAccountById(id: string) {
  const api = ContextForActiveAccount()
  return await api.get(`api/v1/accounts/${id}`)
}

export async function followAccountById(
  id: string
): Promise<RelationshipFromFollowAPIResponse> {
  const api = ContextForActiveAccount()
  return await api.post(`api/v1/accounts/${id}/follow`)
}

export async function unfollowAccountById(
  id: string
): Promise<RelationshipFromFollowAPIResponse> {
  const api = ContextForActiveAccount()
  return await api.post(`api/v1/accounts/${id}/unfollow`)
}

interface getAccountStatusesByIdParameters {
  only_media?: true
  pinned?: true
  exclude_replies?: true
  media_type?: 'photo' | 'video'
  limit?: number
  max_id?: string | number
  since_id?: string | number
  min_id?: string | number
}

export async function getAccountStatusesById(
  id: string,
  parameters: getAccountStatusesByIdParameters
): Promise<Status[]> {
  const api = ContextForActiveAccount()
  return await api.get(`api/v1/accounts/${id}/statuses`, {
    ...parameters,
  })
}

// --- Hashtags ---
//
// `getHashtagByName`, `getHashtagByNameFeed`, `getHashtagRelated` now live
// behind the protocol adapter — Pixelfed and Mastodon differ on the photo
// filter (`pf_type === 'photo'` vs. `media_attachments[].type`), the
// `only_media` default, and the pagination invariants. Adapter at
// `src/lib/protocol/{pixelfedAdapter,mastodonAdapter}.ts`; call sites use
// `activeProtocol().fetchHashtagInfo / .fetchHashtagFeed / .fetchHashtagRelated`.

// --- Status details ---

// Wire envelope for `/api/v1/statuses/{id}/context`. Mirrors the Mastodon
// shape verbatim. On Halflight-patched Pixelfed servers (gated client-side
// via `useRecursiveThreadContext()`) the server walks the full ancestor
// chain (up to ANCESTORS_LIMIT=40) and the full descendants subtree in DFS
// preorder with self-reply promotion (DESCENDANTS_LIMIT=60,
// DESCENDANTS_DEPTH_LIMIT=20, CONTEXT_LIMIT=4096). Upstream Pixelfed walks
// only one level on each side; the "Show full conversation" affordance
// covers the gap.
//
// `asyncRefresh` is set when the server has dispatched an out-of-band
// `FetchAllRepliesJob` against a remote anchor (passed the 5-min initial
// wait + 15-min cooldown windows). The post screen polls `/context` on the
// suggested cadence via `useAsyncRefresh` and refetches when the header
// drops off — which is the canonical completion signal (Halflight audit
// M6: header is only emitted while the job is `running`).
export type StatusContextResponse = {
  ancestors: Status[]
  descendants: Status[]
  asyncRefresh: AsyncRefreshSpec | null
}

export async function getStatusContextById(id: string): Promise<StatusContextResponse> {
  const instance = AccountStore.activeAccountOrThrow().instance
  const url = `https://${instance}/api/v1/statuses/${id}/context`
  const res = await fetchWithMeta(url)
  return {
    ancestors: (res.data?.ancestors ?? []) as Status[],
    descendants: (res.data?.descendants ?? []) as Status[],
    asyncRefresh: res.asyncRefresh,
  }
}

// Mastodon 4.4+ read / 4.5+ author. Pixelfed exposes the same `/quotes`
// shape under its trifecta build.
export async function getStatusQuotes(id: string, page: string | null) {
  const instance = AccountStore.activeAccountOrThrow().instance
  const url = page
    ? `${page}`
    : `https://${instance}/api/v1/statuses/${id}/quotes?limit=20`
  return await fetchPaginatedData(url)
}

export async function revokeQuote(statusId: string, quotingId: string) {
  const api = ContextForActiveAccount()
  return await api.delete(`api/v1/statuses/${statusId}/quotes/${quotingId}`)
}

// --- Status interactions ---

export async function getStatusLikes(id: string, pageUrl: string) {
  if (pageUrl) return await fetchPaginatedData(pageUrl)
  const instance = AccountStore.activeAccountOrThrow().instance
  return await fetchPaginatedData(
    `https://${instance}/api/v1/statuses/${id}/favourited_by?limit=20`
  )
}

export async function getStatusReblogs(id: string, pageUrl: string) {
  if (pageUrl) return await fetchPaginatedData(pageUrl)
  const instance = AccountStore.activeAccountOrThrow().instance
  return await fetchPaginatedData(
    `https://${instance}/api/v1/statuses/${id}/reblogged_by?limit=20`
  )
}

export async function getAccountRelationship(id: string): Promise<Relationship> {
  const api = ContextForActiveAccount()
  const res = await api.get('api/v1/accounts/relationships', {
    'id[]': id,
  })
  return res[0]
}

// --- Post actions ---

export async function postComment({
  postId,
  commentText,
  scope = 'public',
  cw = false,
}: {
  postId: string
  commentText: string
  scope?: string
  cw?: boolean
}) {
  const api = ContextForActiveAccount()
  return await api.post('api/v1/statuses', {
    in_reply_to_id: postId,
    status: commentText,
    visibility: scope,
    sensitive: cw,
  })
}

// Idempotency-Key on like/reblog (and bookmark below) lets the server
// dedupe a double-tap during high latency — without it, a quick retry
// from the React Query mutation layer can fire the toggle twice and the
// authoritative count bounces back to the optimistic value via refetch.
export async function likeStatus({ id }: { id: string }) {
  const api = ContextForActiveAccount()
  return await api.postIdempotent(`api/v1/statuses/${id}/favourite`)
}

export async function unlikeStatus({ id }: { id: string }) {
  const api = ContextForActiveAccount()
  return await api.postIdempotent(`api/v1/statuses/${id}/unfavourite`)
}

export async function reblogStatus({ id }: { id: string }) {
  const api = ContextForActiveAccount()
  return await api.postIdempotent(`api/v1/statuses/${id}/reblog`)
}

export async function unreblogStatus({ id }: { id: string }) {
  const api = ContextForActiveAccount()
  return await api.postIdempotent(`api/v1/statuses/${id}/unreblog`)
}

export async function deleteStatus({ id }: { id: string }) {
  const api = ContextForActiveAccount()
  return await api.delete(`api/v1/statuses/${id}`)
}

export async function deleteStatusV1(id: string) {
  const api = ContextForActiveAccount()
  return await api.delete(`api/v1/statuses/${id}`)
}

// --- Mutes / Blocks ---

export async function getMutes() {
  const api = ContextForActiveAccount()
  return await api.get('api/v1/mutes')
}

export async function muteProfileById(id: string) {
  const api = ContextForActiveAccount()
  return await api.post(`api/v1/accounts/${id}/mute`)
}

export async function unmuteProfileById(id: string) {
  const api = ContextForActiveAccount()
  return await api.post(`api/v1/accounts/${id}/unmute`)
}

export async function getBlocks() {
  const api = ContextForActiveAccount()
  return await api.get('api/v1/blocks')
}

export async function blockProfileById(id: string) {
  const api = ContextForActiveAccount()
  return await api.post(`api/v1/accounts/${id}/block`)
}

export async function unblockProfileById(id: string) {
  const api = ContextForActiveAccount()
  return await api.post(`api/v1/accounts/${id}/unblock`)
}

// --- Tags ---

export async function getFollowedTags() {
  const api = ContextForActiveAccount()
  return await api.get('api/v1/followed_tags')
}

export async function followHashtag(id: string) {
  const api = ContextForActiveAccount()
  return await api.post(`api/v1/tags/${id}/follow`)
}

export async function unfollowHashtag(id: string) {
  const api = ContextForActiveAccount()
  return await api.post(`api/v1/tags/${id}/unfollow`)
}

// --- Domain blocks (Mastodon-only on the wire, but Pixelfed exposes the
// same path; calling on Pixelfed is harmless / no-op-ish). ---

export async function getDomainBlocks() {
  const api = ContextForActiveAccount()
  return await api.get('api/v1/domain_blocks')
}

// --- Self account ---

export async function getSelfAccount() {
  const api = ContextForActiveAccount()
  return await api.get('api/v1/accounts/verify_credentials')
}

// `update_credentials` accepts the full set of profile fields (Pixelfed +
// Mastodon) plus an optional avatar file. We send everything as
// `multipart/form-data` because it's the canonical encoding documented by
// Mastodon for this endpoint and it lets the avatar and the text fields ride
// in one request — no second `updateAvatar` helper needed. Booleans are
// serialised as `'true'` / `'false'` strings (FormData stringifies values
// anyway; doing it explicitly keeps the wire shape readable). The avatar
// field is appended as the flat `{ uri, type, name }` shape so RN's FormData
// polyfill recognises it as a file — wrapping it in `{ payload: ... }`
// breaks file detection and the value is serialised as `[object Object]`.
export type UpdateCredentialsInput = {
  // Either platform
  display_name?: string
  note?: string
  website?: string
  locked?: boolean
  avatar?: { uri: string; type: string | null; name: string }
  // Mastodon-only
  discoverable?: boolean
  hide_collections?: boolean
  indexable?: boolean
  bot?: boolean
  'source[privacy]'?: 'public' | 'unlisted' | 'private' | 'direct'
  // Pixelfed-only privacy switches
  show_profile_follower_count?: boolean
  show_profile_following_count?: boolean
  is_suggestable?: boolean
  public_dm?: boolean
  crawlable?: boolean
  disable_embeds?: boolean
  show_atom?: boolean
}

function isUploadFile(
  v: unknown
): v is { uri: string; type: string | null; name: string } {
  return (
    typeof v === 'object' &&
    v !== null &&
    typeof (v as { uri?: unknown }).uri === 'string'
  )
}

export async function updateCredentials(input: UpdateCredentialsInput) {
  const api = ContextForActiveAccount()
  const form = new FormData()
  for (const [k, v] of Object.entries(input)) {
    if (v === undefined || v === null) continue
    if (isUploadFile(v)) {
      form.append(k, v as unknown as Blob)
    } else if (typeof v === 'boolean') {
      form.append(k, v ? 'true' : 'false')
    } else {
      form.append(k, String(v))
    }
  }
  return await api.postForm('api/v1/accounts/update_credentials', form)
}

export async function accountFollowRequestAccept(id: string) {
  const api = ContextForActiveAccount()
  return await api.post(`api/v1/follow_requests/${id}/authorize`)
}

export async function accountFollowRequestReject(id: string) {
  const api = ContextForActiveAccount()
  return await api.post(`api/v1/follow_requests/${id}/reject`)
}

export async function getFollowRequests() {
  const api = ContextForActiveAccount()
  return await api.get('api/v1/follow_requests')
}

// --- Media upload / status creation ---

export async function uploadMediaV2(params: UploadV2Params) {
  const api = ContextForActiveAccount()
  return (await api.postFormIdempotent(
    'api/v2/media',
    objectToForm(params)
  )) as UploadV2ResponseOrError
}

export async function postNewStatus(params: Record<string, any>) {
  const api = ContextForActiveAccount()
  return await api.postIdempotent('api/v1/statuses', params)
}

// --- Bookmarks ---

export async function postBookmark(id: string) {
  const api = ContextForActiveAccount()
  return await api.postIdempotent(`api/v1/statuses/${id}/bookmark`)
}

export async function postUnBookmark(id: string) {
  const api = ContextForActiveAccount()
  return await api.postIdempotent(`api/v1/statuses/${id}/unbookmark`)
}

// --- Status history (edit history) ---

export async function getStatusHistory(id: string) {
  const api = ContextForActiveAccount()
  return await api.get(`api/v1/statuses/${id}/history`)
}

export async function editPostMedia(id: string, description: string) {
  const api = ContextForActiveAccount()
  return await api.put(`api/v1/media/${id}`, { description })
}

export async function putEditPost(id: string, params: Record<string, any>) {
  const api = ContextForActiveAccount()
  return await api.put(`api/v1/statuses/${id}`, params)
}

// --- Self likes / bookmarks ---

export async function getSelfLikes({
  pageParam = false,
}: {
  pageParam?: string | false
}) {
  if (pageParam) {
    return await fetchPaginatedData(pageParam)
  }
  const instance = AccountStore.activeAccountOrThrow().instance
  const url = `https://${instance}/api/v1/favourites`
  return await fetchPaginatedData(url)
}

export async function getSelfBookmarks({
  pageParam = false,
}: {
  pageParam?: string | false
}) {
  if (pageParam) {
    return await fetchPaginatedData(pageParam)
  }
  const instance = AccountStore.activeAccountOrThrow().instance
  const url = `https://${instance}/api/v1/bookmarks`
  return await fetchPaginatedData(url)
}

// --- Custom emojis (works on both; per-host cache lives in `emoji-cache.ts`
// when that ships — see plan §9.10 #48). Both servers return the same
// `Array<CustomEmoji>` wire shape, so the typed helper lives here. ---

export async function getCustomEmojis(): Promise<
  Array<import('../api-types').CustomEmoji>
> {
  const api = ContextForActiveAccount()
  return (await api.get('api/v1/custom_emojis')) as Array<
    import('../api-types').CustomEmoji
  >
}

// --- Notification markers + unread count.
//
// `/api/v1/markers` is part of the canonical Mastodon API and Pixelfed
// implements the same endpoint. Used to drive the bell-badge unread count.

type NotificationsMarker = {
  last_read_id?: string
  version?: number
  updated_at?: string
} | null

export async function getNotificationMarker(): Promise<NotificationsMarker> {
  const api = ContextForActiveAccount()
  const res = await api.get('api/v1/markers', { 'timeline[]': 'notifications' })
  return (res?.notifications ?? null) as NotificationsMarker
}

// Pixelfed reads bracketed keys from query params as well as body (see
// ApiV1Controller::setMarkers); mirroring `updateCredentials` we pass the
// payload via URL-encoded query and an empty body. Mastodon accepts the
// body form too — but the query form works on both, so we keep it.
export async function setNotificationMarker(lastReadId: string) {
  const api = ContextForActiveAccount()
  const params = new URLSearchParams({
    'notifications[last_read_id]': lastReadId,
  })
  return await api.post(`api/v1/markers?${params.toString()}`)
}

// `getNotificationUnreadCount` previously lived here with an inline
// `if (account.type === 'mastodon')` branch — exactly the divergence the
// protocol adapter exists to absorb. The canonical implementation is now
// `ProtocolAdapter.getNotificationUnreadCount` (see
// `src/lib/protocol/{pixelfedAdapter,mastodonAdapter}.ts`). Call sites use
// `activeProtocol().getNotificationUnreadCount()`.

/**
 * Newest unfiltered notification id, or null when the user has no
 * notifications. Holistic Pass 4 Phase 3.2 uses this on focus to advance the
 * server-side marker without conflating with the currently-active filter
 * tab — the v1 flat list with `limit=1` is the canonical "latest id" answer
 * on both Pixelfed and Mastodon and stays stable across the v1/v2 grouped
 * fork (the v2 grouped envelope orders the same way; the first id matches).
 */
export async function getNewestNotificationId(): Promise<string | null> {
  const api = ContextForActiveAccount()
  const list = await api.get('api/v1/notifications', { limit: 1 })
  if (!Array.isArray(list) || list.length === 0) return null
  const id = list[0]?.id
  return id ? String(id) : null
}

// --- Multi-fetch hydration (Mastodon Pass §6.4) ---
//
// `GET /api/v1/accounts?id[]=…&id[]=…` and `GET /api/v1/statuses?id[]=…`
// return arrays of fully-hydrated entities in a single round-trip. The
// documented shape is Mastodon's (PR #29191, since 4.3.0); Pixelfed's
// account/status controllers honour the same `id[]` query so the helpers
// stay shared. The responses are filtered to currently-visible entities,
// so a 404 / unauthorized for individual ids does not fail the whole call —
// requested ids that don't come back are silently absent.
//
// Fewer round-trips matter most for: notification group `sample_account_ids`
// hydration (4–8 actors per group), thread/quote-chain expansion, and any
// future "rehydrate cached ids" path. The helpers exist as primitives even
// when v1 has no immediate consumer — Phase 5 promotes them to standard.

function buildIdArrayQuery(ids: readonly string[]): string {
  // URLSearchParams can hold duplicate keys, but the Hermes-targeted
  // implementation stringifies them as `id[]=a&id[]=b` only when fed via
  // `append` — `new URLSearchParams({ 'id[]': … })` would only carry the
  // last value. Build the query string explicitly so the shape is
  // unambiguous and matches the documented Mastodon parameter form.
  return ids.map((id) => `id[]=${encodeURIComponent(id)}`).join('&')
}

/**
 * Hydrate a batch of accounts in a single round-trip. Empty input returns
 * an empty array without hitting the network. Order of the returned array
 * follows the server response (Mastodon orders by `id` ascending; Pixelfed
 * preserves request order). Callers that need stable ordering should look
 * up by id from the response.
 */
export async function getAccountsByIds(ids: readonly string[]): Promise<Account[]> {
  if (ids.length === 0) return []
  const api = ContextForActiveAccount()
  return (await api.get(`api/v1/accounts?${buildIdArrayQuery(ids)}`)) as Account[]
}

/**
 * Hydrate a batch of statuses in a single round-trip. Same response-ordering
 * caveat as `getAccountsByIds`. Useful for thread/quote-chain expansion and
 * any "rehydrate from cached ids" path that would otherwise N+1.
 */
export async function getStatusesByIds(ids: readonly string[]): Promise<Status[]> {
  if (ids.length === 0) return []
  const api = ContextForActiveAccount()
  return (await api.get(`api/v1/statuses?${buildIdArrayQuery(ids)}`)) as Status[]
}

// --- Aliases for backwards compat ---

export async function favouriteStatus(id: string) {
  return likeStatus({ id })
}

export async function unfavouriteStatus(id: string) {
  return unlikeStatus({ id })
}

// --- Type re-export for callers that previously imported `Account` via api.ts ---
export type { Account }
