// 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, Status } from '../api-types'
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[]
}

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
}
