// Pixelfed protocol adapter.
//
// Implements `ProtocolAdapter` against the Pixelfed v1.1 / v1 / v2 API
// surface. Every method asserts the active account is Pixelfed via
// `pixelfedActiveContext()` — the runtime firewall is the backstop for any
// path that slips through. See `./SPEC.md` for the per-operation wire shape.

import { ApiError, pixelfedActiveContext } 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 = pixelfedActiveContext()
  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 : []
}

export const pixelfedAdapter: ProtocolAdapter = {
  kind: 'pixelfed',

  // SPEC: Account lookup §1. Pixelfed has no `/api/v1/accounts/lookup?acct=`
  // endpoint, so the local-handle path uses `/api/v1.1/accounts/username/{u}`
  // (which only resolves local handles). Remote handles fall through to
  // `/api/v2/search?resolve=1`, which on Pixelfed federates via webfinger.
  async lookupAccount(query: AccountQueryHandle): Promise<AccountLookupResult> {
    if (!query.localPart) {
      return { kind: 'rejected', reason: 'empty handle' }
    }

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

    try {
      if (query.isLocalToActiveAccount) {
        const api = pixelfedActiveContext()
        const res = await api.get(
          `api/v1.1/accounts/username/${encodeURIComponent(query.localPart)}`
        )
        if (Array.isArray(res) && res.length === 0) {
          const miss: AccountLookupResult = { kind: 'not-found' }
          lookupCache.set(cacheKey, miss)
          return miss
        }
        const found: AccountLookupResult = { kind: 'found', account: res as Account }
        lookupCache.set(cacheKey, found)
        return found
      }

      // Remote handle: fall through to search. `resolve=true` triggers
      // webfinger so an uncached remote handle resolves on first call.
      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) {
      if (e instanceof ApiError && (e.status === 404 || e.status === 422)) {
        return { kind: 'not-found' }
      }
      // Network / 5xx — surface as `rejected` so the caller can distinguish
      // "we got an answer and it was empty" from "we couldn't ask the
      // server". UI today treats both as a generic miss; the discriminant
      // is here for future affordances.
      const reason = e instanceof Error ? e.message : 'lookup failed'
      return { kind: 'rejected', reason }
    }
  },

  async fetchHashtagInfo(name: string): Promise<Tag> {
    const tag = normaliseTagName(name)
    const api = pixelfedActiveContext()
    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> = {
      only_media: opts.onlyMedia === false ? 'false' : 'true',
    }
    if (opts.cursor.maxId) params.max_id = opts.cursor.maxId
    if (opts.cursor.minId) params.min_id = opts.cursor.minId

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

    const filterSensitive = opts.filterSensitive !== false
    const items = (Array.isArray(page) ? page : []).filter((p) => {
      if (filterSensitive && p.sensitive) return false
      // Pixelfed's photo-grid surface filters to media posts via the
      // server-extended `pf_type === 'photo'` field. Mastodon statuses don't
      // have this field, but on Pixelfed every status in this timeline does.
      return p.pf_type === 'photo' && (p.media_attachments?.length ?? 0) > 0
    })

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

  async fetchHashtagRelated(name: string): Promise<Tag[]> {
    const tag = normaliseTagName(name)
    const api = pixelfedActiveContext()
    const res = await api.get(`api/v1/tags/${encodeURIComponent(tag)}/related`)
    return Array.isArray(res) ? (res as Tag[]) : []
  },

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

  async getNotificationUnreadCount(): Promise<number> {
    const api = pixelfedActiveContext()
    // Pixelfed has no `/api/v1/notifications/unread_count` endpoint — derive
    // the count from the notifications marker. When no marker exists yet,
    // return 0 so a fresh install doesn't flash a huge badge on first cold
    // start.
    const markers = (await api.get('api/v1/markers', {
      'timeline[]': 'notifications',
    })) as { notifications?: { last_read_id?: string } | null } | null
    const sinceId = markers?.notifications?.last_read_id
    if (!sinceId) return 0
    const list = await api.get('api/v1/notifications', {
      min_id: sinceId,
      limit: 40,
    })
    return Array.isArray(list) ? list.length : 0
  },
}
