import { afterEach, describe, expect, test } from "bun:test"
import { Flag } from "@saeeol/core/flag/flag"
import { Instance } from "../../src/project/instance"
import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control"
import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global"
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { Server } from "../../src/server/server"
import * as Log from "@saeeol/core/util/log"
import { ConfigProvider, Layer } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { OpenApi } from "effect/unstable/httpapi"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"

void Log.init({ print: false })

const original = {
  SAEEOL_EXPERIMENTAL_HTTPAPI: Flag.SAEEOL_EXPERIMENTAL_HTTPAPI,
  SAEEOL_SERVER_PASSWORD: Flag.SAEEOL_SERVER_PASSWORD,
  SAEEOL_SERVER_USERNAME: Flag.SAEEOL_SERVER_USERNAME,
}

const methods = ["get", "post", "put", "delete", "patch"] as const
let effectSpec: ReturnType<typeof OpenApi.fromApi> | undefined

function effectOpenApi() {
  return (effectSpec ??= OpenApi.fromApi(PublicApi))
}

function app(input?: { password?: string; username?: string }) {
  Flag.SAEEOL_EXPERIMENTAL_HTTPAPI = true
  Flag.SAEEOL_SERVER_PASSWORD = input?.password
  Flag.SAEEOL_SERVER_USERNAME = input?.username

  const handler = HttpRouter.toWebHandler(
    ExperimentalHttpApiServer.routes.pipe(
      Layer.provide(
        ConfigProvider.layer(
          ConfigProvider.fromUnknown({
            SAEEOL_SERVER_PASSWORD: input?.password,
            SAEEOL_SERVER_USERNAME: input?.username,
          }),
        ),
      ),
    ),
    { disableLogger: true },
  ).handler
  return {
    fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context),
    request(input: string | URL | Request, init?: RequestInit) {
      return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init))
    },
  }
}

function openApiRouteKeys(spec: { paths: Record<string, Partial<Record<(typeof methods)[number], unknown>>> }) {
  return Object.entries(spec.paths)
    .flatMap(([path, item]) =>
      methods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`),
    )
    .sort()
}

function openApiParameters(spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }) {
  return Object.fromEntries(
    Object.entries(spec.paths).flatMap(([path, item]) =>
      methods
        .filter((method) => item[method])
        .map((method) => [
          `${method.toUpperCase()} ${path}`,
          (item[method]?.parameters ?? [])
            .map(parameterKey)
            .filter((param) => param !== undefined)
            .sort(),
        ]),
    ),
  )
}

function openApiRequestBodies(spec: OpenApiSpec) {
  return Object.fromEntries(
    Object.entries(spec.paths).flatMap(([path, item]) =>
      methods
        .filter((method) => item[method])
        .map((method) => [`${method.toUpperCase()} ${path}`, requestBodyKey(spec, item[method]?.requestBody)]),
    ),
  )
}

type OpenApiSpec = {
  components?: {
    schemas?: Record<string, unknown>
  }
  paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>>
}

type OpenApiSchema = {
  $ref?: string
  allOf?: unknown[]
  anyOf?: unknown[]
  oneOf?: unknown[]
  properties?: Record<string, unknown>
  type?: string | string[]
}

type Operation = {
  parameters?: unknown[]
  responses?: unknown
  requestBody?: unknown
}

type RequestBody = {
  content?: Record<string, { schema?: OpenApiSchema }>
  required?: boolean
}

function parameterKey(param: unknown): string | undefined {
  if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return undefined
  if (typeof param.in !== "string" || typeof param.name !== "string") return undefined
  return `${param.in}:${param.name}:${"required" in param && param.required === true}:${stableSchema(
    "schema" in param ? param.schema : undefined,
  )}`
}

function stableSchema(input: unknown): string {
  return JSON.stringify(sortSchema(input))
}

function sortSchema(input: unknown): unknown {
  if (Array.isArray(input)) return input.map(sortSchema)
  if (!input || typeof input !== "object") return input
  return Object.fromEntries(
    Object.entries(input)
      .sort(([left], [right]) => left.localeCompare(right))
      .map(([key, value]) => [key, sortSchema(value)]),
  )
}

function parameterSchema(input: {
  spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }
  path: string
  method: (typeof methods)[number]
  name: string
}): unknown {
  const param = input.spec.paths[input.path]?.[input.method]?.parameters?.find(
    (param) => !!param && typeof param === "object" && "name" in param && param.name === input.name,
  )
  if (!param || typeof param !== "object" || !("schema" in param)) return undefined
  return param.schema
}

function requestBodyKey(spec: OpenApiSpec, body: unknown) {
  if (!body || typeof body !== "object" || !("content" in body)) return ""
  // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded above; test helper only needs this OpenAPI subset.
  const requestBody = body as RequestBody
  return JSON.stringify({
    required: requestBody.required === true,
    content: Object.entries(requestBody.content ?? {})
      .map(([type, value]) => [type, requestBodySchemaKind(spec, value.schema)] as const)
      .sort(([left], [right]) => left.localeCompare(right)),
  })
}

function requestBodySchemaKind(spec: OpenApiSpec, schema: OpenApiSchema | undefined) {
  if (!schema) return ""
  // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `$ref` lookup is constrained to OpenAPI schema components in this test helper.
  const resolved = (
    schema.$ref ? spec.components?.schemas?.[schema.$ref.replace("#/components/schemas/", "")] : schema
  ) as OpenApiSchema | undefined
  if (resolved?.properties) return "object"
  if (resolved?.anyOf ?? resolved?.oneOf ?? resolved?.allOf) return "object"
  return resolved?.type ?? schema.type ?? "inline"
}

function responseContentTypes(input: {
  spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }
  path: string
  method: (typeof methods)[number]
  status: string
}) {
  const responses = input.spec.paths[input.path]?.[input.method]?.responses
  if (!responses || typeof responses !== "object" || !(input.status in responses)) return []
  // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded dynamic OpenAPI response lookup.
  const response = (responses as Record<string, unknown>)[input.status]
  if (!response || typeof response !== "object" || !("content" in response)) return []
  const content = (response as { content?: unknown }).content
  if (!content || typeof content !== "object") {
    return []
  }
  return Object.keys(content).sort()
}

function authorization(username: string, password: string) {
  return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
}

function fileUrl(input?: { directory?: string; token?: string }) {
  const url = new URL(`http://localhost${FilePaths.content}`)
  url.searchParams.set("path", "hello.txt")
  if (input?.directory) url.searchParams.set("directory", input.directory)
  if (input?.token) url.searchParams.set("auth_token", input.token)
  return url
}

afterEach(async () => {
  Flag.SAEEOL_EXPERIMENTAL_HTTPAPI = original.SAEEOL_EXPERIMENTAL_HTTPAPI
  Flag.SAEEOL_SERVER_PASSWORD = original.SAEEOL_SERVER_PASSWORD
  Flag.SAEEOL_SERVER_USERNAME = original.SAEEOL_SERVER_USERNAME
  await disposeAllInstances()
  await resetDatabase()
})

describe("HttpApi server", () => {
  test("keeps Effect HttpApi behind the feature flag", () => {
    Flag.SAEEOL_EXPERIMENTAL_HTTPAPI = false
    expect(Server.backend()).toEqual({ backend: "hono", reason: "stable" })

    Flag.SAEEOL_EXPERIMENTAL_HTTPAPI = true
    expect(Server.backend()).toEqual({ backend: "effect-httpapi", reason: "env" })
  })
  // These tests verify every Hono route has an Effect HttpApi contract. Saeeol-specific routes
  // (/config/warnings, /indexing/status, /saeeol/claw/*, /saeeol/cloud-sessions, /experimental/worktree/diff*)
  // aren't yet wired into PublicApi. The Effect HttpApi bridge is gated behind SAEEOL_EXPERIMENTAL_HTTPAPI
  // and is not enabled in any production client (VS Code extension, JetBrains, TUI, desktop all use Hono).
  // Follow-up: migrate Saeeol overlay routes onto the Effect HttpApi bridge.
  test.skip("covers every generated OpenAPI route with Effect HttpApi contracts", async () => {
    const honoRoutes = openApiRouteKeys(await Server.openapi())
    const effectRoutes = openApiRouteKeys(effectOpenApi())

    expect(honoRoutes.filter((route) => !effectRoutes.includes(route))).toEqual([])
    expect(effectRoutes.filter((route) => !honoRoutes.includes(route))).toEqual([])
  })

  test.skip("matches generated OpenAPI route parameters", async () => {
    const hono = openApiParameters(await Server.openapi())
    const effect = openApiParameters(effectOpenApi())

    expect(
      Object.keys(hono)
        .filter((route) => JSON.stringify(hono[route]) !== JSON.stringify(effect[route]))
        .map((route) => ({ route, hono: hono[route], effect: effect[route] })),
    ).toEqual([])
  })

  test.skip("matches generated OpenAPI request body shape", async () => {
    const hono = openApiRequestBodies(await Server.openapi())
    const effect = openApiRequestBodies(effectOpenApi())

    expect(
      Object.keys(hono)
        .filter((route) => hono[route] !== effect[route])
        .map((route) => ({ route, hono: hono[route], effect: effect[route] })),
    ).toEqual([])
  })

  test("matches SDK-affecting query parameter schemas", async () => {
    const effect = effectOpenApi()

    expect(parameterSchema({ spec: effect, path: "/session", method: "get", name: "roots" })).toEqual({
      anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }],
    })
    expect(parameterSchema({ spec: effect, path: "/session", method: "get", name: "start" })).toEqual({
      type: "number",
    })
    expect(parameterSchema({ spec: effect, path: "/find/file", method: "get", name: "limit" })).toEqual({
      type: "integer",
      minimum: 1,
      maximum: 200,
    })
    expect(
      parameterSchema({ spec: effect, path: "/session/{sessionID}/message", method: "get", name: "limit" }),
    ).toEqual({
      type: "integer",
      minimum: 0,
      maximum: Number.MAX_SAFE_INTEGER,
    })
  })

  test("matches SDK-affecting request schema details", () => {
    const effect = effectOpenApi()
    const sessionUpdate = effect.paths["/session/{sessionID}"]?.patch?.requestBody
    const sessionUpdateSchema =
      typeof sessionUpdate === "object" && sessionUpdate && "content" in sessionUpdate
        ? sessionUpdate.content?.["application/json"]?.schema
        : undefined
    const sessionUpdateProperties = sessionUpdateSchema?.properties as Record<string, OpenApiSchema> | undefined
    const time = sessionUpdateProperties?.time
    expect(time?.properties?.archived).toEqual({ type: "number" })
  })

  test("documents event routes as server-sent events", () => {
    const effect = effectOpenApi()

    expect(responseContentTypes({ spec: effect, path: "/event", method: "get", status: "200" })).toEqual([
      "text/event-stream",
    ])
    expect(responseContentTypes({ spec: effect, path: "/global/event", method: "get", status: "200" })).toEqual([
      "text/event-stream",
    ])
  })

  test("allows requests when auth is disabled", async () => {
    await using tmp = await tmpdir({ git: true })
    await Bun.write(`${tmp.path}/hello.txt`, "hello")

    const response = await app().request(fileUrl(), {
      headers: {
        "x-saeeol-directory": tmp.path,
      },
    })

    expect(response.status).toBe(200)
    expect(await response.json()).toMatchObject({ content: "hello" })
  })

  test("provides instance context to bridged handlers", async () => {
    await using tmp = await tmpdir({ git: true })

    const response = await app().request("/project/current", {
      headers: {
        "x-saeeol-directory": tmp.path,
      },
    })

    expect(response.status).toBe(200)
    expect(await response.json()).toMatchObject({ worktree: tmp.path })
  })

  test("requires credentials when auth is enabled", async () => {
    await using tmp = await tmpdir({ git: true })
    await Bun.write(`${tmp.path}/hello.txt`, "hello")

    const [missing, bad, good] = await Promise.all([
      app({ password: "secret" }).request(fileUrl(), {
        headers: { "x-saeeol-directory": tmp.path },
      }),
      app({ password: "secret" }).request(fileUrl(), {
        headers: {
          authorization: authorization("saeeol", "wrong"), 
          "x-saeeol-directory": tmp.path,
        },
      }),
      app({ password: "secret" }).request(fileUrl(), {
        headers: {
          authorization: authorization("saeeol", "secret"), 
          "x-saeeol-directory": tmp.path,
        },
      }),
    ])

    expect(missing.status).toBe(401)
    expect(bad.status).toBe(401)
    expect(good.status).toBe(200)
  })

  test("accepts auth_token query credentials", async () => {
    await using tmp = await tmpdir({ git: true })
    await Bun.write(`${tmp.path}/hello.txt`, "hello")

    const response = await app({ password: "secret" }).request(
      fileUrl({ token: Buffer.from("saeeol:secret").toString("base64") }), 
      {
        headers: {
          "x-saeeol-directory": tmp.path,
        },
      },
    )

    expect(response.status).toBe(200)
  })

  test("selects instance from query before directory header", async () => {
    await using header = await tmpdir({ git: true })
    await using query = await tmpdir({ git: true })
    await Bun.write(`${header.path}/hello.txt`, "header")
    await Bun.write(`${query.path}/hello.txt`, "query")

    const response = await app().request(fileUrl({ directory: query.path }), {
      headers: {
        "x-saeeol-directory": header.path,
      },
    })

    expect(response.status).toBe(200)
    expect(await response.json()).toMatchObject({ content: "query" })
  })

  test("serves global health from Effect HttpApi", async () => {
    const response = await app().request(`${GlobalPaths.health}?directory=/does/not/exist/saeeol-test`)

    expect(response.status).toBe(200)
    expect(await response.json()).toMatchObject({ healthy: true })
  })

  test("serves global event stream from Effect HttpApi", async () => {
    const response = await app().request(GlobalPaths.event)
    if (!response.body) throw new Error("missing event stream body")
    const reader = response.body.getReader()
    const chunk = await reader.read()
    await reader.cancel()

    expect(response.status).toBe(200)
    expect(response.headers.get("content-type")).toContain("text/event-stream")
    expect(new TextDecoder().decode(chunk.value)).toContain("server.connected")
  })

  test("serves control log from Effect HttpApi", async () => {
    const response = await app().request(ControlPaths.log, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ service: "httpapi-test", level: "info", message: "hello" }),
    })

    expect(response.status).toBe(200)
    expect(await response.json()).toBe(true)
  })

  test("validates control auth without falling through to 404", async () => {
    const response = await app().request(ControlPaths.auth.replace(":providerID", "test"), {
      method: "PUT",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ type: "api" }),
    })

    expect(response.status).toBe(400)
  })

  test("validates global upgrade without invoking installers", async () => {
    const response = await app().request(GlobalPaths.upgrade, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: "not-json",
    })

    expect(response.status).toBe(400)
    expect(await response.json()).toMatchObject({ success: false })
  })
})
