// Mastodon protocol adapter.
//
// Implements `ProtocolAdapter` against the Mastodon ≥ 4.4 API surface
// (capability floor enforced at login). Every method asserts the active
// account is Mastodon via `mastodonActiveContext()`. See `./SPEC.md` for
// the per-operation wire shape.

import { ApiError, mastodonActiveContext } from '../api-context'
import type { Account, Status, Tag } from '../api-types'
import { nextCursorFromLowestId } from '../pagination'
import type { AccountQueryHandle } from './handle'
import { normaliseTagName } from './handle'
import { lookupCache } from './lookupCache'
import type {
  AccountLookupResult,
  HashtagFeedOptions,
  HashtagFeedPage,
  ProtocolAdapter,
  SearchAccountsOptions,
} from './types'

async function searchAccountsViaV2(
  query: string,
  opts: { resolve: boolean; limit?: number }
): Promise<Account[]> {
  const api = mastodonActiveContext()
  const res = (await api.get('api/v2/search', {
    q: query,
    type: 'accounts',
    resolve: opts.resolve ? 1 : 0,
    limit: opts.limit ?? 20,
  })) as { accounts?: Account[] }
  return Array.isArray(res?.accounts) ? res.accounts : []
}

// Mastodon's MediaAttachment.type discriminant for what we consider "media"
// in the photo-grid hashtag surface. `gifv` is short looping mp4 — counts as
// a still-frame-with-motion, same UX as a Pixelfed photo. `audio` and
// generic `video` are excluded to match Pixelfed's `pf_type === 'photo'`
// filter; if a future grid wants to surface video we can widen.
const MASTODON_PHOTO_TYPES = new Set(['image', 'gifv'])

export const mastodonAdapter: ProtocolAdapter = {
  kind: 'mastodon',

  // SPEC: Account lookup §1. Mastodon's `/api/v1/accounts/lookup` is the
  // cheap, exact, non-federating path; on 404 we fall back to
  // `/api/v2/search?resolve=true` which triggers webfinger for unknown
  // remote handles. The `lookup` call is preferred even for local handles
  // because it sidesteps any chance of search ranking returning a different
  // local account by accident.
  async lookupAccount(query: AccountQueryHandle): Promise<AccountLookupResult> {
    if (!query.localPart) {
      return { kind: 'rejected', reason: 'empty handle' }
    }

    const cacheKey = `mastodon:${query.acct.toLowerCase()}`
    const cached = lookupCache.get(cacheKey)
    if (cached) return cached

    const api = mastodonActiveContext()

    // 1. Cheap path: `lookup?acct=<acct>`. Works for both local and remote
    //    handles the server has seen before.
    try {
      const res = (await api.get('api/v1/accounts/lookup', {
        acct: query.acct,
      })) as Account
      if (res && (res as { id?: string }).id) {
        const found: AccountLookupResult = { kind: 'found', account: res }
        lookupCache.set(cacheKey, found)
        return found
      }
    } catch (e) {
      if (!(e instanceof ApiError) || (e.status !== 404 && e.status !== 422)) {
        const reason = e instanceof Error ? e.message : 'lookup failed'
        return { kind: 'rejected', reason }
      }
      // 404 / 422 → fall through to search path below.
    }

    // 2. If the handle is local-only (no host), the server has authoritative
    //    knowledge already — a `lookup` miss means the account doesn't
    //    exist. Don't burn a search/webfinger round-trip.
    if (query.isLocalToActiveAccount) {
      const miss: AccountLookupResult = { kind: 'not-found' }
      lookupCache.set(cacheKey, miss)
      return miss
    }

    // 3. Remote handle: `/api/v2/search?resolve=true` triggers webfinger +
    //    ActivityPub `inbox` discovery on the home instance. Rate-limited
    //    (≈5/min/token on mastodon.social); the lookupCache TTL keeps a
    //    rapid retap from rebanging the rate limit.
    try {
      const accounts = await searchAccountsViaV2(query.acct, {
        resolve: true,
        limit: 1,
      })
      const hit = accounts.find(
        (a) => (a.acct ?? '').toLowerCase() === query.acct.toLowerCase()
      )
      const result: AccountLookupResult = hit
        ? { kind: 'found', account: hit }
        : { kind: 'not-found' }
      lookupCache.set(cacheKey, result)
      return result
    } catch (e) {
      const reason = e instanceof Error ? e.message : 'search failed'
      return { kind: 'rejected', reason }
    }
  },

  async fetchHashtagInfo(name: string): Promise<Tag> {
    const tag = normaliseTagName(name)
    const api = mastodonActiveContext()
    return (await api.get(`api/v1/tags/${encodeURIComponent(tag)}`)) as Tag
  },

  async fetchHashtagFeed(
    name: string,
    opts: HashtagFeedOptions
  ): Promise<HashtagFeedPage> {
    const tag = normaliseTagName(name)
    const params: Record<string, string | number> = {
      // Mastodon's `/api/v1/timelines/tag/:name` defaults `only_media=false`;
      // the photo-grid surface needs media-only. Adapter sends the explicit
      // value so first-page behaviour matches Pixelfed.
      only_media: opts.onlyMedia === false ? 'false' : 'true',
      limit: 40,
    }
    if (opts.cursor.maxId) params.max_id = opts.cursor.maxId
    if (opts.cursor.minId) params.min_id = opts.cursor.minId

    const api = mastodonActiveContext()
    const page = (await api.get(
      `api/v1/timelines/tag/${encodeURIComponent(tag)}`,
      params
    )) as Array<Status & { sensitive?: boolean }>

    const filterSensitive = opts.filterSensitive !== false
    const items = (Array.isArray(page) ? page : []).filter((p) => {
      if (filterSensitive && p.sensitive) return false
      // Mastodon statuses have no `pf_type` — discriminate via MediaAttachment.type.
      return (p.media_attachments ?? []).some((m) =>
        MASTODON_PHOTO_TYPES.has(String((m as { type?: string }).type ?? ''))
      )
    })

    const lowest = nextCursorFromLowestId(items)
    return {
      items,
      nextCursor: lowest ? { maxId: lowest } : undefined,
    }
  },

  async fetchHashtagRelated(_name: string): Promise<Tag[]> {
    // Mastodon has no related-hashtag endpoint. UI surfaces degrade
    // gracefully on `[]` — the trends API is a different shape and not
    // worth coercing here.
    return []
  },

  async searchAccounts(query: string, opts: SearchAccountsOptions): Promise<Account[]> {
    if (!query || query.trim() === '') return []
    return searchAccountsViaV2(query, opts)
  },

  async getNotificationUnreadCount(): Promise<number> {
    const api = mastodonActiveContext()
    // Mastodon ≥ 4.3 has a dedicated endpoint that respects server-side
    // filters and returns `{ count }` in one round-trip. The capability
    // floor at login (≥ 4.4) guarantees this is available.
    const res = (await api.get('api/v1/notifications/unread_count')) as {
      count?: number
    } | null
    return typeof res?.count === 'number' ? res.count : 0
  },
}
