// D1a — Direct ActivityPub outbox fallback for remote profiles whose home
// instance returns no statuses. Reached when the user lands on a remote
// profile via a single boosted post and `GET /api/v1/accounts/{id}/statuses`
// comes back `[]` (Pixelfed's flat `whereProfileId` query has no federation
// pull). The fallback fetches the actor's AP outbox directly and renders the
// activities through the existing `FeedPost`. Synthetic ids are prefixed with
// `__remote:` so existing key extractors don't collide a synthetic with a
// home-instance status, and so screen-level interceptors can branch tap
// actions on `isSyntheticRemoteId(post.id)` (likes/boosts/reply need a real
// home-instance id, obtained on demand via `/api/v2/search?resolve=1`).
//
// The fetcher returns and the mapper accepts the AP `OrderedCollectionPage`
// shape verbatim — narrowing happens here, the rest of the app sees `Status`.

import type {
  Account,
  CustomEmoji,
  MediaAttachment,
  Mention,
  Status,
  Tag,
} from 'src/lib/api-types'
import { getJsonWithTimeout } from 'src/requests'

export const SYNTHETIC_REMOTE_PREFIX = '__remote:'

export function syntheticRemoteId(canonicalId: string): string {
  return `${SYNTHETIC_REMOTE_PREFIX}${canonicalId}`
}

export function isSyntheticRemoteId(id: string | undefined | null): boolean {
  return typeof id === 'string' && id.startsWith(SYNTHETIC_REMOTE_PREFIX)
}

export function canonicalUrlFromSyntheticId(id: string): string | null {
  if (!isSyntheticRemoteId(id)) return null
  return id.slice(SYNTHETIC_REMOTE_PREFIX.length)
}

// AP wire shapes — values arrive from the network and most fields are
// optional in the spec; `unknown` fields are narrowed where they're read.
type APAttachment = {
  type?: string
  mediaType?: string
  url?: string
  name?: string | null
  blurhash?: string | null
  width?: number
  height?: number
}

type APObject = {
  id?: string
  type?: string
  url?: string
  content?: string
  summary?: string | null
  sensitive?: boolean
  published?: string
  attachment?: APAttachment | APAttachment[]
  inReplyTo?: string | null
}

type APActivity = {
  id?: string
  type?: string
  published?: string
  object?: APObject | string
}

export type OrderedCollectionPage = {
  type?: string
  orderedItems?: APActivity[]
  next?: string
  // Some servers return an `OrderedCollection` root when `?page=true` is
  // ignored; `first` then points at the actual first page.
  first?: string
}

function asArray<T>(v: T | T[] | undefined | null): T[] {
  if (!v) return []
  return Array.isArray(v) ? v : [v]
}

function attachmentMediaType(att: APAttachment): MediaAttachment['type'] {
  const m = att.mediaType ?? ''
  if (m.startsWith('image/')) return 'image'
  if (m.startsWith('video/')) return 'video'
  if (m.startsWith('audio/')) return 'audio'
  return 'unknown'
}

function mapAttachment(
  att: APAttachment,
  idx: number,
  postId: string
): MediaAttachment | null {
  if (!att.url || typeof att.url !== 'string') return null
  return {
    id: `${postId}:m${idx}`,
    type: attachmentMediaType(att),
    url: att.url,
    preview_url: null,
    remote_url: att.url,
    description: typeof att.name === 'string' ? att.name : null,
    blurhash: typeof att.blurhash === 'string' ? att.blurhash : null,
    text_url: undefined,
    meta:
      typeof att.width === 'number' && typeof att.height === 'number'
        ? { original: { width: att.width, height: att.height } }
        : ({} as MediaAttachment['meta']),
  } as MediaAttachment
}

// Pure mapper — takes an AP `OrderedCollectionPage` and the rendered profile's
// `Account` (already loaded by the screen) and emits synthetic `Status` rows.
// Filters out activities that aren't top-level posts: `Announce` (boosts),
// `Like`s, replies (`object.inReplyTo` set), and unsupported object types.
export function mapOrderedCollectionPage(
  page: OrderedCollectionPage,
  account: Account
): Status[] {
  const items = page.orderedItems ?? []
  const out: Status[] = []
  for (const activity of items) {
    if (activity.type !== 'Create') continue
    const obj = activity.object
    if (!obj || typeof obj !== 'object') continue
    const objType = obj.type
    if (
      objType &&
      objType !== 'Note' &&
      objType !== 'Article' &&
      objType !== 'Question'
    ) {
      continue
    }
    if (obj.inReplyTo) continue
    const canonical = obj.id ?? obj.url ?? activity.id
    if (!canonical) continue
    const postId = syntheticRemoteId(canonical)
    const attachments = asArray(obj.attachment)
      .map((att, idx) => mapAttachment(att, idx, postId))
      .filter((a): a is MediaAttachment => a !== null)

    const status = {
      id: postId,
      created_at: obj.published ?? activity.published ?? new Date(0).toISOString(),
      in_reply_to_id: null,
      in_reply_to_account_id: null,
      sensitive: obj.sensitive === true,
      spoiler_text: typeof obj.summary === 'string' ? obj.summary : '',
      visibility: 'public',
      language: null,
      uri: canonical,
      url: typeof obj.url === 'string' ? obj.url : canonical,
      reply_count: 0,
      reblogs_count: 0,
      favourites_count: 0,
      edited_at: null,
      favourited: false,
      liked_at: null,
      reblogged: false,
      muted: false,
      bookmarked: false,
      pinned: false,
      filtered: false,
      content: typeof obj.content === 'string' ? obj.content : '',
      reblog: null,
      application: null,
      account,
      media_attachments: attachments,
      mentions: [] as Mention[],
      tags: [] as Tag[],
      emojis: [] as CustomEmoji[],
      card: [],
      poll: null,
      local: false,
      liked_by: null,
      pf_type: undefined,
    } as unknown as Status
    out.push(status)
  }
  return out
}

export class RemoteOutboxError extends Error {
  status: number
  constructor(message: string, status: number) {
    super(message)
    this.status = status
    this.name = 'RemoteOutboxError'
  }
}

// `pageUrl` is the full URL emitted by `OrderedCollectionPage.next` on the
// previous page. On the first call, pass `null` and the helper builds the
// initial URL from the account's canonical profile URL.
//
// Some servers (PeerTube, some Misskey configs) gate the outbox behind HTTP
// signatures; an unauthenticated GET returns 403. The caller treats that as
// "unable to render fallback" and shows an explanatory empty state.
export async function fetchRemoteOutboxPage(
  pageUrl: string | null,
  account: Account
): Promise<{ statuses: Status[]; nextPageUrl: string | null }> {
  let url = pageUrl ?? `${account.url}/outbox?page=true`
  let res = await getJsonWithTimeout(
    url,
    undefined,
    undefined,
    { Accept: 'application/activity+json' },
    8000
  )
  if (!res.ok) {
    throw new RemoteOutboxError(`outbox fetch failed: ${res.status}`, res.status)
  }
  let json = (await res.json()) as OrderedCollectionPage

  // If the server ignored `?page=true` and returned the collection root,
  // follow `first` once. Don't recurse further — a server that wraps in two
  // layers is non-standard enough that bailing is preferable to looping.
  if (
    (json.type === 'OrderedCollection' || json.type === 'Collection') &&
    typeof json.first === 'string'
  ) {
    res = await getJsonWithTimeout(
      json.first,
      undefined,
      undefined,
      { Accept: 'application/activity+json' },
      8000
    )
    if (!res.ok) {
      throw new RemoteOutboxError(
        `outbox first-page fetch failed: ${res.status}`,
        res.status
      )
    }
    json = (await res.json()) as OrderedCollectionPage
    url = json.first ?? url
  }

  return {
    statuses: mapOrderedCollectionPage(json, account),
    nextPageUrl: typeof json.next === 'string' ? json.next : null,
  }
}
