import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"
import cors from "@fastify/cors"
import fastifyStatic from "@fastify/static"
import replyFrom from "@fastify/reply-from"
import fs from "fs"
import path from "path"
import { fetch } from "undici"
import type { Logger } from "../logger"
import { WorkspaceManager } from "../workspaces/manager"
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"

import type { SettingsService } from "../settings/service"
import { FileSystemBrowser } from "../filesystem/browser"
import { EventBus } from "../events/bus"
import { registerWorkspaceRoutes } from "./routes/workspaces"
import { registerSettingsRoutes } from "./routes/settings"
import { registerFilesystemRoutes } from "./routes/filesystem"
import { registerMetaRoutes } from "./routes/meta"
import { registerEventRoutes } from "./routes/events"
import { registerStorageRoutes } from "./routes/storage"
import { registerPluginRoutes } from "./routes/plugin"
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
import { registerWorktreeRoutes } from "./routes/worktrees"
import { ServerMeta } from "../api-types"
import { InstanceStore } from "../storage/instance-store"
import { BackgroundProcessManager } from "../background-processes/manager"
import type { AuthManager } from "../auth/manager"
import { registerAuthRoutes } from "./routes/auth"
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"

interface HttpServerDeps {
  bindHost: string
  bindPort: number
  /** When bindPort is 0, try this first. */
  defaultPort: number
  protocol: "http" | "https"
  httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
  workspaceManager: WorkspaceManager
  settings: SettingsService
  fileSystemBrowser: FileSystemBrowser
  eventBus: EventBus
  serverMeta: ServerMeta
  instanceStore: InstanceStore
  authManager: AuthManager
  uiStaticDir: string
  uiDevServerUrl?: string
  logger: Logger
}

interface HttpServerStartResult {
  port: number
  url: string
  displayHost: string
}

export function createHttpServer(deps: HttpServerDeps) {
  // Fastify's type-level RawServer inference gets noisy when toggling HTTP vs HTTPS.
  // We keep the runtime behavior correct and cast the instance to a generic FastifyInstance.
  const app = Fastify(
    ({
      logger: false,
      ...(deps.protocol === "https" && deps.httpsOptions ? { https: deps.httpsOptions } : {}),
    } as unknown) as any,
  ) as unknown as FastifyInstance
  const proxyLogger = deps.logger.child({ component: "proxy" })
  const apiLogger = deps.logger.child({ component: "http" })
  const sseLogger = deps.logger.child({ component: "sse" })

  const sseClients = new Set<() => void>()
  const registerSseClient = (cleanup: () => void) => {
    sseClients.add(cleanup)
    return () => sseClients.delete(cleanup)
  }
  const closeSseClients = () => {
    for (const cleanup of Array.from(sseClients)) {
      cleanup()
    }
    sseClients.clear()
  }

  app.addHook("onRequest", (request, _reply, done) => {
    ;(request as FastifyRequest & { __logMeta?: { start: bigint } }).__logMeta = {
      start: process.hrtime.bigint(),
    }
    done()
  })

  app.addHook("onResponse", (request, reply, done) => {
    const meta = (request as FastifyRequest & { __logMeta?: { start: bigint } }).__logMeta
    const durationMs = meta ? Number((process.hrtime.bigint() - meta.start) / BigInt(1_000_000)) : undefined
    const base = {
      method: request.method,
      url: request.url,
      status: reply.statusCode,
      durationMs,
    }
    apiLogger.debug(base, "HTTP request completed")
    if (apiLogger.isLevelEnabled("trace")) {
      apiLogger.trace({ ...base, params: request.params, query: request.query, body: request.body }, "HTTP request payload")
    }
    done()
  })

  const allowedDevOrigins = new Set(["http://localhost:3000", "http://127.0.0.1:3000"])
  const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")

  const getSelfOrigins = (): Set<string> => {
    const origins = new Set<string>()
    const candidates: Array<string | undefined> = [deps.serverMeta.localUrl, deps.serverMeta.remoteUrl]
    for (const candidate of candidates) {
      if (!candidate) continue
      try {
        origins.add(new URL(candidate).origin)
      } catch {
        // ignore
      }
    }
    for (const addr of deps.serverMeta.addresses ?? []) {
      try {
        origins.add(new URL(addr.remoteUrl).origin)
      } catch {
        // ignore
      }
    }
    return origins
  }

  app.register(cors, {
    origin: (origin, cb) => {
      if (!origin) {
        cb(null, true)
        return
      }

      const selfOrigins = getSelfOrigins()
      if (selfOrigins.has(origin)) {
        cb(null, true)
        return
      }

       if (allowedDevOrigins.has(origin)) {
         cb(null, true)
         return
       }

       // When we bind to a non-loopback host (e.g., 0.0.0.0 or LAN IP), allow cross-origin UI access.
       if (deps.bindHost === "0.0.0.0" || !isLoopbackHost(deps.bindHost)) {
         cb(null, true)
         return
       }


      cb(null, false)
    },
    credentials: true,
  })

  app.register(replyFrom, {
    contentTypesToEncode: [],
    undici: {
      connections: 16,
      pipelining: 1,
      bodyTimeout: 0,
      headersTimeout: 0,
    },
  })

  const backgroundProcessManager = new BackgroundProcessManager({
    workspaceManager: deps.workspaceManager,
    eventBus: deps.eventBus,
    logger: deps.logger.child({ component: "background-processes" }),
  })

  registerAuthRoutes(app, { authManager: deps.authManager })

  app.addHook("preHandler", (request, reply, done) => {
    const rawUrl = request.raw.url ?? request.url
    const pathname = (rawUrl.split("?")[0] ?? "").trim()

    const publicApiPaths = new Set(["/api/auth/login", "/api/auth/token", "/api/auth/status", "/api/auth/logout"])
    const publicPagePaths = new Set(["/login"])
    if (deps.authManager.isTokenBootstrapEnabled()) {
      publicPagePaths.add("/auth/token")
    }

    if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname)) {
      done()
      return
    }

    const session = deps.authManager.getSessionFromRequest(request)

    const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/")
    if (requiresAuthForApi && !session) {
      // Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
      const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/)
      if (pluginMatch) {
        const workspaceId = pluginMatch[1]
        const expected = deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
        const provided = Array.isArray(request.headers.authorization)
          ? request.headers.authorization[0]
          : request.headers.authorization

        if (expected && provided && provided === expected) {
          done()
          return
        }
      }

      sendUnauthorized(request, reply)
      return
    }

    if (!session && wantsHtml(request)) {
      reply.redirect("/login")
      return
    }

    done()
  })

  app.get("/", async (request, reply) => {
    const session = deps.authManager.getSessionFromRequest(request)
    if (!session) {
      reply.redirect("/login")
      return
    }

    if (deps.uiDevServerUrl) {
      await proxyToDevServer(request, reply, deps.uiDevServerUrl)
      return
    }

    const uiDir = deps.uiStaticDir
    const indexPath = path.join(uiDir, "index.html")
    if (uiDir && fs.existsSync(indexPath)) {
      reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"))
      return
    }

    reply.code(404).send({ message: "UI bundle missing" })
  })

  registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
  registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger })
  registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
  registerMetaRoutes(app, { serverMeta: deps.serverMeta })
  registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
  registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
  registerStorageRoutes(app, {
    instanceStore: deps.instanceStore,
    eventBus: deps.eventBus,
    workspaceManager: deps.workspaceManager,
  })
  registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger })
  registerBackgroundProcessRoutes(app, { backgroundProcessManager })
  registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })


  if (deps.uiDevServerUrl) {
    setupDevProxy(app, deps.uiDevServerUrl, deps.authManager)
  } else {
    setupStaticUi(app, deps.uiStaticDir, deps.authManager)
  }

  return {
    instance: app,
    start: async (): Promise<HttpServerStartResult> => {
      const attemptListen = async (requestedPort: number) => {
        const addressInfo = await app.listen({ port: requestedPort, host: deps.bindHost })
        return { addressInfo, requestedPort }
      }

      const autoPortRequested = deps.bindPort === 0
      const primaryPort = autoPortRequested ? deps.defaultPort : deps.bindPort

      const shouldRetryWithEphemeral = (error: unknown) => {
        if (!autoPortRequested) return false
        const err = error as NodeJS.ErrnoException | undefined
        return Boolean(err && err.code === "EADDRINUSE")
      }

      let listenResult

      try {
        listenResult = await attemptListen(primaryPort)
      } catch (error) {
        if (!shouldRetryWithEphemeral(error)) {
          throw error
        }
        deps.logger.warn({ err: error, port: primaryPort }, "Preferred port unavailable, retrying on ephemeral port")
        listenResult = await attemptListen(0)
      }

      let actualPort = listenResult.requestedPort

      if (typeof listenResult.addressInfo === "string") {
        try {
          const parsed = new URL(listenResult.addressInfo)
          actualPort = Number(parsed.port) || listenResult.requestedPort
        } catch {
          actualPort = listenResult.requestedPort
        }
      } else {
        const address = app.server.address()
        if (typeof address === "object" && address) {
          actualPort = address.port
        }
      }

      const displayHost = deps.bindHost === "127.0.0.1" ? "localhost" : deps.bindHost
      const serverUrl = `${deps.protocol}://${displayHost}:${actualPort}`

      deps.logger.info({ port: actualPort, host: deps.bindHost, protocol: deps.protocol }, "HTTP server listening")

      return { port: actualPort, url: serverUrl, displayHost }
    },
    stop: () => {
      closeSseClients()
      return app.close()
    },
  }
}

interface InstanceProxyDeps {
  workspaceManager: WorkspaceManager
  logger: Logger
}

function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) {
  app.register(async (instance) => {
    instance.removeAllContentTypeParsers()
    instance.addContentTypeParser("*", (req, body, done) => done(null, body))

    const proxyBaseHandler = async (
      request: FastifyRequest<{ Params: { id: string; slug: string } }>,
      reply: FastifyReply,
    ) => {
      await proxyWorkspaceRequest({
        request,
        reply,
        workspaceManager: deps.workspaceManager,
        worktreeSlug: request.params.slug,
        pathSuffix: "",
        logger: deps.logger,
      })
    }

    const proxyWildcardHandler = async (
      request: FastifyRequest<{ Params: { id: string; slug: string; "*": string } }>,
      reply: FastifyReply,
    ) => {
      await proxyWorkspaceRequest({
        request,
        reply,
        workspaceManager: deps.workspaceManager,
        worktreeSlug: request.params.slug,
        pathSuffix: request.params["*"] ?? "",
        logger: deps.logger,
      })
    }

    instance.all("/workspaces/:id/worktrees/:slug/instance", proxyBaseHandler)
    instance.all("/workspaces/:id/worktrees/:slug/instance/*", proxyWildcardHandler)
  })
}

const INSTANCE_PROXY_HOST = "127.0.0.1"

// Special-case OpenCode directory override.
//
// UI clients may need to scope certain requests to an arbitrary directory that is not
// part of the Git worktree list. Since the OpenCode SDK does not reliably support
// injecting per-request headers, we encode an override into the *path* and strip it
// before proxying to the instance.
//
// Example proxied request path:
//   /workspaces/:id/worktrees/:slug/instance/__dir/<base64url>/session/create
//
// The server will decode <base64url> -> absolute directory, validate it, then set
// x-opencode-directory accordingly and forward the request to /session/create.
const OPENCODE_DIR_OVERRIDE_PREFIX = "__dir/"
const OPENCODE_DIR_OVERRIDE_MAX_LEN = 4096

async function proxyWorkspaceRequest(args: {
  request: FastifyRequest
  reply: FastifyReply
  workspaceManager: WorkspaceManager
  logger: Logger
  worktreeSlug: string
  pathSuffix?: string
}) {
  const { request, reply, workspaceManager, logger, worktreeSlug } = args
  const workspaceId = (request.params as { id: string }).id
  const workspace = workspaceManager.get(workspaceId)

  const bodyToJson = (body: unknown): unknown => {
    if (body == null) return null

    const anyBody = body as any
    if (anyBody && typeof anyBody.pipe === "function") {
      // Don't consume streams (would break proxying).
      // Best-effort: if the stream already has buffered chunks, parse those.
      try {
        const buffered = anyBody?._readableState?.buffer
        if (Array.isArray(buffered) && buffered.length > 0) {
          const chunks: Buffer[] = []
          for (const entry of buffered) {
            if (!entry) continue
            if (Buffer.isBuffer(entry)) {
              chunks.push(entry)
              continue
            }
            const data = (entry as any).data
            if (Buffer.isBuffer(data)) {
              chunks.push(data)
            }
          }

          if (chunks.length > 0) {
            const text = Buffer.concat(chunks).toString("utf-8")
            try {
              return JSON.parse(text)
            } catch {
              return { __raw: text }
            }
          }
        }
      } catch {
        // fall through
      }

      return { __stream: true }
    }

    const maybeParse = (input: string): unknown => {
      try {
        return JSON.parse(input)
      } catch {
        return { __raw: input }
      }
    }

    if (Buffer.isBuffer(body)) {
      return maybeParse(body.toString("utf-8"))
    }

    if (typeof body === "string") {
      return maybeParse(body)
    }

    if (typeof body === "object") {
      return body
    }

    return body
  }

  if (!workspace) {
    reply.code(404).send({ error: "Workspace not found" })
    return
  }

  const port = workspaceManager.getInstancePort(workspaceId)
  if (!port) {
    reply.code(502).send({ error: "Workspace instance is not ready" })
    return
  }

  if (!isValidWorktreeSlug(worktreeSlug)) {
    reply.code(400).send({ error: "Invalid worktree slug" })
    return
  }

  let extracted: { overrideDirectory: string | null; forwardedSuffix: string | undefined }
  try {
    extracted = extractOpencodeDirectoryOverride(args.pathSuffix)
  } catch (error) {
    const message = error instanceof Error ? error.message : "Invalid directory override"
    reply.code(400).send({ error: message })
    return
  }
  let directory: string | null = null
  let forwardedSuffix = extracted.forwardedSuffix

  if (extracted.overrideDirectory) {
    try {
      directory = validateAndNormalizeOverrideDirectory({
        overrideDirectory: extracted.overrideDirectory,
        workspaceRoot: workspace.path,
      })
    } catch (error) {
      const message = error instanceof Error ? error.message : "Invalid directory override"
      reply.code(400).send({ error: message })
      return
    }
  } else {
    directory = await resolveWorktreeDirectory({
      workspaceId,
      workspacePath: workspace.path,
      worktreeSlug,
      logger,
    })

    if (!directory) {
      reply.code(404).send({ error: "Worktree not found" })
      return
    }
  }

  const normalizedSuffix = normalizeInstanceSuffix(forwardedSuffix)
  const queryIndex = (request.raw.url ?? "").indexOf("?")
  const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
  const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
  const instanceAuthHeader = workspaceManager.getInstanceAuthorizationHeader(workspaceId)

  logger.debug({ workspaceId, method: request.method, targetUrl }, "Proxying request to instance")
  if (logger.isLevelEnabled("trace")) {
    logger.trace({ workspaceId, targetUrl, body: request.body }, "Instance proxy payload")
  }

  return reply.from(targetUrl, {
    rewriteRequestHeaders: (_originalRequest, headers) => {
      if (instanceAuthHeader) {
        headers.authorization = instanceAuthHeader
      }

      // OpenCode expects the *full* path; we send it via header to avoid query tampering.
      const isNonASCII = /[^\x00-\x7F]/.test(directory)
      const encodedDirectory = isNonASCII ? encodeURIComponent(directory) : directory

      // Overwrite any client-provided value (case-insensitive headers are normalized by Node).
      ;(headers as Record<string, unknown>)["x-opencode-directory"] = encodedDirectory

      if (logger.isLevelEnabled("trace")) {
        const outgoing: Record<string, unknown> = {}
        for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
          outgoing[key] = value
        }

        // Redact sensitive headers.
        for (const key of Object.keys(outgoing)) {
          const lower = key.toLowerCase()
          if (lower === "authorization" || lower === "cookie" || lower === "set-cookie") {
            outgoing[key] = "<redacted>"
          }
        }

        logger.trace(
          {
            workspaceId,
            method: request.method,
            targetUrl,
            worktreeSlug,
            directory,
            contentType: request.headers["content-type"],
            body: bodyToJson(request.body),
            headers: outgoing,
          },
          "Proxy -> OpenCode request",
        )
      }

      return headers
    },
    onError: (proxyReply, { error }) => {
      logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
      if (!proxyReply.sent) {
        proxyReply.code(502).send({ error: "Workspace instance proxy failed" })
      }
    },
  })
}

function extractOpencodeDirectoryOverride(pathSuffix: string | undefined): {
  overrideDirectory: string | null
  forwardedSuffix: string | undefined
} {
  if (!pathSuffix) {
    return { overrideDirectory: null, forwardedSuffix: pathSuffix }
  }

  // Fastify wildcard param does not include a leading slash.
  const trimmed = pathSuffix.replace(/^\/+/, "")
  if (!trimmed.startsWith(OPENCODE_DIR_OVERRIDE_PREFIX)) {
    return { overrideDirectory: null, forwardedSuffix: pathSuffix }
  }

  const rest = trimmed.slice(OPENCODE_DIR_OVERRIDE_PREFIX.length)
  const slashIndex = rest.indexOf("/")
  const encoded = (slashIndex >= 0 ? rest.slice(0, slashIndex) : rest).trim()
  const remaining = slashIndex >= 0 ? rest.slice(slashIndex + 1) : ""

  if (!encoded) {
    throw new Error("Missing directory override")
  }

  if (encoded.length > OPENCODE_DIR_OVERRIDE_MAX_LEN) {
    throw new Error("Directory override too large")
  }

  let overrideDirectory = ""
  try {
    overrideDirectory = decodeBase64Url(encoded)
  } catch {
    throw new Error("Invalid directory override")
  }
  const forwardedSuffix = remaining
  return { overrideDirectory, forwardedSuffix }
}

function decodeBase64Url(input: string): string {
  // base64url -> base64
  const normalized = input.replace(/-/g, "+").replace(/_/g, "/")
  const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4))
  const base64 = `${normalized}${padding}`
  return Buffer.from(base64, "base64").toString("utf-8")
}

function validateAndNormalizeOverrideDirectory(params: { overrideDirectory: string; workspaceRoot: string }): string {
  const raw = params.overrideDirectory.trim()
  if (!raw) {
    throw new Error("Override directory is empty")
  }

  if (!path.isAbsolute(raw)) {
    throw new Error("Override directory must be an absolute path")
  }

  if (!fs.existsSync(raw)) {
    throw new Error(`Override directory does not exist: ${raw}`)
  }

  const stats = fs.statSync(raw)
  if (!stats.isDirectory()) {
    throw new Error(`Override path is not a directory: ${raw}`)
  }

  const normalizedOverride = fs.realpathSync(raw)
  const normalizedRoot = fs.realpathSync(params.workspaceRoot)

  if (!isSubpath(normalizedOverride, normalizedRoot)) {
    throw new Error("Override directory must be within the workspace root")
  }

  return normalizedOverride
}

function isSubpath(candidate: string, root: string): boolean {
  const rel = path.relative(root, candidate)
  if (rel === "") return true
  if (rel === "..") return false
  if (rel.startsWith(`..${path.sep}`)) return false
  if (path.isAbsolute(rel)) return false
  return true
}

function normalizeInstanceSuffix(pathSuffix: string | undefined) {
  if (!pathSuffix || pathSuffix === "/") {
    return "/"
  }
  const trimmed = pathSuffix.replace(/^\/+/, "")
  return trimmed.length === 0 ? "/" : `/${trimmed}`
}

type WorktreeCacheEntry = {
  expiresAt: number
  repoRoot: string
  worktrees: Array<{ slug: string; directory: string }>
}

const WORKTREE_CACHE_TTL_MS = 2000
const worktreeCache = new Map<string, WorktreeCacheEntry>()

async function getCachedWorktrees(params: { workspaceId: string; workspacePath: string; logger: Logger }) {
  const cached = worktreeCache.get(params.workspaceId)
  const now = Date.now()
  if (cached && cached.expiresAt > now) {
    return cached
  }

  const { repoRoot } = await resolveRepoRoot(params.workspacePath, params.logger)
  const worktrees = await listWorktrees({ repoRoot, workspaceFolder: params.workspacePath, logger: params.logger })
  const entry: WorktreeCacheEntry = {
    expiresAt: now + WORKTREE_CACHE_TTL_MS,
    repoRoot,
    worktrees: worktrees.map((wt) => ({ slug: wt.slug, directory: wt.directory })),
  }
  worktreeCache.set(params.workspaceId, entry)
  return entry
}

async function resolveWorktreeDirectory(params: {
  workspaceId: string
  workspacePath: string
  worktreeSlug: string
  logger: Logger
}): Promise<string | null> {
  const { worktreeSlug } = params
  const cached = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger })
  const match = cached.worktrees.find((wt) => wt.slug === worktreeSlug)
  if (match) {
    return match.directory
  }

  // If the slug is new (e.g., created moments ago), refresh once.
  worktreeCache.delete(params.workspaceId)
  const refreshed = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger })
  return refreshed.worktrees.find((wt) => wt.slug === worktreeSlug)?.directory ?? null
}

function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) {
  if (!uiDir) {
    app.log.warn("UI static directory not provided; API endpoints only")
    return
  }

  if (!fs.existsSync(uiDir)) {
    app.log.warn({ uiDir }, "UI static directory missing; API endpoints only")
    return
  }

  app.register(fastifyStatic, {
    root: uiDir,
    prefix: "/",
    decorateReply: false,
  })

  const indexPath = path.join(uiDir, "index.html")

  app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
    const url = request.raw.url ?? ""
    if (isApiRequest(url)) {
      reply.code(404).send({ message: "Not Found" })
      return
    }

    const session = authManager.getSessionFromRequest(request)
    if (!session && wantsHtml(request)) {
      reply.redirect("/login")
      return
    }

    if (fs.existsSync(indexPath)) {
      reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"))
    } else {
      reply.code(404).send({ message: "UI bundle missing" })
    }
  })
}

function setupDevProxy(app: FastifyInstance, upstreamBase: string, authManager: AuthManager) {
  app.log.info({ upstreamBase }, "Proxying UI requests to development server")
  app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
    const url = request.raw.url ?? ""
    if (isApiRequest(url)) {
      reply.code(404).send({ message: "Not Found" })
      return
    }

    const session = authManager.getSessionFromRequest(request)
    if (!session && wantsHtml(request)) {
      reply.redirect("/login")
      return
    }

    void proxyToDevServer(request, reply, upstreamBase)
  })
}

async function proxyToDevServer(request: FastifyRequest, reply: FastifyReply, upstreamBase: string) {
  try {
    const targetUrl = new URL(request.raw.url ?? "/", upstreamBase)
    const response = await fetch(targetUrl, {
      method: request.method,
      headers: buildProxyHeaders(request.headers),
    })

    response.headers.forEach((value, key) => {
      reply.header(key, value)
    })

    reply.code(response.status)

    if (!response.body || request.method === "HEAD") {
      reply.send()
      return
    }

    const buffer = Buffer.from(await response.arrayBuffer())
    reply.send(buffer)
  } catch (error) {
    request.log.error({ err: error }, "Failed to proxy UI request to dev server")
    if (!reply.sent) {
      reply.code(502).send("UI dev server is unavailable")
    }
  }
}

function isApiRequest(rawUrl: string | null | undefined) {
  if (!rawUrl) return false
  const pathname = rawUrl.split("?")[0] ?? ""
  return pathname === "/api" || pathname.startsWith("/api/")
}

function buildProxyHeaders(headers: FastifyRequest["headers"]): Record<string, string> {
  const result: Record<string, string> = {}
  for (const [key, value] of Object.entries(headers ?? {})) {
    if (!value || key.toLowerCase() === "host") continue
    result[key] = Array.isArray(value) ? value.join(",") : value
  }
  return result
}
