// Mastodon Pass §3.1: Mastodon-only API surface.
//
// Phase 2 landed this module with a small surface — capability fetch and the
// shared-shape custom-emojis helper typed against the Mastodon response
// shape. Phase 4 fills in Mastodon push (`POST /api/v1/push/subscription`,
// RFC 8030 Web Push) and the v2 grouped notifications path. Phase 5 may add
// markers / streaming / multi-fetch hydration helpers.
//
// Helpers in this file go through `mastodonActiveContext()` so a Pixelfed
// active account hitting any Mastodon-only path fails loudly rather than
// silently 404-ing.

import { AccountStore } from 'src/state/accountStore'
import { ApiError, mastodonActiveContext } from '../api-context'
import type {
  Account,
  InstanceV2,
  PaginatedStatus,
  Relationship,
  Status,
} from '../api-types'
import type { LinkPaginatedResponse } from '../pagination'
import { fetchPaginatedData } from './shared'

/**
 * `GET /api/v2/instance` — capability discovery. Mastodon ≥ 4.4 guarantees
 * this endpoint is reachable even on limited-federation servers (PR #34576).
 *
 * The same payload also flows through `src/lib/capabilities.ts` via an
 * unauthenticated `getJsonWithTimeout` — that path warms the cache for
 * sign-in (no `ApiContext` exists yet) and for accounts whose access token
 * is unavailable. This authenticated variant exists for callers already in
 * a Mastodon context that want a deliberately-fresh read; today there are
 * none, but Phase 5 streaming setup may want one.
 */
export async function getInstanceV2(): Promise<InstanceV2> {
  const api = mastodonActiveContext()
  return (await api.get('api/v2/instance')) as InstanceV2
}

// `getCustomEmojis` lives in `./shared.ts` — both servers return the same
// `Array<CustomEmoji>` wire shape, so a single typed helper there avoids
// duplication. Plan §9.10 #48 wants a per-host emoji cache
// (`src/lib/emoji-cache.ts`) to land in Phase 2; deferred to a follow-up
// since it is orthogonal to the type-aware request layer this phase
// delivers, and the existing EmojiText primitive already renders against
// the raw response.

// ---------------------------------------------------------------------------
// Notifications — Phase 4 §5.1.
//
// Mastodon ≥ 4.4 always exposes `/api/v2/notifications` (grouped, since
// 4.3.0); the version floor in `capabilities.ts` removes any need for a
// v1 fallback path. The Pixelfed-shaped fetcher in `pixelfed.ts` keeps the
// `types[]` filter and the `_pe=1` extended-response opt-in (the latter
// handled centrally by `ApiContext` for Pixelfed accounts); the Mastodon
// version below sticks to the documented Mastodon parameters per plan
// §5.1 / §1.5 #16–#20:
//
//   - `grouped_types[]=favourite&grouped_types[]=follow
//      &grouped_types[]=reblog&grouped_types[]=admin.sign_up`
//     mirrors the server-side default grouping set; passing it explicitly
//     keeps us robust if a future server expands the default.
//   - `expand_accounts=partial_avatars` keeps the response payload small
//     for grouped rows — full account objects we already have cached.
//   - **`exclude_types[]`, not `types[]`** for the per-tab filter. `types[]`
//     is non-standard for Mastodon's grouped path; the documented filter is
//     `exclude_types[]`. The mapping inverts the Pixelfed `types[]=<x>`
//     selection: tab=likes ⇒ exclude every type *except* `favourite`.
//
// The render layer (`NotificationGroup`, `RenderNotificationItem`) reads
// `notification_groups` / `notifications` / `accounts` / `statuses` off the
// response. Mastodon's grouped v2 returns the same shape, so no per-type
// branch is needed downstream.

// Mastodon's v2 notification type catalogue (4.4 floor + 4.5-only). The
// Pixelfed-only types (`event:*`, `story:*`, `like` in v1) are intentionally
// absent — Mastodon servers don't emit them, so they would never appear in
// a notifications response from a Mastodon context.
const MASTODON_NOTIFICATION_TYPES = [
  'mention',
  'status',
  'reblog',
  'follow',
  'follow_request',
  'favourite',
  'poll',
  'update',
  'admin.sign_up',
  'admin.report',
  'severed_relationships',
  'moderation_warning',
  'quote',
  'quoted_update',
] as const

// UI tab → the single Mastodon notification type to keep. The set difference
// against `MASTODON_NOTIFICATION_TYPES` is what we send as `exclude_types[]`.
const TAB_TO_KEPT_TYPE: Record<string, string | null> = {
  all: null,
  likes: 'favourite',
  follows: 'follow',
  mentions: 'mention',
  reblogs: 'reblog',
}

function excludeParamsForTab(tab: string): string {
  const kept = TAB_TO_KEPT_TYPE[tab]
  if (!kept) return ''
  return MASTODON_NOTIFICATION_TYPES.filter((t) => t !== kept)
    .map((t) => `exclude_types[]=${encodeURIComponent(t)}`)
    .join('&')
}

const GROUPED_TYPES = ['favourite', 'follow', 'reblog', 'admin.sign_up']
const GROUPED_PARAM = GROUPED_TYPES.map((t) => `grouped_types[]=${t}`).join('&')

export async function fetchNotificationsV2Mastodon({
  tab,
  pageParam = false,
}: {
  tab: string
  pageParam?: string | false
}) {
  if (pageParam) {
    return await fetchPaginatedData(pageParam)
  }
  // Asserts the active account is Mastodon — same belt-and-braces pattern as
  // `pixelfedActiveContext()` in pixelfed.ts. Phase 4 routes Mastodon
  // accounts to this fetcher, but the assertion is the seatbelt for any
  // accidental cross-type call site.
  mastodonActiveContext()
  const instance = AccountStore.activeAccountOrThrow().instance
  const exclude = excludeParamsForTab(tab)
  const base = `https://${instance}/api/v2/notifications?${GROUPED_PARAM}&expand_accounts=partial_avatars`
  const url = exclude ? `${base}&${exclude}` : base
  return await fetchPaginatedData(url)
}

// ---------------------------------------------------------------------------
// Notification dereference — used by the cold-start / tap handler when a
// Mastodon push payload carries `notification_id` but lacks routing fields
// (status_id / account_id). Plan §5.2: a follow-up GET on the active account
// resolves what to push the user to. Returns null on failure (network,
// missing notification, foreign instance) so the tap handler can fall
// through to a no-op rather than throwing.
export async function getMastodonNotification(id: string): Promise<{
  type?: string
  status?: { id: string } | null
  account?: { id: string } | null
} | null> {
  try {
    const api = mastodonActiveContext()
    return (await api.get(`api/v1/notifications/${id}`)) as {
      type?: string
      status?: { id: string } | null
      account?: { id: string } | null
    }
  } catch (e) {
    if (e instanceof ApiError) return null
    return null
  }
}

// ---------------------------------------------------------------------------
// Lists — Mastodon follow-up plan Phase B §3.2.
//
// `GET /api/v1/lists` returns the active user's lists; `GET /api/v1/timelines/
// list/:id` returns paginated statuses for one list with the same Link-header
// shape as `/api/v1/timelines/home`. The Mastodon-active home screen swaps
// its filter pills for "All + …user's lists" and routes the timeline query
// through these helpers.

export type MastodonList = {
  id: string
  title: string
  replies_policy: 'followed' | 'list' | 'none'
  exclusive: boolean
}

export async function getMastodonLists(): Promise<MastodonList[]> {
  const api = mastodonActiveContext()
  return (await api.get('api/v1/lists')) as MastodonList[]
}

// `GET /api/v1/lists/:id/accounts` — paginated members of one list. Uses the
// same Link-header shape as every other paginated Mastodon list endpoint, so
// `fetchPaginatedData` does the heavy lifting.
export async function getMastodonListAccounts({
  listId,
  pageParam = false,
}: {
  listId: string
  pageParam?: string | false
}): Promise<LinkPaginatedResponse<Account>> {
  if (pageParam) {
    return (await fetchPaginatedData(
      pageParam + '&limit=40'
    )) as LinkPaginatedResponse<Account>
  }
  mastodonActiveContext()
  const instance = AccountStore.activeAccountOrThrow().instance
  return (await fetchPaginatedData(
    `https://${instance}/api/v1/lists/${encodeURIComponent(listId)}/accounts?limit=40`
  )) as LinkPaginatedResponse<Account>
}

// `GET /api/v1/accounts/:id/lists` — the inverse lookup: which lists is this
// account a member of? Mastodon's serializer returns the catalogue subset
// inline (no pagination), so a plain array is fine.
export async function getMastodonListsForAccount(
  accountId: string
): Promise<MastodonList[]> {
  const api = mastodonActiveContext()
  return (await api.get(
    `api/v1/accounts/${encodeURIComponent(accountId)}/lists`
  )) as MastodonList[]
}

export type MastodonListPatch = {
  title?: string
  repliesPolicy?: 'followed' | 'list' | 'none'
  exclusive?: boolean
}

export async function createMastodonList(params: {
  title: string
  repliesPolicy?: 'followed' | 'list' | 'none'
  exclusive?: boolean
}): Promise<MastodonList> {
  const api = mastodonActiveContext()
  return (await api.postIdempotent('api/v1/lists', {
    title: params.title,
    replies_policy: params.repliesPolicy ?? 'list',
    exclusive: params.exclusive ?? false,
  })) as MastodonList
}

export async function updateMastodonList(
  listId: string,
  patch: MastodonListPatch
): Promise<MastodonList> {
  const api = mastodonActiveContext()
  const body: Record<string, unknown> = {}
  if (patch.title !== undefined) body.title = patch.title
  if (patch.repliesPolicy !== undefined) body.replies_policy = patch.repliesPolicy
  if (patch.exclusive !== undefined) body.exclusive = patch.exclusive
  return (await api.put(
    `api/v1/lists/${encodeURIComponent(listId)}`,
    body
  )) as MastodonList
}

export async function deleteMastodonList(listId: string): Promise<void> {
  const api = mastodonActiveContext()
  await api.delete(`api/v1/lists/${encodeURIComponent(listId)}`)
}

export async function addMastodonListMembers(
  listId: string,
  accountIds: string[]
): Promise<void> {
  const api = mastodonActiveContext()
  await api.postIdempotent(`api/v1/lists/${encodeURIComponent(listId)}/accounts`, {
    account_ids: accountIds,
  })
}

export async function removeMastodonListMembers(
  listId: string,
  accountIds: string[]
): Promise<void> {
  const api = mastodonActiveContext()
  await api.delete(`api/v1/lists/${encodeURIComponent(listId)}/accounts`, {
    account_ids: accountIds,
  })
}

// `GET /api/v1/accounts/relationships?id[]=…` — batch relationship lookup.
// The List Organizer (Surface E) uses this for the mutual-follow star badge on
// every follow at once. The endpoint caps the page size server-side (≈80), so
// we chunk locally and merge. `ApiContext.buildUrl` only stringifies scalar
// `searchParams`, so the repeated `id[]` array is encoded into the path here
// rather than passed as an options bag.
export async function getRelationshipsBatch(
  accountIds: string[]
): Promise<Relationship[]> {
  if (accountIds.length === 0) return []
  const api = mastodonActiveContext()
  const CHUNK = 80
  const out: Relationship[] = []
  for (let i = 0; i < accountIds.length; i += CHUNK) {
    const slice = accountIds.slice(i, i + CHUNK)
    const qs = new URLSearchParams()
    for (const id of slice) qs.append('id[]', id)
    const result = (await api.get(
      `api/v1/accounts/relationships?${qs.toString()}`
    )) as Relationship[]
    if (Array.isArray(result)) out.push(...result)
  }
  return out
}

export async function fetchMastodonListTimeline({
  listId,
  pageParam = false,
}: {
  listId: string
  pageParam?: string | false
}): Promise<PaginatedStatus> {
  if (pageParam) {
    return await fetchPaginatedData(pageParam + '&limit=20')
  }
  // Asserts the active account is Mastodon — same belt-and-braces pattern as
  // the notifications fetcher. The home screen only routes here when
  // `user.type === 'mastodon'`, but the assertion makes accidental cross-type
  // call sites fail loudly.
  mastodonActiveContext()
  const instance = AccountStore.activeAccountOrThrow().instance
  const url = `https://${instance}/api/v1/timelines/list/${encodeURIComponent(listId)}?limit=20`
  return await fetchPaginatedData(url)
}

// ---------------------------------------------------------------------------
// Conversations / DMs — Mastodon follow-up plan Phase C §4.2.
//
// Mastodon's `/api/v1/conversations` returns rows whose `accounts` already
// excludes self (`AccountConversation#participant_account_ids`); the
// `last_status` is a full Status with HTML content + media. The thread is
// reconstructed via `GET /api/v1/statuses/:id/context` anchored on the
// conversation's `last_status.id`. Sending is a normal `POST /api/v1/statuses`
// with `visibility=direct` — the participants must appear as `@user@host`
// mentions in the status body or the message won't reach them.

export type MastodonConversation = {
  id: string
  unread: boolean
  // Mastodon's serializer excludes self from this array (see
  // `app/models/account_conversation.rb`); the row's "other party" is
  // `accounts[0]` for two-party threads.
  accounts: Account[]
  last_status: Status | null
}

export async function getMastodonConversations({
  pageParam = false,
}: {
  pageParam?: string | false
}): Promise<{
  data: MastodonConversation[]
  nextPage: string | null
  prevPage: string | null
}> {
  if (pageParam) {
    return (await fetchPaginatedData(pageParam)) as {
      data: MastodonConversation[]
      nextPage: string | null
      prevPage: string | null
    }
  }
  mastodonActiveContext()
  const instance = AccountStore.activeAccountOrThrow().instance
  return (await fetchPaginatedData(
    `https://${instance}/api/v1/conversations?limit=40`
  )) as {
    data: MastodonConversation[]
    nextPage: string | null
    prevPage: string | null
  }
}

export type MastodonContext = { ancestors: Status[]; descendants: Status[] }

export async function getMastodonConversationThread(lastStatusId: string): Promise<{
  context: MastodonContext
  anchor: Status
}> {
  const api = mastodonActiveContext()
  const [anchor, context] = await Promise.all([
    api.get(`api/v1/statuses/${encodeURIComponent(lastStatusId)}`) as Promise<Status>,
    api.get(
      `api/v1/statuses/${encodeURIComponent(lastStatusId)}/context`
    ) as Promise<MastodonContext>,
  ])
  return { context, anchor }
}

export async function markMastodonConversationRead(conversationId: string) {
  const api = mastodonActiveContext()
  return await api.post(`api/v1/conversations/${encodeURIComponent(conversationId)}/read`)
}

export async function sendMastodonDirectMessage(args: {
  text: string
  // Already-formatted `@user@host` mentions for every other participant.
  // Caller is responsible for excluding self (the conversation serializer
  // already strips self from `accounts[]`, so a straightforward
  // `participants.map(p => '@' + p.acct)` does the right thing).
  mentions: string[]
  inReplyToId: string | null
  spoilerText?: string | null
  sensitive?: boolean
}): Promise<Status> {
  const api = mastodonActiveContext()
  const status = `${args.mentions.join(' ')} ${args.text}`.trim()
  return (await api.postIdempotent('api/v1/statuses', {
    status,
    visibility: 'direct',
    in_reply_to_id: args.inReplyToId ?? undefined,
    sensitive: args.sensitive ? 'true' : undefined,
    spoiler_text: args.spoilerText ?? undefined,
  })) as Status
}
