import {
  normalizeTags,
  type ExtractCrumbDraft,
  type PlaceDraft,
} from '@crumbs/core/schemas'
import {eq, sql} from 'drizzle-orm'
import {HTTPException} from 'hono/http-exception'

import {db, schema} from '../../db/client'
import {geocoder} from '../../lib/geocoder'
import {
  publicImageUrl,
  storeSourceAsset,
  storeSourceDisplayImage,
} from '../../lib/source-image'
import {toEventResponse} from '../crumbs'
import {getOrCreateEvent} from '../events'
import {resolvePlaceRef, toCrumbPlace} from '../places'
import {
  extractFromUrl,
  normalizeShareUrl,
  type LinkEntity,
} from './link'
import {visionProvider, type ExtractedEntity} from './provider'

const MAX_IMAGE_BYTES = 5 * 1024 * 1024

export const extractLinkDrafts = async (
  userId: string,
  rawUrl: string,
): Promise<ExtractCrumbDraft[]> => {
  const url = normalizeShareUrl(rawUrl)
  const entities = await extractFromUrl(url)
  // best-effort: a missing/unfetchable og:image just leaves the crumb imageless
  const found = entities.find((e) => e.imageUrl)?.imageUrl ?? null
  const imageRef = found ? await storeSourceDisplayImage(found) : null
  const sourceId = await createSource(userId, 'link', url, {imageRef})
  const imageUrl = publicImageUrl(imageRef)
  return Promise.all(
    entities.map((e) =>
      linkEntityToDraft(e, sourceId, url, imageUrl),
    ),
  )
}

export const extractScreengrabDrafts = async (
  userId: string,
  image: {image: string; mimeType: string},
  apiKey: string | undefined,
): Promise<ExtractCrumbDraft[]> => {
  if (!apiKey)
    throw new HTTPException(400, {message: 'missing_llm_key'})
  if ((image.image.length * 3) / 4 > MAX_IMAGE_BYTES)
    throw new HTTPException(413, {message: 'image_too_large'})

  const entities = await visionProvider.extract(
    {base64: image.image, mimeType: image.mimeType},
    apiKey,
  )
  // persist the screengrab itself as the source's provenance + display image
  const assetRef = await storeSourceAsset(
    userId,
    Buffer.from(image.image, 'base64'),
  )
  const sourceId = await createSource(userId, 'screengrab', null, {
    assetRef,
  })
  const imageUrl = publicImageUrl(assetRef)
  return Promise.all(
    entities.map((e) => visionEntityToDraft(e, sourceId, imageUrl)),
  )
}

const linkEntityToDraft = (
  entity: LinkEntity,
  sourceId: string,
  sharedUrl: string,
  imageUrl: string | null,
): Promise<ExtractCrumbDraft> => {
  const seed = {
    name: entity.name,
    note: entity.description,
    tags: normalizeTags(
      entity.kind === 'place' && entity.category
        ? [entity.category]
        : [],
    ),
    source: 'link' as const,
    sourceId,
    imageUrl,
  }

  // a bare page has no place to resolve — skip the geocoder entirely
  if (entity.kind === 'page')
    return Promise.resolve({...emptyDraft, ...seed})

  const hasCoords =
    entity.latitude != null && entity.longitude != null
  return assembleDraft({
    ...seed,
    placeQuery: [
      (entity.kind === 'event' ? entity.venueName : null) ??
        entity.name,
      entity.addressHint,
    ]
      .filter(Boolean)
      .join(', '),
    event:
      entity.kind === 'event'
        ? {
            startsAt: entity.startsAt
              ? new Date(entity.startsAt)
              : null,
            endsAt: entity.endsAt ? new Date(entity.endsAt) : null,
            url: sharedUrl,
          }
        : null,
    provisional: {
      name: entity.name,
      address: entity.addressHint ?? undefined,
      latitude: hasCoords ? entity.latitude : null,
      longitude: hasCoords ? entity.longitude : null,
    },
  })
}

const visionEntityToDraft = (
  entity: ExtractedEntity,
  sourceId: string,
  imageUrl: string | null,
): Promise<ExtractCrumbDraft> => {
  const startsAt = entity.startsAt ? new Date(entity.startsAt) : null
  const validStart =
    startsAt && !Number.isNaN(startsAt.getTime()) ? startsAt : null
  return assembleDraft({
    name: entity.name,
    note: null,
    // keep extracted tags few and high-level (the prompt steers breadth)
    tags: normalizeTags(entity.tags, 3),
    source: 'screengrab',
    sourceId,
    imageUrl,
    placeQuery: [entity.name, entity.addressHint]
      .filter(Boolean)
      .join(', '),
    event: validStart
      ? {startsAt: validStart, endsAt: null, url: null}
      : null,
    provisional: entity.addressHint
      ? {name: entity.name, address: entity.addressHint}
      : null,
  })
}

// the source-agnostic shape both extractors resolve into; assembleDraft owns
// the shared geocode → event/place/provisional decision
type DraftSeed = {
  name: string
  note: string | null
  tags: string[]
  source: ExtractCrumbDraft['source']
  sourceId: string
  imageUrl: string | null
  placeQuery: string
  event: {
    startsAt: Date | null
    endsAt: Date | null
    url: string | null
  } | null
  provisional: PlaceDraft | null
}

const assembleDraft = async (
  seed: DraftSeed,
): Promise<ExtractCrumbDraft> => {
  const base = {
    ...emptyDraft,
    name: seed.name,
    note: seed.note,
    tags: seed.tags,
    source: seed.source,
    sourceId: seed.sourceId,
    imageUrl: seed.imageUrl,
  }

  const [hit] = await geocoder.search(seed.placeQuery, {limit: 1})
  const place = hit
    ? await resolvePlaceRef(hit.ref, {
        raw: hit.raw,
        queryText: seed.placeQuery,
      })
    : null

  if (seed.event) {
    const event = await getOrCreateEvent({
      name: seed.name,
      placeId: place?.id ?? null,
      startsAt: seed.event.startsAt,
      endsAt: seed.event.endsAt,
      url: seed.event.url,
    })
    return {
      ...base,
      eventId: event.id,
      event: toEventResponse(event),
      place: place ? toCrumbPlace(place) : null,
      resolved: place != null,
    }
  }

  if (place)
    return {
      ...base,
      placeId: place.id,
      place: toCrumbPlace(place),
      resolved: true,
    }

  return {...base, placeDraft: seed.provisional}
}

const emptyDraft = {
  note: null,
  tags: [],
  sourceId: null,
  imageUrl: null,
  placeId: null,
  place: null,
  placeDraft: null,
  eventId: null,
  event: null,
  resolved: false,
} satisfies Partial<ExtractCrumbDraft>

const createSource = async (
  userId: string,
  kind: 'screengrab' | 'link',
  url: string | null = null,
  refs: {imageRef?: string | null; assetRef?: string | null} = {},
): Promise<string> => {
  const inserted = await db
    .insert(schema.sources)
    .values({
      kind,
      url,
      sharedById: userId,
      imageRef: refs.imageRef ?? null,
      assetRef: refs.assetRef ?? null,
    })
    .onConflictDoNothing({
      target: schema.sources.url,
      where: sql`url is not null`,
    })
    .returning({id: schema.sources.id})
  if (inserted[0]) return inserted[0].id
  const [existing] = await db
    .select({id: schema.sources.id})
    .from(schema.sources)
    .where(eq(schema.sources.url, url!))
    .limit(1)
  if (!existing) throw new Error('failed to create source')
  return existing.id
}
