import {HTTPException} from 'hono/http-exception'

import {guardedFetch, readCapped} from '../../lib/fetch-guard'

// an entity found on a shared page: a place, a dated event, or the page
// itself — each kind carries only the fields it can actually populate
type LinkEntityBase = {
  name: string
  description: string | null
  imageUrl: string | null
}

export type LinkEntity =
  | (LinkEntityBase & {
      kind: 'place'
      addressHint: string | null
      category: string | null
      latitude: number | null
      longitude: number | null
    })
  | (LinkEntityBase & {
      kind: 'event'
      addressHint: string | null
      latitude: number | null
      longitude: number | null
      startsAt: string | null
      endsAt: string | null
      venueName: string | null
    })
  | (LinkEntityBase & {kind: 'page'})

const MAX_BODY_BYTES = 1024 * 1024
const MAX_ENTITIES = 10

// strip volatile parts so the same share dedups to one source row
export const normalizeShareUrl = (raw: string): string => {
  const url = new URL(raw)
  url.hash = ''
  url.hostname = url.hostname.toLowerCase()
  for (const key of [...url.searchParams.keys()]) {
    if (key.startsWith('utm_') || key === 'fbclid' || key === 'igsh')
      url.searchParams.delete(key)
  }
  return url.toString()
}

// fetch a shared page's html through the ssrf-guarded fetcher
export const fetchPage = async (rawUrl: string): Promise<string> => {
  const res = await guardedFetch(
    rawUrl,
    'text/html,application/xhtml+xml',
  )
  if (!res?.ok)
    throw new HTTPException(502, {message: 'unreachable_url'})
  const contentType = res.headers.get('content-type') ?? ''
  if (!contentType.includes('html'))
    throw new HTTPException(400, {message: 'unsupported_url'})
  return new TextDecoder().decode(
    await readCapped(res, MAX_BODY_BYTES),
  )
}

const decodeEntities = (s: string): string =>
  s
    .replace(/&#x([0-9a-f]+);/gi, (_, hex: string) =>
      String.fromCodePoint(parseInt(hex, 16)),
    )
    .replace(/&#(\d+);/g, (_, dec: string) =>
      String.fromCodePoint(Number(dec)),
    )
    .replace(/&amp;/g, '&')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&quot;/g, '"')
    .replace(/&apos;/g, "'")
    .replace(/&nbsp;/g, ' ')

const parseMetaTags = (html: string): Map<string, string> => {
  const out = new Map<string, string>()
  for (const tag of html.matchAll(/<meta\s[^>]*>/gi)) {
    const attrs = tag[0]
    const key = attrs.match(
      /(?:property|name)=["']([^"']+)["']/i,
    )?.[1]
    const value = attrs.match(/content=["']([^"']*)["']/i)?.[1]
    if (key && value && !out.has(key.toLowerCase()))
      out.set(key.toLowerCase(), decodeEntities(value))
  }
  return out
}

const parseTitle = (html: string): string | null => {
  const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)
  return m?.[1] ? decodeEntities(m[1]).trim() || null : null
}

type JsonLdNode = Record<string, unknown>

const parseJsonLdBlocks = (html: string): JsonLdNode[] => {
  const nodes: JsonLdNode[] = []
  const blocks = html.matchAll(
    /<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi,
  )
  for (const [, raw] of blocks) {
    try {
      const parsed: unknown = JSON.parse(raw!)
      for (const node of flattenJsonLd(parsed)) nodes.push(node)
    } catch {
      // malformed json-ld is common in the wild — skip the block
    }
  }
  return nodes
}

function* flattenJsonLd(value: unknown): Generator<JsonLdNode> {
  if (Array.isArray(value)) {
    for (const item of value) yield* flattenJsonLd(item)
    return
  }
  if (value == null || typeof value !== 'object') return
  const node = value as JsonLdNode
  if (Array.isArray(node['@graph']))
    yield* flattenJsonLd(node['@graph'])
  if (typeof node['@type'] !== 'undefined') yield node
}

const typeNames = (node: JsonLdNode): string[] => {
  const t = node['@type']
  const list = Array.isArray(t) ? t : [t]
  return list.filter((x): x is string => typeof x === 'string')
}

const isEventType = (node: JsonLdNode): boolean =>
  typeNames(node).some((t) => t === 'Event' || t.endsWith('Event'))

const isPlaceType = (node: JsonLdNode): boolean =>
  typeNames(node).length > 0 && ('address' in node || 'geo' in node)

const text = (value: unknown): string | null =>
  typeof value === 'string' && value.trim()
    ? decodeEntities(value.trim())
    : null

const formatAddress = (value: unknown): string | null => {
  if (typeof value === 'string') return text(value)
  if (value == null || typeof value !== 'object') return null
  const a = value as JsonLdNode
  const parts = [
    a.streetAddress,
    a.addressLocality,
    a.addressRegion,
    a.postalCode,
    a.addressCountry,
  ]
    .map(text)
    .filter(Boolean)
  return parts.length ? parts.join(', ') : null
}

const coord = (value: unknown): number | null => {
  const n =
    typeof value === 'string' ? Number(value) : (value as number)
  return typeof n === 'number' && Number.isFinite(n) ? n : null
}

// json-ld image is a string, an array, or an ImageObject {url}
const imageFrom = (value: unknown): string | null => {
  if (typeof value === 'string') return value.trim() || null
  if (Array.isArray(value)) {
    for (const item of value) {
      const found = imageFrom(item)
      if (found) return found
    }
    return null
  }
  if (value && typeof value === 'object') {
    const node = value as JsonLdNode
    return imageFrom(node.url ?? node.contentUrl)
  }
  return null
}

const hasUtcOffset = /[zZ]|[+-]\d{2}:?\d{2}$/

const isoDate = (value: unknown): string | null => {
  const raw = text(value)
  if (!raw) return null
  // offsetless json-ld datetimes are common; treat them as utc (an
  // approximation) rather than the server's local zone
  const normalized =
    raw.includes('T') && !hasUtcOffset.test(raw) ? `${raw}Z` : raw
  const date = new Date(normalized)
  return Number.isNaN(date.getTime()) ? null : date.toISOString()
}

const placeEntity = (node: JsonLdNode): LinkEntity | null => {
  const name = text(node.name)
  if (!name) return null
  const geo = (node.geo ?? {}) as JsonLdNode
  return {
    kind: 'place',
    name,
    description: text(node.description),
    addressHint: formatAddress(node.address),
    category: typeNames(node)[0]?.toLowerCase() ?? null,
    latitude: coord(geo.latitude),
    longitude: coord(geo.longitude),
    imageUrl: imageFrom(node.image),
  }
}

const eventEntity = (node: JsonLdNode): LinkEntity | null => {
  const name = text(node.name)
  if (!name) return null
  const location = (node.location ?? {}) as JsonLdNode
  const geo = (location.geo ?? {}) as JsonLdNode
  return {
    kind: 'event',
    name,
    description: text(node.description),
    addressHint:
      formatAddress(location.address) ?? text(node.location),
    latitude: coord(geo.latitude),
    longitude: coord(geo.longitude),
    startsAt: isoDate(node.startDate),
    endsAt: isoDate(node.endDate),
    venueName: text(location.name),
    imageUrl: imageFrom(node.image),
  }
}

// json-ld events/places first; otherwise degrade to an og/title page entity
export const parseLinkEntities = (html: string): LinkEntity[] => {
  const entities: LinkEntity[] = []
  for (const node of parseJsonLdBlocks(html)) {
    const entity = isEventType(node)
      ? eventEntity(node)
      : isPlaceType(node)
        ? placeEntity(node)
        : null
    if (entity) entities.push(entity)
    if (entities.length >= MAX_ENTITIES) break
  }
  if (entities.length) return entities

  const meta = parseMetaTags(html)
  const name = meta.get('og:title') ?? parseTitle(html)
  if (!name) return []
  return [
    {
      kind: 'page',
      name,
      description:
        meta.get('og:description') ?? meta.get('description') ?? null,
      imageUrl:
        meta.get('og:image') ?? meta.get('twitter:image') ?? null,
    },
  ]
}

const absoluteUrl = (raw: string, base: string): string | null => {
  try {
    return new URL(raw, base).toString()
  } catch {
    return null
  }
}

export const extractFromUrl = async (
  url: string,
): Promise<LinkEntity[]> => {
  const html = await fetchPage(url)
  // resolve relative image urls against the page they were found on
  return parseLinkEntities(html).map((entity) => ({
    ...entity,
    imageUrl: entity.imageUrl
      ? absoluteUrl(entity.imageUrl, url)
      : null,
  }))
}
