import type { OAuthSession } from '@atproto/oauth-client-browser'
import { nextTid, resolvePdsUrl } from '~/lib/atproto'
import { MAX_IMAGE_BYTES, MAX_IMAGE_LABEL } from '~/lib/security-constants'

export type BskyReply = {
  uri: string
  author: {
    did: string
    handle: string
    displayName?: string
    avatar?: string
  }
  text: string
  indexedAt: string
  replies?: BskyReply[]
}

export type AtpBlob = {
  $type: 'blob'
  ref: { $link: string }
  mimeType: string
  size: number
}

export type Publication = {
  sk: string
  atUri: string
  url: string
  name: string
  description?: string
  authorHandle?: string
  preferences: { showInDiscover: boolean }
}

export const CC_LICENSES = [
  'CC BY',
  'CC BY-SA',
  'CC BY-ND',
  'CC BY-NC',
  'CC BY-NC-SA',
  'CC BY-NC-ND',
  'CC0',
] as const

export type CcLicense = typeof CC_LICENSES[number]

export type Post = {
  sk: string
  atUri: string
  site: string
  resolvedUrl?: string
  resolvedPubName?: string
  resolvedHandle?: string
  path?: string
  title: string
  publishedAt: string
  visibility: 'published' | 'draft'
  coverImageRef?: string
  bskyPostRef?: { uri: string; cid: string }
  license?: string
  textContent?: string
  description?: string
}

export type PostDetail = Post & {
  content: string | undefined
  updatedAt?: string
}

export type FeedItem = {
  sk: string
  atUri: string
  site: string
  resolvedUrl?: string
  resolvedPubName?: string
  resolvedHandle?: string
  path?: string
  title: string
  publishedAt: string
  coverImageRef?: string
  textContent?: string
  description?: string
}

type FeedPage = {
  items: FeedItem[]
  cursor: string | null
}

export type Subscription = {
  rkey: string
  publicationAtUri: string
  authorDid: string
  subscribedAt: string
}

export type ResolvedPub = Publication & { rkey: string }

export type SuggestionItem = {
  rkey: string
  pubAtUri: string
  authorDid: string
  pubName: string
  pubUrl: string
  pubDescription?: string
  authorHandle?: string
}

export type TimelineItem = {
  authorDid: string
  rkey: string
  title: string
  publishedAt: string
  publicationName?: string
  pubAtUri?: string
  resolvedUrl?: string
  path?: string
  resolvedHandle?: string
  coverImageCid?: string
  description?: string
  textContent?: string
}

type TimelinePage = {
  items: TimelineItem[]
  cursor: string | null
}


export class SessionExpiredError extends Error {
  constructor() {
    super('Session expired — please sign in again')
    this.name = 'SessionExpiredError'
  }
}

function markdownToText(markdown: string): string {
  return markdown
    .replace(/```[\s\S]*?```/g, '')           // fenced code blocks
    .replace(/^#{1,6}\s+/gm, '')              // ATX headings
    .replace(/\*{1,3}([^*]+)\*{1,3}/g, '$1') // bold/italic asterisks
    .replace(/_{1,3}([^_]+)_{1,3}/g, '$1')   // bold/italic underscores
    .replace(/~~([^~]+)~~/g, '$1')            // strikethrough
    .replace(/`([^`]+)`/g, '$1')              // inline code
    .replace(/!\[[^\]]*\]\([^)]*\)/g, '')     // images
    .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')  // links → keep text
    .replace(/^>\s?/gm, '')                   // blockquotes
    .replace(/^\s*[-*+]\s+/gm, '')            // unordered lists
    .replace(/^\s*\d+[.)]\s+/gm, '')          // ordered lists
    .replace(/^[-*_]{3,}$/gm, '')             // horizontal rules
    .replace(/<[^>]+>/g, '')                  // HTML tags
    .replace(/[^\S\n]+/g, ' ')               // collapse horizontal whitespace, preserve newlines
    .replace(/\n{3,}/g, '\n\n')              // normalize excess blank lines
    .trim()
}

async function getServiceToken(session: OAuthSession, pdsUrl: string): Promise<string> {
  const apiDid = import.meta.env.VITE_API_DID
  if (!apiDid) throw new Error('VITE_API_DID is not configured')
  const res = await session.fetchHandler(
    `${pdsUrl}/xrpc/com.atproto.server.getServiceAuth?aud=${encodeURIComponent(apiDid)}&lxm=com.atproto.server.getServiceAuth`,
    { method: 'GET' },
  )
  if (!res.ok) {
    if (res.status === 401) throw new SessionExpiredError()
    const body = await res.text().catch(() => '')
    throw new Error(`getServiceAuth failed (${res.status})${body ? `: ${body}` : ''}`)
  }
  const { token } = (await res.json()) as { token: string }
  return token
}

function apiUrl(): string {
  const url = import.meta.env.VITE_API_URL
  if (!url) throw new Error('VITE_API_URL is not configured')
  return url
}

export async function uploadBlob(session: OAuthSession, file: Blob): Promise<AtpBlob> {
  const pdsUrl = await resolvePdsUrl(session.sub)
  const res = await session.fetchHandler(
    `${pdsUrl}/xrpc/com.atproto.repo.uploadBlob`,
    {
      method: 'POST',
      headers: { 'Content-Type': file.type },
      body: file,
    },
  )
  if (!res.ok) {
    const body = await res.text().catch(() => '')
    throw new Error(`uploadBlob failed (${res.status})${body ? `: ${body}` : ''}`)
  }
  const { blob } = (await res.json()) as { blob: AtpBlob }
  return blob
}

export async function getPostPdsData(
  session: OAuthSession,
  rkey: string,
  visibility: 'published' | 'draft' = 'published',
): Promise<{ coverImage?: AtpBlob; bskyPostRef?: { uri: string; cid: string }; license?: string }> {
  const pdsUrl = await resolvePdsUrl(session.sub)
  const collection = visibility === 'draft' ? 'pub.lemma.blog.draft' : 'site.standard.document'
  const res = await session.fetchHandler(
    `${pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(session.sub)}&collection=${collection}&rkey=${encodeURIComponent(rkey)}`,
    { method: 'GET' },
  )
  if (!res.ok) return {}
  const { value } = (await res.json()) as { value: { coverImage?: AtpBlob; bskyPostRef?: { uri: string; cid: string }; content?: { license?: string } } }
  return { coverImage: value.coverImage, bskyPostRef: value.bskyPostRef, license: value.content?.license }
}

export async function uploadContentImages(
  session: OAuthSession,
  markdown: string,
): Promise<{ content: string; imageBlobs: Array<{ image: AtpBlob; alt: string }> }> {
  const matches = [...markdown.matchAll(/!\[([^\]]*)\]\((blob:[^)]+)\)/g)]
  if (matches.length === 0) return { content: markdown, imageBlobs: [] }

  const did = session.sub
  let result = markdown
  const imageBlobs: Array<{ image: AtpBlob; alt: string }> = []
  const failures: string[] = []

  for (const match of matches) {
    const [fullMatch, alt, blobUrl] = match
    try {
      const res = await fetch(blobUrl)
      const blob = await res.blob()
      if (blob.size > MAX_IMAGE_BYTES) {
        failures.push(alt || 'image')
        continue
      }
      const file = new File([blob], 'image', { type: blob.type || 'image/jpeg' })
      const atpBlob = await uploadBlob(session, file)
      const cdnUrl = coverImageCdnUrl(did, atpBlob.ref.$link)
      result = result.replace(fullMatch, `![${alt}](${cdnUrl})`)
      imageBlobs.push({ image: atpBlob, alt })
    } catch {
      failures.push(alt || 'image')
    }
  }

  if (failures.length > 0) {
    const count = failures.length
    throw new Error(
      `${count} image${count > 1 ? 's' : ''} could not be uploaded — each must be under ${MAX_IMAGE_LABEL}. Remove ${count > 1 ? 'them' : 'it'} and try again.`
    )
  }

  return { content: result, imageBlobs }
}

function extractReferencedCids(did: string, content: string): Set<string> {
  const escaped = did.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
  const pattern = new RegExp(`cdn\\.bsky\\.app/img/[^/]+/plain/${escaped}/([A-Za-z0-9]+)@`, 'g')
  const cids = new Set<string>()
  for (const match of content.matchAll(pattern)) cids.add(match[1])
  return cids
}

export async function getBskyPostText(session: OAuthSession, bskyPostUri: string): Promise<string> {
  const parts = bskyPostUri.split('/')
  const repoDid = parts[2]
  const sidecarRkey = parts[4]
  const pdsUrl = await resolvePdsUrl(session.sub)
  const res = await session.fetchHandler(
    `${pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(repoDid)}&collection=app.bsky.feed.post&rkey=${encodeURIComponent(sidecarRkey)}`,
    { method: 'GET' },
  )
  if (!res.ok) return ''
  const { value } = (await res.json()) as { value: { text?: string } }
  return value.text ?? ''
}

export function coverImageCdnUrl(did: string, cid: string, thumbnail = false): string {
  const size = thumbnail ? 'feed_thumbnail' : 'feed_fullsize'
  return `https://cdn.bsky.app/img/${size}/plain/${did}/${cid}@jpeg`
}

export async function listPublications(session: OAuthSession): Promise<Publication[]> {
  const pdsUrl = await resolvePdsUrl(session.sub)
  const token = await getServiceToken(session, pdsUrl)
  const res = await fetch(`${apiUrl()}/publications`, {
    headers: { Authorization: `Bearer ${token}` },
  })
  if (!res.ok) {
    const body = await res.text().catch(() => '')
    throw new Error(`API error (${res.status})${body ? `: ${body}` : ''}`)
  }
  return res.json() as Promise<Publication[]>
}

function svgToPng(svgBlob: Blob, size = 256): Promise<Blob> {
  return new Promise((resolve, reject) => {
    const url = URL.createObjectURL(svgBlob)
    const img = new Image()
    img.onload = () => {
      URL.revokeObjectURL(url)
      const canvas = document.createElement('canvas')
      canvas.width = size
      canvas.height = size
      canvas.getContext('2d')!.drawImage(img, 0, 0, size, size)
      canvas.toBlob((blob) => blob ? resolve(blob) : reject(new Error('toBlob failed')), 'image/png')
    }
    img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Failed to load SVG')) }
    img.src = url
  })
}

async function uploadPublicationIcon(session: OAuthSession): Promise<AtpBlob | null> {
  try {
    const res = await fetch(`${window.location.origin}/avatar.svg`)
    if (!res.ok) return null
    const png = await svgToPng(await res.blob())
    return await uploadBlob(session, new File([png], 'avatar.png', { type: 'image/png' }))
  } catch {
    return null
  }
}

export async function createPublication(
  session: OAuthSession,
  data: { name: string; description?: string; showInDiscover: boolean },
): Promise<Publication> {
  const did = session.sub
  const pdsUrl = await resolvePdsUrl(did)
  const rkey = nextTid()
  const url = `${window.location.origin}/${did}/pub/${rkey}`

  const icon = await uploadPublicationIcon(session)

  const record: Record<string, unknown> = {
    $type: 'site.standard.publication',
    url,
    name: data.name,
    preferences: { showInDiscover: data.showInDiscover },
  }
  if (data.description?.trim()) record.description = data.description.trim()
  if (icon) record.icon = icon

  const pdsRes = await session.fetchHandler(
    `${pdsUrl}/xrpc/com.atproto.repo.putRecord`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ repo: did, collection: 'site.standard.publication', rkey, record }),
    },
  )
  if (!pdsRes.ok) {
    const body = await pdsRes.text().catch(() => '')
    throw new Error(`putRecord failed (${pdsRes.status})${body ? `: ${body}` : ''}`)
  }
  const { uri: atUri } = (await pdsRes.json()) as { uri: string; cid: string }

  const token = await getServiceToken(session, pdsUrl)
  const res = await fetch(`${apiUrl()}/publications`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      atUri, rkey, url, name: data.name,
      description: data.description?.trim() || undefined,
      preferences: { showInDiscover: data.showInDiscover },
    }),
  })
  if (!res.ok) {
    const body = await res.text().catch(() => '')
    throw new Error(`API error (${res.status})${body ? `: ${body}` : ''}`)
  }
  return res.json() as Promise<Publication>
}

export async function updatePublication(
  session: OAuthSession,
  rkey: string,
  data: { name: string; description?: string; showInDiscover: boolean },
): Promise<void> {
  const did = session.sub
  const pdsUrl = await resolvePdsUrl(did)

  const url = `${window.location.origin}/${did}/pub/${rkey}`
  const icon = await uploadPublicationIcon(session)

  const record: Record<string, unknown> = {
    $type: 'site.standard.publication',
    url,
    name: data.name,
    preferences: { showInDiscover: data.showInDiscover },
  }
  if (data.description?.trim()) record.description = data.description.trim()
  if (icon) record.icon = icon

  const pdsRes = await session.fetchHandler(
    `${pdsUrl}/xrpc/com.atproto.repo.putRecord`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ repo: did, collection: 'site.standard.publication', rkey, record }),
    },
  )
  if (!pdsRes.ok) {
    const body = await pdsRes.text().catch(() => '')
    throw new Error(`putRecord failed (${pdsRes.status})${body ? `: ${body}` : ''}`)
  }

  const token = await getServiceToken(session, pdsUrl)
  const res = await fetch(`${apiUrl()}/publications?rkey=${encodeURIComponent(rkey)}`, {
    method: 'PUT',
    headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      name: data.name,
      description: data.description?.trim() || undefined,
      preferences: { showInDiscover: data.showInDiscover },
    }),
  })
  if (!res.ok) {
    const body = await res.text().catch(() => '')
    throw new Error(`API error (${res.status})${body ? `: ${body}` : ''}`)
  }
}

export async function deletePublication(session: OAuthSession, rkey: string): Promise<void> {
  const did = session.sub
  const pdsUrl = await resolvePdsUrl(did)

  const pdsRes = await session.fetchHandler(
    `${pdsUrl}/xrpc/com.atproto.repo.deleteRecord`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ repo: did, collection: 'site.standard.publication', rkey }),
    },
  )
  if (!pdsRes.ok) {
    const body = await pdsRes.text().catch(() => '')
    throw new Error(`deleteRecord failed (${pdsRes.status})${body ? `: ${body}` : ''}`)
  }

  const token = await getServiceToken(session, pdsUrl)
  const res = await fetch(`${apiUrl()}/publications?rkey=${encodeURIComponent(rkey)}`, {
    method: 'DELETE',
    headers: { Authorization: `Bearer ${token}` },
  })
  if (!res.ok) {
    const body = await res.text().catch(() => '')
    throw new Error(`API error (${res.status})${body ? `: ${body}` : ''}`)
  }
}

// Reads all site.standard.publication records from the user's PDS and indexes
// any that are missing from DynamoDB. Called when listPublications returns empty
// so the app stays in sync after a device switch or data loss.
export async function backfillPublications(session: OAuthSession): Promise<void> {
  const did = session.sub
  const pdsUrl = await resolvePdsUrl(did)

  const res = await session.fetchHandler(
    `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(did)}&collection=site.standard.publication&limit=100`,
    { method: 'GET' },
  )
  if (!res.ok) throw new Error(`listRecords failed (${res.status})`)

  const { records } = (await res.json()) as {
    records: Array<{
      uri: string
      value: {
        url: string
        name: string
        description?: string
        preferences?: { showInDiscover?: boolean }
      }
    }>
  }
  if (records.length === 0) return

  const token = await getServiceToken(session, pdsUrl)
  await Promise.all(
    records.map((record) => {
      const rkey = record.uri.split('/').pop()!
      return fetch(`${apiUrl()}/publications`, {
        method: 'POST',
        headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
        body: JSON.stringify({
          atUri: record.uri,
          rkey,
          url: record.value.url,
          name: record.value.name,
          description: record.value.description,
          preferences: { showInDiscover: record.value.preferences?.showInDiscover ?? false },
        }),
      })
    }),
  )
}

// Reads all site.standard.document and pub.lemma.blog.draft records from the
// user's PDS and indexes any missing from DynamoDB. Called when getUserBlogPosts
// returns empty (e.g. after a device switch or data loss).
export async function backfillPosts(session: OAuthSession): Promise<void> {
  const did = session.sub
  const pdsUrl = await resolvePdsUrl(did)
  const token = await getServiceToken(session, pdsUrl)

  type PdsRecord = {
    uri: string
    value: {
      site: string
      path?: string
      title: string
      publishedAt: string
      content?: { content?: string }
      coverImage?: { ref: { $link: string } }
      bskyPostRef?: { uri: string; cid: string }
    }
  }

  const [docRes, draftRes] = await Promise.all([
    session.fetchHandler(
      `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(did)}&collection=site.standard.document&limit=100`,
      { method: 'GET' },
    ),
    session.fetchHandler(
      `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(did)}&collection=pub.lemma.blog.draft&limit=100`,
      { method: 'GET' },
    ),
  ])

  const posts: Array<{ record: PdsRecord; visibility: 'published' | 'draft' }> = []

  if (docRes.ok) {
    const { records } = (await docRes.json()) as { records: PdsRecord[] }
    for (const record of records) posts.push({ record, visibility: 'published' })
  }

  if (draftRes.ok) {
    const { records } = (await draftRes.json()) as { records: PdsRecord[] }
    for (const record of records) posts.push({ record, visibility: 'draft' })
  }

  if (posts.length === 0) return

  await Promise.all(
    posts.map(({ record, visibility }) => {
      const rkey = record.uri.split('/').pop()!
      const payload: Record<string, unknown> = {
        atUri: record.uri,
        site: record.value.site,
        path: record.value.path ?? `/${rkey}`,
        rkey,
        title: record.value.title,
        publishedAt: record.value.publishedAt,
        content: record.value.content?.content,
        visibility,
      }
      if (record.value.coverImage?.ref.$link) {
        payload.coverImageRef = record.value.coverImage.ref.$link
      }
      if (visibility === 'published' && record.value.bskyPostRef) {
        payload.bskyPostRef = record.value.bskyPostRef
      }
      return fetch(`${apiUrl()}/posts`, {
        method: 'POST',
        headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
      })
    }),
  )
}

export async function backfillSubscriptions(session: OAuthSession): Promise<void> {
  const did = session.sub
  const pdsUrl = await resolvePdsUrl(did)

  const res = await session.fetchHandler(
    `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(did)}&collection=site.standard.graph.subscription&limit=100`,
    { method: 'GET' },
  )
  if (!res.ok) throw new Error(`listRecords failed (${res.status})`)

  const { records } = (await res.json()) as {
    records: Array<{
      uri: string
      value: { publication: string; createdAt?: string }
    }>
  }
  if (records.length === 0) return

  const token = await getServiceToken(session, pdsUrl)
  await Promise.all(
    records.map((record) => {
      const rkey = record.uri.split('/').pop()!
      return fetch(`${apiUrl()}/subscriptions`, {
        method: 'POST',
        headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
        body: JSON.stringify({
          rkey,
          publicationAtUri: record.value.publication,
          subscribedAt: record.value.createdAt ?? new Date().toISOString(),
        }),
      })
    }),
  )
}

export async function getFeed(cursor?: string): Promise<FeedPage> {
  const params = cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''
  const res = await fetch(`${apiUrl()}/feed${params}`)
  if (!res.ok) {
    const body = await res.text().catch(() => '')
    throw new Error(`API error (${res.status})${body ? `: ${body}` : ''}`)
  }
  return res.json() as Promise<FeedPage>
}

export async function listPublicPosts(did: string): Promise<Post[]> {
  const res = await fetch(`${apiUrl()}/profile?did=${encodeURIComponent(did)}`)
  if (!res.ok) {
    const body = await res.text().catch(() => '')
    throw new Error(`API error (${res.status})${body ? `: ${body}` : ''}`)
  }
  return res.json() as Promise<Post[]>
}

export async function recordAcceptedTerms(session: OAuthSession): Promise<void> {
  const pdsUrl = await resolvePdsUrl(session.sub)
  const token = await getServiceToken(session, pdsUrl)
  await fetch(`${apiUrl()}/users`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}` },
  })
}

export async function getUserBlogPosts(session: OAuthSession): Promise<Post[]> {
  const pdsUrl = await resolvePdsUrl(session.sub)
  const token = await getServiceToken(session, pdsUrl)
  const res = await fetch(`${apiUrl()}/posts`, {
    headers: { Authorization: `Bearer ${token}` },
  })
  if (!res.ok) {
    const body = await res.text().catch(() => '')
    throw new Error(`API error (${res.status})${body ? `: ${body}` : ''}`)
  }
  return res.json() as Promise<Post[]>
}

export async function createPost(
  session: OAuthSession,
  site: string,
  title: string,
  content: string,
  visibility: 'published' | 'draft',
  existingRkey?: string,
  coverImage?: AtpBlob,
  bskyPostOptions?: { text: string; pubUrl: string },
  imageBlobs?: Array<{ image: AtpBlob; alt: string }>,
  license?: string,
): Promise<Post> {
  const did = session.sub
  const publishedAt = new Date().toISOString()
  const pdsUrl = await resolvePdsUrl(did)
  // When promoting a draft to published, generate a fresh rkey for the published record
  // and keep the draft rkey around so we can delete it.
  const draftRkey = visibility === 'published' && existingRkey ? existingRkey : undefined
  const rkey = draftRkey ? nextTid() : (existingRkey ?? nextTid())

  const collection = visibility === 'draft' ? 'pub.lemma.blog.draft' : 'site.standard.document'
  const atUri = `at://${did}/${collection}/${rkey}`
  let bskyPostRef: { uri: string; cid: string } | undefined

  if (visibility === 'published' && bskyPostOptions) {
    const postUrl = `${bskyPostOptions.pubUrl.replace(/\/$/, '')}/${rkey}`
    try {
      const sidecarRes = await session.fetchHandler(
        `${pdsUrl}/xrpc/com.atproto.repo.createRecord`,
        {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            repo: did,
            collection: 'app.bsky.feed.post',
            record: {
              $type: 'app.bsky.feed.post',
              text: bskyPostOptions.text,
              createdAt: publishedAt,
              embed: {
                $type: 'app.bsky.embed.external',
                external: {
                  uri: postUrl,
                  title,
                  description: '',
                  ...(coverImage ? { thumb: coverImage } : {}),
                },
              },
            },
          }),
        },
      )
      if (sidecarRes.ok) {
        bskyPostRef = (await sidecarRes.json()) as { uri: string; cid: string }
      }
    } catch {
      // sidecar creation is non-fatal
    }
  }

  const record: Record<string, unknown> = {
    $type: collection,
    site,
    path: `/${rkey}`,
    title,
    publishedAt,
    updatedAt: publishedAt,
    content: { $type: 'pub.lemma.blog.entry', content, ...(license ? { license } : {}) },
    textContent: markdownToText(content),
  }
  if (coverImage) record.coverImage = coverImage
  if (bskyPostRef) record.bskyPostRef = bskyPostRef
  if (imageBlobs && imageBlobs.length > 0) {
    record.embed = { $type: 'app.bsky.embed.images', images: imageBlobs }
  }

  const pdsRes = await session.fetchHandler(
    `${pdsUrl}/xrpc/com.atproto.repo.putRecord`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ repo: did, collection, rkey, record }),
    },
  )
  if (!pdsRes.ok) {
    const body = await pdsRes.text().catch(() => '')
    throw new Error(`putRecord failed (${pdsRes.status})${body ? `: ${body}` : ''}`)
  }

  if (draftRkey) {
    const draftDeleteRes = await session.fetchHandler(
      `${pdsUrl}/xrpc/com.atproto.repo.deleteRecord`,
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ repo: did, collection: 'pub.lemma.blog.draft', rkey: draftRkey }),
      },
    )
    if (!draftDeleteRes.ok && draftDeleteRes.status !== 404) {
      const body = await draftDeleteRes.text().catch(() => '')
      throw new Error(`Failed to delete draft from PDS (${draftDeleteRes.status})${body ? `: ${body}` : ''}`)
    }
  }

  const token = await getServiceToken(session, pdsUrl)
  const payload: Record<string, unknown> = { atUri, site, rkey, title, publishedAt, updatedAt: publishedAt, content, visibility }
  if (draftRkey) payload.draftRkey = draftRkey
  if (coverImage) payload.coverImageRef = coverImage.ref.$link
  if (bskyPostRef) payload.bskyPostRef = bskyPostRef
  if (license) payload.license = license
  const res = await fetch(`${apiUrl()}/posts`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  })
  if (!res.ok) {
    const body = await res.text().catch(() => '')
    throw new Error(`API error (${res.status})${body ? `: ${body}` : ''}`)
  }
  return res.json() as Promise<Post>
}

export type PublicPostDetail = {
  title: string
  publishedAt: string
  updatedAt: string | undefined
  content: string | undefined
  coverImageRef: string | undefined
  bskyPostRef?: { uri: string; cid: string }
  site?: string
  license?: string
}

export async function getPublicPost(did: string, rkey: string): Promise<PublicPostDetail | null> {
  const res = await fetch(`${apiUrl()}/posts/content?did=${encodeURIComponent(did)}&rkey=${encodeURIComponent(rkey)}`)
  if (res.status === 404) return null
  if (!res.ok) throw new Error(`API error (${res.status})`)
  return res.json() as Promise<PublicPostDetail>
}

export async function getPost(session: OAuthSession, rkey: string): Promise<PostDetail> {
  const pdsUrl = await resolvePdsUrl(session.sub)
  const token = await getServiceToken(session, pdsUrl)
  const res = await fetch(`${apiUrl()}/posts?rkey=${encodeURIComponent(rkey)}`, {
    headers: { Authorization: `Bearer ${token}` },
  })
  if (!res.ok) {
    const body = await res.text().catch(() => '')
    throw new Error(`API error (${res.status})${body ? `: ${body}` : ''}`)
  }
  return res.json() as Promise<PostDetail>
}

export async function updatePost(
  session: OAuthSession,
  rkey: string,
  title: string,
  content: string,
  visibility: 'published' | 'draft',
  original: PostDetail,
  coverImage?: AtpBlob | null,
  bskyPostOptions?: { text: string; pubUrl: string },
  imageBlobs?: Array<{ image: AtpBlob; alt: string }>,
  license?: string,
): Promise<Post> {
  const did = session.sub
  const pdsUrl = await resolvePdsUrl(did)
  const originalCollection = original.visibility === 'draft' ? 'pub.lemma.blog.draft' : 'site.standard.document'
  const isPublishing = original.visibility === 'draft' && visibility === 'published'
  const targetCollection = isPublishing ? 'site.standard.document' : originalCollection
  // When promoting a draft, assign a new rkey to the published record so they are distinct AT Protocol records.
  const newRkey = isPublishing ? nextTid() : rkey

  // Create or update the Bluesky sidecar post when publishing.
  let bskyPostRef: { uri: string; cid: string } | undefined
  if (visibility === 'published' && bskyPostOptions) {
    if (!original.bskyPostRef) {
      // Draft → published: create sidecar for the first time.
      const postUrl = `${bskyPostOptions.pubUrl.replace(/\/$/, '')}/${newRkey}`
      try {
        const sidecarRes = await session.fetchHandler(
          `${pdsUrl}/xrpc/com.atproto.repo.createRecord`,
          {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              repo: did,
              collection: 'app.bsky.feed.post',
              record: {
                $type: 'app.bsky.feed.post',
                text: bskyPostOptions.text,
                createdAt: new Date().toISOString(),
                embed: {
                  $type: 'app.bsky.embed.external',
                  external: {
                    uri: postUrl,
                    title,
                    description: '',
                    ...(coverImage ? { thumb: coverImage } : {}),
                  },
                },
              },
            }),
          },
        )
        if (sidecarRes.ok) {
          bskyPostRef = (await sidecarRes.json()) as { uri: string; cid: string }
        }
      } catch {
        // non-fatal
      }
    } else {
      // Already published: upsert the sidecar with the updated text.
      const sidecarRkey = original.bskyPostRef.uri.split('/').at(-1)!
      const postUrl = `${bskyPostOptions.pubUrl.replace(/\/$/, '')}/${newRkey}`
      try {
        await session.fetchHandler(
          `${pdsUrl}/xrpc/com.atproto.repo.putRecord`,
          {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              repo: did,
              collection: 'app.bsky.feed.post',
              rkey: sidecarRkey,
              record: {
                $type: 'app.bsky.feed.post',
                text: bskyPostOptions.text,
                createdAt: original.publishedAt,
                embed: {
                  $type: 'app.bsky.embed.external',
                  external: {
                    uri: postUrl,
                    title,
                    description: '',
                    ...(coverImage ? { thumb: coverImage } : {}),
                  },
                },
              },
            }),
          },
        )
      } catch {
        // non-fatal
      }
    }
  }

  // Merge still-referenced existing image blobs with newly uploaded ones to prevent GC.
  const referencedCids = extractReferencedCids(did, content)
  let allImageBlobs: Array<{ image: AtpBlob; alt: string }> = []
  if (referencedCids.size > 0 || (imageBlobs && imageBlobs.length > 0)) {
    try {
      const existingRes = await session.fetchHandler(
        `${pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=${originalCollection}&rkey=${encodeURIComponent(rkey)}`,
        { method: 'GET' },
      )
      if (existingRes.ok) {
        type ExistingRecord = { value: { embed?: { $type?: string; images?: Array<{ image: AtpBlob; alt: string }> } } }
        const existingData = (await existingRes.json()) as ExistingRecord
        const existingImages = existingData.value?.embed?.images ?? []
        allImageBlobs = existingImages.filter((img) => referencedCids.has(img.image.ref.$link))
      }
    } catch {
      // non-fatal: just use newly uploaded blobs
    }
    const existingCids = new Set(allImageBlobs.map((b) => b.image.ref.$link))
    for (const blob of (imageBlobs ?? [])) {
      if (!existingCids.has(blob.image.ref.$link)) {
        allImageBlobs.push(blob)
        existingCids.add(blob.image.ref.$link)
      }
    }
  }

  const updatedAt = new Date().toISOString()

  const record: Record<string, unknown> = {
    $type: targetCollection,
    site: original.site,
    path: `/${newRkey}`,
    title,
    publishedAt: original.publishedAt,
    updatedAt,
    content: { $type: 'pub.lemma.blog.entry', content, ...(license ? { license } : {}) },
    textContent: markdownToText(content),
  }
  if (coverImage) record.coverImage = coverImage
  if (bskyPostRef) record.bskyPostRef = bskyPostRef
  else if (original.bskyPostRef) record.bskyPostRef = original.bskyPostRef
  if (allImageBlobs.length > 0) record.embed = { $type: 'app.bsky.embed.images', images: allImageBlobs }

  const atUri = isPublishing ? `at://${did}/site.standard.document/${newRkey}` : original.atUri

  const pdsRes = await session.fetchHandler(
    `${pdsUrl}/xrpc/com.atproto.repo.putRecord`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ repo: did, collection: targetCollection, rkey: newRkey, record }),
    },
  )
  if (!pdsRes.ok) {
    const body = await pdsRes.text().catch(() => '')
    throw new Error(`putRecord failed (${pdsRes.status})${body ? `: ${body}` : ''}`)
  }

  if (isPublishing) {
    const draftDeleteRes = await session.fetchHandler(
      `${pdsUrl}/xrpc/com.atproto.repo.deleteRecord`,
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ repo: did, collection: 'pub.lemma.blog.draft', rkey }),
      },
    )
    if (!draftDeleteRes.ok && draftDeleteRes.status !== 404) {
      const body = await draftDeleteRes.text().catch(() => '')
      throw new Error(`Failed to delete draft from PDS (${draftDeleteRes.status})${body ? `: ${body}` : ''}`)
    }
  }

  const token = await getServiceToken(session, pdsUrl)
  const payload: Record<string, unknown> = {
    atUri, site: original.site, rkey: newRkey, title,
    publishedAt: original.publishedAt, updatedAt, content, visibility,
  }
  if (isPublishing) payload.draftRkey = rkey
  if (coverImage) payload.coverImageRef = coverImage.ref.$link
  if (bskyPostRef) payload.bskyPostRef = bskyPostRef
  else if (original.bskyPostRef) payload.bskyPostRef = original.bskyPostRef
  if (license) payload.license = license
  const res = await fetch(`${apiUrl()}/posts`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  })
  if (!res.ok) {
    const body = await res.text().catch(() => '')
    throw new Error(`API error (${res.status})${body ? `: ${body}` : ''}`)
  }
  return res.json() as Promise<Post>
}

export async function notifyPostUpdate(
  session: OAuthSession,
  params: {
    atUri: string
    site: string
    rkey: string
    title: string
    publishedAt: string
    updatedAt: string
    content: string
    coverImageRef?: string
    bskyPostRef?: { uri: string; cid: string }
    license?: string
  },
): Promise<void> {
  const pdsUrl = await resolvePdsUrl(session.sub)
  const token = await getServiceToken(session, pdsUrl)
  const payload: Record<string, unknown> = {
    ...params,
    visibility: 'published',
  }
  const res = await fetch(`${apiUrl()}/posts`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  })
  if (!res.ok) {
    const body = await res.text().catch(() => '')
    throw new Error(`API error (${res.status})${body ? `: ${body}` : ''}`)
  }
}

export async function deletePost(
  session: OAuthSession,
  rkey: string,
  bskyPostRef?: { uri: string; cid: string },
  visibility: 'published' | 'draft' = 'published',
): Promise<void> {
  const pdsUrl = await resolvePdsUrl(session.sub)
  const token = await getServiceToken(session, pdsUrl)
  const did = session.sub
  const collection = visibility === 'draft' ? 'pub.lemma.blog.draft' : 'site.standard.document'

  if (bskyPostRef) {
    const sidecarRkey = bskyPostRef.uri.split('/').at(-1)
    if (sidecarRkey) {
      try {
        await session.fetchHandler(
          `${pdsUrl}/xrpc/com.atproto.repo.deleteRecord`,
          {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ repo: did, collection: 'app.bsky.feed.post', rkey: sidecarRkey }),
          },
        )
      } catch {
        // sidecar deletion is non-fatal
      }
    }
  }

  const pdsRes = await session.fetchHandler(
    `${pdsUrl}/xrpc/com.atproto.repo.deleteRecord`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ repo: did, collection, rkey }),
    },
  )
  if (!pdsRes.ok) {
    const body = await pdsRes.text().catch(() => '')
    throw new Error(`deleteRecord failed (${pdsRes.status})${body ? `: ${body}` : ''}`)
  }

  const apiRes = await fetch(`${apiUrl()}/posts?rkey=${encodeURIComponent(rkey)}`, {
    method: 'DELETE',
    headers: { Authorization: `Bearer ${token}` },
  })
  if (!apiRes.ok) {
    const body = await apiRes.text().catch(() => '')
    throw new Error(`API error (${apiRes.status})${body ? `: ${body}` : ''}`)
  }
}

type ThreadPost = {
  $type?: string
  post: {
    uri: string
    likeCount?: number
    replyCount?: number
    author: { did: string; handle: string; displayName?: string; avatar?: string }
    record: { text?: string }
    indexedAt: string
  }
  replies?: ThreadPost[]
}

function processThreadReplies(items: ThreadPost[]): BskyReply[] {
  return items
    .filter((r): r is ThreadPost => r.$type === 'app.bsky.feed.defs#threadViewPost')
    .map((r) => ({
      uri: r.post.uri,
      author: {
        did: r.post.author.did,
        handle: r.post.author.handle,
        displayName: r.post.author.displayName,
        avatar: r.post.author.avatar,
      },
      text: r.post.record.text ?? '',
      indexedAt: r.post.indexedAt,
      replies: r.replies ? processThreadReplies(r.replies) : undefined,
    }))
}

export async function getBskyPostThread(uri: string): Promise<{
  likeCount: number
  replyCount: number
  replies: BskyReply[]
}> {
  const res = await fetch(
    `https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=${encodeURIComponent(uri)}&depth=6&parentHeight=0`,
  )
  if (!res.ok) throw new Error(`Failed to fetch thread (${res.status})`)
  const { thread } = (await res.json()) as { thread: ThreadPost }

  return {
    likeCount: thread.post.likeCount ?? 0,
    replyCount: thread.post.replyCount ?? 0,
    replies: processThreadReplies(thread.replies ?? []),
  }
}

export async function getBskyViewerLike(session: OAuthSession, uri: string): Promise<string | null> {
  try {
    const did = session.sub
    const pdsUrl = await resolvePdsUrl(did)
    let cursor: string | undefined
    for (let i = 0; i < 10; i++) {
      const params = new URLSearchParams({ repo: did, collection: 'app.bsky.feed.like', limit: '100' })
      if (cursor) params.set('cursor', cursor)
      const res = await session.fetchHandler(`${pdsUrl}/xrpc/com.atproto.repo.listRecords?${params}`)
      if (!res.ok) return null
      const data = (await res.json()) as {
        records: Array<{ uri: string; value: { subject?: { uri: string } } }>
        cursor?: string
      }
      const match = data.records.find(r => r.value?.subject?.uri === uri)
      if (match) return match.uri
      if (!data.cursor) break
      cursor = data.cursor
    }
    return null
  } catch {
    return null
  }
}

export async function likeBskyPost(session: OAuthSession, subject: { uri: string; cid: string }): Promise<string> {
  const did = session.sub
  const pdsUrl = await resolvePdsUrl(did)
  const res = await session.fetchHandler(
    `${pdsUrl}/xrpc/com.atproto.repo.createRecord`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        repo: did,
        collection: 'app.bsky.feed.like',
        record: { $type: 'app.bsky.feed.like', subject, createdAt: new Date().toISOString() },
      }),
    },
  )
  if (!res.ok) {
    const body = await res.text().catch(() => '')
    throw new Error(`Failed to like (${res.status})${body ? `: ${body}` : ''}`)
  }
  const { uri } = (await res.json()) as { uri: string }
  return uri
}

export async function unlikeBskyPost(session: OAuthSession, likeUri: string): Promise<void> {
  const did = session.sub
  const pdsUrl = await resolvePdsUrl(did)
  const likeRkey = likeUri.split('/').at(-1)!
  const res = await session.fetchHandler(
    `${pdsUrl}/xrpc/com.atproto.repo.deleteRecord`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ repo: did, collection: 'app.bsky.feed.like', rkey: likeRkey }),
    },
  )
  if (!res.ok) {
    const body = await res.text().catch(() => '')
    throw new Error(`Failed to unlike (${res.status})${body ? `: ${body}` : ''}`)
  }
}

export async function listPublicPublications(did: string): Promise<Publication[]> {
  const res = await fetch(`${apiUrl()}/publications?did=${encodeURIComponent(did)}`)
  if (!res.ok) {
    const body = await res.text().catch(() => '')
    throw new Error(`API error (${res.status})${body ? `: ${body}` : ''}`)
  }
  return res.json() as Promise<Publication[]>
}


export async function listSubscriptions(session: OAuthSession): Promise<Subscription[]> {
  const pdsUrl = await resolvePdsUrl(session.sub)
  const token = await getServiceToken(session, pdsUrl)
  const res = await fetch(`${apiUrl()}/subscriptions`, {
    headers: { Authorization: `Bearer ${token}` },
  })
  if (!res.ok) {
    const body = await res.text().catch(() => '')
    throw new Error(`API error (${res.status})${body ? `: ${body}` : ''}`)
  }
  return res.json() as Promise<Subscription[]>
}

export async function subscribe(session: OAuthSession, publicationAtUri: string): Promise<string> {
  const did = session.sub
  const pdsUrl = await resolvePdsUrl(did)
  const rkey = nextTid()
  const subscribedAt = new Date().toISOString()
  const pdsRes = await session.fetchHandler(
    `${pdsUrl}/xrpc/com.atproto.repo.putRecord`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        repo: did,
        collection: 'site.standard.graph.subscription',
        rkey,
        record: { $type: 'site.standard.graph.subscription', publication: publicationAtUri, createdAt: subscribedAt },
      }),
    },
  )
  if (!pdsRes.ok) {
    const body = await pdsRes.text().catch(() => '')
    throw new Error(`putRecord failed (${pdsRes.status})${body ? `: ${body}` : ''}`)
  }
  const token = await getServiceToken(session, pdsUrl)
  await fetch(`${apiUrl()}/subscriptions`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ rkey, publicationAtUri, subscribedAt }),
  })
  return rkey
}

export async function unsubscribe(session: OAuthSession, subscriptionRkey: string): Promise<void> {
  const did = session.sub
  const pdsUrl = await resolvePdsUrl(did)
  const pdsRes = await session.fetchHandler(
    `${pdsUrl}/xrpc/com.atproto.repo.deleteRecord`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ repo: did, collection: 'site.standard.graph.subscription', rkey: subscriptionRkey }),
    },
  )
  if (!pdsRes.ok) {
    const body = await pdsRes.text().catch(() => '')
    throw new Error(`deleteRecord failed (${pdsRes.status})${body ? `: ${body}` : ''}`)
  }
  const token = await getServiceToken(session, pdsUrl)
  await fetch(`${apiUrl()}/subscriptions?rkey=${encodeURIComponent(subscriptionRkey)}`, {
    method: 'DELETE',
    headers: { Authorization: `Bearer ${token}` },
  })
}

export async function getTimeline(session: OAuthSession, cursor?: string): Promise<TimelinePage> {
  const pdsUrl = await resolvePdsUrl(session.sub)
  const token = await getServiceToken(session, pdsUrl)
  const params = cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''
  const res = await fetch(`${apiUrl()}/timeline${params}`, {
    headers: { Authorization: `Bearer ${token}` },
  })
  if (!res.ok) {
    const body = await res.text().catch(() => '')
    throw new Error(`API error (${res.status})${body ? `: ${body}` : ''}`)
  }
  return res.json() as Promise<TimelinePage>
}

export async function migratePipupEntries(
  session: OAuthSession,
  publicationAtUri: string,
): Promise<{ migrated: number; skipped: number; errors: Array<{ title: string; reason: string }> }> {
  const did = session.sub
  const pdsUrl = await resolvePdsUrl(did)

  const res = await session.fetchHandler(
    `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(did)}&collection=social.pipup.blog.entry&limit=100`,
    { method: 'GET' },
  )
  if (!res.ok) throw new Error(`listRecords failed (${res.status})`)

  const { records } = (await res.json()) as {
    records: Array<{
      uri: string
      value: {
        content: string
        createdAt: string
        updatedAt?: string
        title?: string
        visibility: 'draft' | 'published'
      }
    }>
  }

  if (records.length === 0) return { migrated: 0, skipped: 0, errors: [] }

  const token = await getServiceToken(session, pdsUrl)
  let migrated = 0, skipped = 0
  const errors: Array<{ title: string; reason: string }> = []

  for (const record of records) {
    const rkey = record.uri.split('/').pop()!
    const title = record.value.title?.trim()
    if (!title) { skipped++; continue }

    const publishedAt = record.value.createdAt
    const updatedAt = record.value.updatedAt ?? publishedAt
    const visibility = record.value.visibility ?? 'published'
    const atUri = `at://${did}/site.standard.document/${rkey}`

    try {
      const pdsRes = await session.fetchHandler(
        `${pdsUrl}/xrpc/com.atproto.repo.putRecord`,
        {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            repo: did,
            collection: 'site.standard.document',
            rkey,
            record: {
              $type: 'site.standard.document',
              site: publicationAtUri,
              path: `/${rkey}`,
              title,
              publishedAt,
              updatedAt,
              content: { $type: 'pub.lemma.blog.entry', content: record.value.content },
            },
          }),
        },
      )
      if (!pdsRes.ok) {
        const body = await pdsRes.text().catch(() => '')
        errors.push({ title, reason: `PDS write failed (${pdsRes.status})${body ? `: ${body.slice(0, 120)}` : ''}` })
        continue
      }

      const lambdaRes = await fetch(`${apiUrl()}/posts`, {
        method: 'POST',
        headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
        body: JSON.stringify({
          atUri,
          site: publicationAtUri,
          rkey,
          path: `/${rkey}`,
          title,
          publishedAt,
          updatedAt,
          visibility,
          content: record.value.content,
        }),
      })
      if (lambdaRes.ok) migrated++
      else {
        const body = await lambdaRes.text().catch(() => '')
        errors.push({ title, reason: `Index failed (${lambdaRes.status})${body ? `: ${body.slice(0, 120)}` : ''}` })
      }
    } catch (err) {
      errors.push({ title, reason: err instanceof Error ? err.message : 'Unknown error' })
    }
  }

  return { migrated, skipped, errors }
}

export async function listSuggestions(session: OAuthSession): Promise<SuggestionItem[]> {
  const pdsUrl = await resolvePdsUrl(session.sub)
  const token = await getServiceToken(session, pdsUrl)
  const res = await fetch(`${apiUrl()}/suggestions`, {
    headers: { Authorization: `Bearer ${token}` },
  })
  if (!res.ok) {
    const body = await res.text().catch(() => '')
    throw new Error(`API error (${res.status})${body ? `: ${body}` : ''}`)
  }
  return res.json() as Promise<SuggestionItem[]>
}

export async function addSuggestions(
  session: OAuthSession,
  pubs: Array<{ pubAtUri: string; rkey: string; authorDid: string; pubName: string; pubUrl: string; pubDescription?: string; authorHandle?: string }>,
): Promise<{ added: number }> {
  const pdsUrl = await resolvePdsUrl(session.sub)
  const token = await getServiceToken(session, pdsUrl)
  const res = await fetch(`${apiUrl()}/suggestions`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ pubs }),
  })
  if (!res.ok) {
    const body = await res.text().catch(() => '')
    throw new Error(`API error (${res.status})${body ? `: ${body}` : ''}`)
  }
  return res.json() as Promise<{ added: number }>
}

export async function dismissSuggestion(session: OAuthSession, rkey: string): Promise<void> {
  const pdsUrl = await resolvePdsUrl(session.sub)
  const token = await getServiceToken(session, pdsUrl)
  const res = await fetch(`${apiUrl()}/suggestions?rkey=${encodeURIComponent(rkey)}`, {
    method: 'PATCH',
    headers: { Authorization: `Bearer ${token}` },
  })
  if (!res.ok) {
    const body = await res.text().catch(() => '')
    throw new Error(`API error (${res.status})${body ? `: ${body}` : ''}`)
  }
}

export async function getBskyFollows(did: string, cursor?: string): Promise<{ follows: Array<{ did: string }>; cursor?: string }> {
  const params = new URLSearchParams({ actor: did, limit: '100' })
  if (cursor) params.set('cursor', cursor)
  const res = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.graph.getFollows?${params}`)
  if (!res.ok) throw new Error(`getFollows failed (${res.status})`)
  return res.json() as Promise<{ follows: Array<{ did: string }>; cursor?: string }>
}

export async function replyToBskyPost(
  session: OAuthSession,
  text: string,
  parent: { uri: string; cid: string },
): Promise<{ uri: string }> {
  const did = session.sub
  const pdsUrl = await resolvePdsUrl(did)
  const res = await session.fetchHandler(
    `${pdsUrl}/xrpc/com.atproto.repo.createRecord`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        repo: did,
        collection: 'app.bsky.feed.post',
        record: {
          $type: 'app.bsky.feed.post',
          text,
          createdAt: new Date().toISOString(),
          reply: { root: parent, parent },
        },
      }),
    },
  )
  if (!res.ok) {
    const body = await res.text().catch(() => '')
    throw new Error(`Failed to reply (${res.status})${body ? `: ${body}` : ''}`)
  }
  return res.json() as Promise<{ uri: string }>
}
