// @ts-nocheck
import {readFile} from "node:fs/promises"
import {dirname, extname, join} from "node:path"
import {fileURLToPath} from "node:url"
import {load as loadHtml} from "cheerio"
import {serve} from "@hono/node-server"
import {serveStatic} from "@hono/node-server/serve-static"
import {Hono} from "hono"
import {request} from "@welshman/net"
import {
  Address,
  CLASSIFIED,
  EVENT_TIME,
  POLL,
  ROOM_META,
  THREAD,
  ZAP_GOAL,
  displayPubkey,
  getTagValue,
  normalizeRelayUrl,
  readRoomMeta,
} from "@welshman/util"

const __dirname = dirname(fileURLToPath(import.meta.url))
const buildDir = join(__dirname, "build")
const indexPath = join(buildDir, "index.html")

const RELAY_CACHE_TTL_MS = 5 * 60 * 1000
const NOSTR_CACHE_TTL_MS = 60 * 1000
const RELAY_TIMEOUT_MS = 1500
const NOSTR_TIMEOUT_MS = 1800

const staticTitles = new Map([
  ["/", "Redirecting"],
  ["/home", "Home"],
  ["/spaces", "Spaces"],
  ["/spaces/create", "Create a Space"],
  ["/chat", "Messages"],
  ["/join", "Join Space"],
  ["/people", "Find People"],
  ["/settings/about", "About"],
  ["/settings/profile", "Profile Settings"],
  ["/settings/content", "Content Settings"],
  ["/settings/privacy", "Privacy Settings"],
  ["/settings/relays", "Relay Settings"],
  ["/settings/alerts", "Alert Settings"],
  ["/settings/wallet", "Wallet Settings"],
])

const spaceSectionTitles = new Map([
  ["chat", "Space Chat"],
  ["recent", "Recent Activity"],
  ["threads", "Threads"],
  ["classifieds", "Classifieds"],
  ["calendar", "Calendar"],
  ["goals", "Goals"],
  ["polls", "Polls"],
])

const eventRouteKinds = new Map([
  ["threads", THREAD],
  ["goals", ZAP_GOAL],
  ["calendar", EVENT_TIME],
  ["classifieds", CLASSIFIED],
  ["polls", POLL],
])

const reservedSingleSegments = new Set([
  "home",
  "spaces",
  "space",
  "chat",
  "join",
  "people",
  "settings",
])

const relayInfoCache = new Map()
const roomInfoCache = new Map()
const eventCache = new Map()

const indexHtml = await readFile(indexPath, "utf8").catch(error => {
  console.error("Unable to start server: build/index.html is missing. Run `pnpm run build` first.")

  throw error
})

const defaults = getHtmlDefaults(indexHtml)
const app = new Hono()
const staticFiles = serveStatic({
  root: "./build",
  rewriteRequestPath: path => path.replace(/^\/+/, ""),
})

app.use("*", staticFiles)

app.get("*", async c => {
  const requestUrl = new URL(c.req.url)

  if (extname(requestUrl.pathname)) {
    return c.text("Not Found", 404)
  }

  const origin = getRequestOrigin(c.req.raw, requestUrl)
  const meta = await buildRouteMeta(requestUrl, origin)
  const html = renderHtml(indexHtml, meta)

  c.header("Cache-Control", "no-cache")

  return c.html(html)
})

app.notFound(c => c.text("Not Found", 404))

app.onError((error, c) => {
  console.error(error)

  return c.text("Internal Server Error", 500)
})

const port = Number.parseInt(process.env.PORT || "3000", 10)
const host = process.env.HOST || "0.0.0.0"

serve({fetch: app.fetch, hostname: host, port})
console.log(`Flotilla server listening on http://${host}:${port}`)

async function buildRouteMeta(requestUrl, origin) {
  const absoluteDefaultImage = toAbsoluteHttpUrl(defaults.image, origin)

  if (!absoluteDefaultImage) {
    throw new Error(`Default twitter:image must resolve to an absolute URL. Found: ${defaults.image}`)
  }

  const meta = {
    card: "summary",
    description: defaults.description,
    image: absoluteDefaultImage,
    site: defaults.site,
    title: defaults.title,
    type: "website",
    url: requestUrl.href,
  }

  const route = parseRoute(requestUrl.pathname)

  if (route.kind === "join") {
    return await buildJoinMeta(meta, requestUrl, origin)
  }

  if (route.kind === "static") {
    meta.title = route.title

    return meta
  }

  if (route.kind === "chat") {
    meta.title = getChatTitle(route.chat)

    return meta
  }

  if (route.kind === "bech32") {
    meta.title = "Opening Link"

    return meta
  }

  if (!route.relay) {
    return meta
  }

  const relayUrl = normalizeRelayParam(route.relay)
  const relayInfo = relayUrl ? await loadRelayInfo(relayUrl) : undefined
  const relayName = relayInfo?.name || (relayUrl ? getRelayDisplay(relayUrl) : "Space")
  const relayHttpUrl = relayUrl ? toRelayHttpUrl(relayUrl) : undefined

  if (relayInfo?.icon) {
    meta.image = relayInfo.icon
  }

  if (relayInfo?.description) {
    meta.description = relayInfo.description
  }

  if (route.kind === "space") {
    meta.title = relayName

    return meta
  }

  if (route.kind === "space-section") {
    meta.title = composeSpaceTitle(relayName, route.sectionTitle)

    return meta
  }

  if (route.kind === "room") {
    const roomInfo = relayUrl ? await loadRoomInfo(relayUrl, route.h) : undefined
    const roomName = roomInfo?.name || route.h

    meta.title = composeSpaceTitle(relayName, roomName)
    meta.description = roomInfo?.about || meta.description

    const roomImage = roomInfo?.picture
      ? toAbsoluteHttpUrl(roomInfo.picture, relayHttpUrl || origin)
      : undefined

    if (roomImage) {
      meta.image = roomImage
    }

    return meta
  }

  if (route.kind === "event") {
    const event = relayUrl
      ? await loadEventForRoute(relayUrl, route.section, route.identifier)
      : undefined
    const eventTitle = getEventTitle(route.section, event)

    meta.title = composeSpaceTitle(relayName, eventTitle)
    meta.description = getEventDescription(route.section, event, meta.description)

    const eventImage = getTagValue("image", event?.tags || [])
    const absoluteEventImage = eventImage
      ? toAbsoluteHttpUrl(eventImage, relayHttpUrl || origin)
      : undefined

    if (absoluteEventImage) {
      meta.image = absoluteEventImage
      meta.card = "summary_large_image"
    }

    return meta
  }

  return meta
}

function parseRoute(pathname) {
  const normalizedPath = normalizePathname(pathname)

  if (normalizedPath === "/join") {
    return {kind: "join"}
  }

  if (staticTitles.has(normalizedPath)) {
    return {kind: "static", title: staticTitles.get(normalizedPath)}
  }

  const segments = getPathSegments(normalizedPath)

  if (segments.length === 2 && segments[0] === "chat") {
    return {chat: segments[1], kind: "chat"}
  }

  if (segments.length === 1 && !reservedSingleSegments.has(segments[0])) {
    return {bech32: segments[0], kind: "bech32"}
  }

  if ((segments[0] === "spaces" || segments[0] === "space") && segments.length >= 2) {
    const relay = segments[1]

    if (segments.length === 2) {
      return {kind: "space", relay}
    }

    const section = segments[2]

    if (segments.length === 3) {
      if (spaceSectionTitles.has(section)) {
        return {
          kind: "space-section",
          relay,
          section,
          sectionTitle: spaceSectionTitles.get(section),
        }
      }

      return {h: section, kind: "room", relay}
    }

    if (segments.length === 4 && eventRouteKinds.has(section)) {
      return {
        identifier: segments[3],
        kind: "event",
        relay,
        section,
      }
    }
  }

  return {kind: "unknown"}
}

async function buildJoinMeta(meta, requestUrl, origin) {
  const relayUrl = parseInviteRelay(requestUrl)

  if (!relayUrl) {
    meta.title = staticTitles.get("/join") || "Join Space"

    return meta
  }

  const relayInfo = await loadRelayInfo(relayUrl)
  const relayDisplay = relayInfo?.name || getRelayDisplay(relayUrl)

  meta.title = `Invitation to join ${relayDisplay}`
  meta.description = relayInfo?.description || `Join this Flotilla space on ${relayDisplay}.`
  meta.image = relayInfo?.icon || meta.image
  meta.url = requestUrl.href
  meta.site = defaults.site

  return meta
}

function getChatTitle(chat) {
  if (!chat) {
    return "Chat"
  }

  const peers = chat
    .split(",")
    .map(part => part.trim())
    .filter(Boolean)

  if (peers.length === 1) {
    return `Chat with ${displayPubkey(peers[0])}`
  }

  if (peers.length > 1) {
    return `Group chat (${peers.length})`
  }

  return "Chat"
}

function getEventTitle(section, event) {
  if (section === "threads") {
    return getTagValue("title", event?.tags || []) || "Thread"
  }

  if (section === "calendar") {
    return getTagValue("title", event?.tags || []) || "Event"
  }

  if (section === "classifieds") {
    return getTagValue("title", event?.tags || []) || "Listing"
  }

  if (section === "goals") {
    return event?.content?.trim() || getTagValue("summary", event?.tags || []) || "Goal"
  }

  if (section === "polls") {
    return getTagValue("title", event?.tags || []) || "Poll"
  }

  return "Event"
}

function getEventDescription(section, event, fallback) {
  const summary =
    getTagValue("summary", event?.tags || []) || getTagValue("description", event?.tags || [])

  if (summary) {
    return clip(summary, 220)
  }

  if (event?.content?.trim()) {
    return clip(event.content.trim(), 220)
  }

  if (section === "threads") {
    return "Read this thread in Flotilla."
  }

  if (section === "goals") {
    return "Track this goal in Flotilla."
  }

  if (section === "calendar") {
    return "View this calendar event in Flotilla."
  }

  if (section === "classifieds") {
    return "Browse this listing in Flotilla."
  }

  if (section === "polls") {
    return "Take this poll in Flotilla."
  }

  return fallback
}

function composeSpaceTitle(spaceName, leafTitle) {
  const cleanedSpace = spaceName?.trim()
  const cleanedLeaf = leafTitle?.trim()

  if (cleanedSpace && cleanedLeaf) {
    return `${cleanedSpace} / ${cleanedLeaf}`
  }

  return cleanedLeaf || cleanedSpace || defaults.title
}

async function loadRelayInfo(relayUrl) {
  const cached = getCachedValue(relayInfoCache, relayUrl)

  if (cached) {
    return cached
  }

  const relayHttpUrl = toRelayHttpUrl(relayUrl)

  if (!relayHttpUrl) {
    setCachedValue(relayInfoCache, relayUrl, undefined, RELAY_CACHE_TTL_MS)

    return undefined
  }

  const controller = new AbortController()
  const timeout = setTimeout(() => controller.abort(), RELAY_TIMEOUT_MS)

  let value

  try {
    const response = await fetch(relayHttpUrl, {
      headers: {Accept: "application/nostr+json"},
      signal: controller.signal,
    })

    if (response.ok) {
      const json = await response.json()
      const name = typeof json.name === "string" ? json.name.trim() : ""
      const description = typeof json.description === "string" ? json.description.trim() : ""
      const icon = typeof json.icon === "string" ? toAbsoluteHttpUrl(json.icon, relayHttpUrl) : undefined

      value = {
        description: description || undefined,
        icon,
        name: name || undefined,
      }
    }
  } catch {
    value = undefined
  } finally {
    clearTimeout(timeout)
  }

  setCachedValue(relayInfoCache, relayUrl, value, RELAY_CACHE_TTL_MS)

  return value
}

async function loadRoomInfo(relayUrl, h) {
  const cacheKey = `${relayUrl}|${h}`
  const cached = getCachedValue(roomInfoCache, cacheKey)

  if (cached !== undefined) {
    return cached
  }

  const events = await requestEvents(relayUrl, [{"#d": [h], kinds: [ROOM_META], limit: 20}])
  const roomMetas = []

  for (const event of events) {
    try {
      const roomMeta = readRoomMeta(event)

      if (roomMeta.h === h) {
        roomMetas.push(roomMeta)
      }
    } catch {
      // Ignore malformed room metadata.
    }
  }

  const latest = roomMetas.sort((a, b) => b.event.created_at - a.event.created_at)[0]
  const roomInfo = latest
    ? {
        about: latest.about,
        name: latest.name,
        picture: latest.picture,
      }
    : undefined

  setCachedValue(roomInfoCache, cacheKey, roomInfo, NOSTR_CACHE_TTL_MS)

  return roomInfo
}

async function loadEventForRoute(relayUrl, section, identifier) {
  const kind = eventRouteKinds.get(section)

  if (!kind || !identifier) {
    return undefined
  }

  const cacheKey = `${relayUrl}|${section}|${identifier}`
  const cached = getCachedValue(eventCache, cacheKey)

  if (cached !== undefined) {
    return cached
  }

  const filters = getEventFilters(kind, identifier)
  const events = filters.length > 0 ? await requestEvents(relayUrl, filters) : []
  const event = events[0]

  setCachedValue(eventCache, cacheKey, event, NOSTR_CACHE_TTL_MS)

  return event
}

function getEventFilters(kind, identifier) {
  if (kind === EVENT_TIME || kind === CLASSIFIED) {
    try {
      const address = Address.from(identifier)

      return [
        {
          "#d": [address.identifier],
          authors: [address.pubkey],
          kinds: [address.kind],
          limit: 1,
        },
      ]
    } catch {
      return [{ids: [identifier], kinds: [kind], limit: 1}]
    }
  }

  return [{ids: [identifier], kinds: [kind], limit: 1}]
}

async function requestEvents(relayUrl, filters) {
  const controller = new AbortController()
  const timeout = setTimeout(() => controller.abort(), NOSTR_TIMEOUT_MS)

  try {
    return await request({
      autoClose: true,
      filters,
      relays: [relayUrl],
      signal: controller.signal,
    })
  } catch {
    return []
  } finally {
    clearTimeout(timeout)
  }
}

function renderHtml(html, meta) {
  const $ = loadHtml(html)

  upsertTitle($, meta.title)
  upsertMetaTag($, "name", "description", meta.description)
  upsertMetaTag($, "name", "og:url", meta.url)
  upsertMetaTag($, "name", "og:type", meta.type)
  upsertMetaTag($, "name", "og:title", meta.title)
  upsertMetaTag($, "name", "og:description", meta.description)
  upsertMetaTag($, "name", "twitter:card", meta.card)
  upsertMetaTag($, "name", "twitter:site", meta.site)
  upsertMetaTag($, "name", "twitter:title", meta.title)
  upsertMetaTag($, "name", "twitter:description", meta.description)
  upsertMetaTag($, "name", "twitter:image", meta.image)

  upsertMetaTag($, "property", "og:url", meta.url)
  upsertMetaTag($, "property", "og:type", meta.type)
  upsertMetaTag($, "property", "og:title", meta.title)
  upsertMetaTag($, "property", "og:description", meta.description)
  upsertMetaTag($, "property", "og:image", meta.image)

  return $.html()
}

function upsertTitle($, value) {
  let titleTag = $("head > title").first()

  if (titleTag.length === 0) {
    $("head").prepend("<title></title>")
    titleTag = $("head > title").first()
  }

  titleTag.text(value)
}

function upsertMetaTag($, attribute, key, content) {
  const selector = `meta[${attribute}="${key}"]`
  let metaTag = $(selector).first()

  if (metaTag.length === 0) {
    metaTag = $("<meta>").attr(attribute, key)
    $("head").append(metaTag)
  }

  metaTag.attr("content", content)
}

function getHtmlDefaults(html) {
  const $ = loadHtml(html)

  return {
    description: readRequiredMetaContent($, "og:description"),
    image: readRequiredMetaContent($, "twitter:image"),
    site: readRequiredMetaContent($, "twitter:site"),
    title: readRequiredMetaContent($, "og:title"),
  }
}

function readRequiredMetaContent($, key) {
  const content = readMetaContent($, key)

  if (!content) {
    throw new Error(`Missing required meta tag ${key} in build/index.html. Ensure it exists in src/app.html.`)
  }

  return content
}

function readMetaContent($, key) {
  const byName = $(`meta[name="${key}"]`).attr("content")

  if (typeof byName === "string" && byName.trim()) {
    return byName.trim()
  }

  const byProperty = $(`meta[property="${key}"]`).attr("content")

  return typeof byProperty === "string" && byProperty.trim() ? byProperty.trim() : undefined
}

function parseInviteRelay(requestUrl) {
  const relay = requestUrl.searchParams.get("r") || requestUrl.searchParams.get("relay")

  if (!relay) {
    return undefined
  }

  return normalizeRelayParam(relay)
}

function normalizeRelayParam(value) {
  const decoded = value.trim()

  if (!decoded) {
    return undefined
  }

  const hasProtocol = /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(decoded)
  const withProtocol = hasProtocol ? decoded : `wss://${decoded}`

  try {
    const normalized = normalizeRelayUrl(withProtocol)

    if (normalized.startsWith("ws://") || normalized.startsWith("wss://")) {
      return normalized.replace(/\/+$/, "")
    }
  } catch {
    // Ignore malformed relay URLs.
  }

  return undefined
}

function toRelayHttpUrl(relayUrl) {
  if (relayUrl.startsWith("wss://")) {
    return `https://${relayUrl.slice(6)}`
  }

  if (relayUrl.startsWith("ws://")) {
    return `http://${relayUrl.slice(5)}`
  }

  return undefined
}

function getRelayDisplay(relayUrl) {
  const relayHttpUrl = toRelayHttpUrl(relayUrl)

  if (!relayHttpUrl) {
    return relayUrl
  }

  try {
    return new URL(relayHttpUrl).host
  } catch {
    return relayUrl
  }
}

function toAbsoluteHttpUrl(value, baseUrl) {
  try {
    const parsed = new URL(value, baseUrl)

    if (parsed.protocol === "http:" || parsed.protocol === "https:") {
      return parsed.href
    }
  } catch {
    // Ignore malformed URLs.
  }

  return undefined
}

function getRequestOrigin(req, requestUrl) {
  const protocol = firstHeaderValue(req.headers.get("x-forwarded-proto")) || requestUrl.protocol.slice(0, -1)
  const host =
    firstHeaderValue(req.headers.get("x-forwarded-host")) ||
    req.headers.get("host") ||
    requestUrl.host ||
    "localhost"

  return `${protocol}://${host}`
}

function firstHeaderValue(value) {
  if (typeof value === "string") {
    return value.split(",")[0].trim()
  }

  return undefined
}

function normalizePathname(pathname) {
  const cleanPath = pathname.replace(/\/+/g, "/")

  if (cleanPath === "/") {
    return "/"
  }

  return cleanPath.replace(/\/+$/, "") || "/"
}

function getPathSegments(pathname) {
  const normalized = normalizePathname(pathname)

  if (normalized === "/") {
    return []
  }

  return normalized
    .slice(1)
    .split("/")
    .map(decodeSegment)
}

function decodeSegment(value) {
  try {
    return decodeURIComponent(value)
  } catch {
    return value
  }
}

function clip(text, maxLength) {
  if (text.length <= maxLength) {
    return text
  }

  return `${text.slice(0, maxLength - 1).trimEnd()}…`
}

function getCachedValue(cache, key) {
  const cached = cache.get(key)

  if (!cached) {
    return undefined
  }

  if (cached.expiresAt <= Date.now()) {
    cache.delete(key)

    return undefined
  }

  return cached.value
}

function setCachedValue(cache, key, value, ttlMs) {
  cache.set(key, {expiresAt: Date.now() + ttlMs, value})
}
