import { readFileSync } from "node:fs"

import { sql } from "@/lib/db/client"

/**
 * The pieces every measurement in this folder needs: a session, a way to press
 * a button over HTTP, and a reader for `text/event-stream`.
 *
 * Throwaway.
 */

export const BASE = "http://127.0.0.1:4148"

export async function signIn(
  username: string,
  password: string,
): Promise<string> {
  const response = await fetch(`${BASE}/api/auth/sign-in/username`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ username, password }),
  })
  if (!response.ok) {
    throw new Error(`${username}: ${response.status} ${await response.text()}`)
  }
  const cookies = response.headers
    .getSetCookie()
    .map((c) => c.split(";")[0])
    .join("; ")
  if (!cookies) throw new Error(`${username}: no cookie came back`)
  return cookies
}

/**
 * The id Next.js gives a Server Action, read out of the manifest the dev server
 * writes for the page that exports it. The page has to have been rendered once.
 */
export function actionId(page: string, exportedName: string): string {
  const path = `.next/dev/server/app/${page}/page/server-reference-manifest.json`
  const manifest = JSON.parse(readFileSync(path, "utf8")) as {
    node: Record<string, { exportedName: string }>
  }
  for (const [id, entry] of Object.entries(manifest.node)) {
    if (entry.exportedName === exportedName) return id
  }
  throw new Error(`${exportedName} is not in ${path}`)
}

/** Press it, exactly as the browser does: a POST to the page it lives on. */
export async function callAction(
  url: string,
  cookie: string,
  id: string,
  args: unknown[],
): Promise<number> {
  const response = await fetch(`${BASE}${url}`, {
    method: "POST",
    headers: {
      Cookie: cookie,
      "Next-Action": id,
      "Content-Type": "text/plain;charset=UTF-8",
    },
    body: JSON.stringify(args),
  })
  await response.arrayBuffer()
  return response.status
}

export type Frame = {
  /** Milliseconds since the stream was opened. */
  at: number
  raw: string
  event?: string
  id?: string
  data?: string
  comment?: string
}

/** One open `text/event-stream`, with every frame timed as it lands. */
export class Stream {
  readonly frames: Frame[] = []
  private readonly controller = new AbortController()
  private opened = 0
  status = 0
  contentType = ""
  done: Promise<void> = Promise.resolve()

  async open(cookie: string, lastEventId?: string): Promise<this> {
    const headers: Record<string, string> = {
      Cookie: cookie,
      Accept: "text/event-stream",
    }
    if (lastEventId) headers["Last-Event-ID"] = lastEventId

    this.opened = performance.now()
    const response = await fetch(`${BASE}/api/notifications/stream`, {
      headers,
      signal: this.controller.signal,
    })
    this.status = response.status
    this.contentType = response.headers.get("content-type") ?? ""
    if (!response.body) return this

    this.done = this.read(response.body)
    return this
  }

  private async read(body: ReadableStream<Uint8Array>): Promise<void> {
    const decoder = new TextDecoder()
    let buffer = ""
    try {
      for await (const chunk of body as unknown as AsyncIterable<Uint8Array>) {
        buffer += decoder.decode(chunk, { stream: true })
        let cut: number
        while ((cut = buffer.indexOf("\n\n")) !== -1) {
          const raw = buffer.slice(0, cut)
          buffer = buffer.slice(cut + 2)
          this.frames.push(this.parse(raw))
        }
      }
    } catch {
      // Aborted by close(), which is the only way this ends.
    }
  }

  private parse(raw: string): Frame {
    const frame: Frame = { at: performance.now() - this.opened, raw }
    for (const line of raw.split("\n")) {
      if (line.startsWith(":")) frame.comment = line.slice(1).trim()
      else if (line.startsWith("event:")) frame.event = line.slice(6).trim()
      else if (line.startsWith("id:")) frame.id = line.slice(3).trim()
      else if (line.startsWith("data:")) frame.data = line.slice(5).trim()
    }
    return frame
  }

  /** Every frame that is a notification, in the order it arrived. */
  get notifications(): Frame[] {
    return this.frames.filter((f) => f.event === "notification")
  }

  async waitFor(
    matches: (f: Frame) => boolean,
    timeoutMs = 5_000,
  ): Promise<Frame> {
    const deadline = Date.now() + timeoutMs
    for (;;) {
      const found = this.frames.find(matches)
      if (found) return found
      if (Date.now() > deadline) {
        throw new Error(
          `Nothing matched in ${timeoutMs} ms. Frames: ${JSON.stringify(this.frames)}`,
        )
      }
      await Bun.sleep(2)
    }
  }

  close(): void {
    this.controller.abort()
  }
}

export async function counts(): Promise<{
  events: number
  notifications: number
}> {
  const [e] = await sql`select count(*)::int n from domain_events`
  const [n] = await sql`select count(*)::int n from notifications`
  return { events: Number(e!.n), notifications: Number(n!.n) }
}

export async function personId(username: string): Promise<string> {
  const [row] = await sql`select id from people where username = ${username}`
  if (!row) throw new Error(`no person named ${username}`)
  return row.id as string
}

/** A task the given person is the assignee of, still owed. */
export async function openTaskFor(person: string): Promise<string> {
  const [row] = await sql`
    select id from task_instances
    where assignee_person_id = ${person} and status = 'todo'
    order by due_date limit 1`
  if (!row) throw new Error(`no open task for ${person}`)
  return row.id as string
}
