import { serve } from "bun"
import { afterAll } from "bun:test"
import { ensure } from "@workshop/shared/utils"
import { mkdtemp, rm } from "fs/promises"
import { join } from "path"
import { tmpdir } from "os"

const giteaUrl = "https://git.nose.space"

export type User = { token: string; username: string }

// --- Webhook capture ---

type Expectation = {
  eventType: string
  match: (payload: any) => boolean
  resolve: (payload: any) => void
  timer: ReturnType<typeof setTimeout>
}

const captured: any[] = []
const expectations: Expectation[] = []

function matchIncoming(eventType: string, payload: any) {
  const idx = expectations.findIndex((e) => e.eventType === eventType && e.match(payload))
  if (idx < 0) return

  const expectation = expectations.splice(idx, 1)[0]!
  clearTimeout(expectation.timer)
  expectation.resolve(payload)
}

const captureServer = serve({
  port: 0,
  routes: {
    "/gitea/webhook": {
      POST: async (req) => {
        const payload = await req.json()
        const eventType = req.headers.get("X-Gitea-Event") || "unknown"
        captured.push(payload)
        matchIncoming(eventType, payload)
        return new Response("OK")
      },
    },
  },
})

// Registers an expectation before calling the callback, so no webhook can
// slip through the crack. The callback receives a promise that resolves
// to the webhook payload once it arrives.
function awaitWebhook(
  eventType: string,
  match: (payload: any) => boolean,
  timeoutMs = 15000,
): Promise<any> {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      const idx = expectations.indexOf(expectation)
      if (idx >= 0) expectations.splice(idx, 1)
      reject(new Error(`Timed out waiting for ${eventType} webhook`))
    }, timeoutMs)

    const expectation: Expectation = { eventType, match, resolve, timer }
    expectations.push(expectation)
  })
}

function hasBranch(payload: any, branch: string) {
  return payload.pull_request?.head?.ref === branch || payload.pull_request?.head?.label === branch
}

export async function expectPullRequestWebhook<T>(
  match: { action: string; branch: string },
  callback: (webhook: Promise<any>) => Promise<T>,
): Promise<T> {
  const webhook = awaitWebhook("pull_request", (p) => p.action === match.action && hasBranch(p, match.branch))
  return callback(webhook)
}

export async function expectIssueCommentWebhook<T>(
  match: { action: string; username: string; prNumber: number },
  callback: (webhook: Promise<any>) => Promise<T>,
): Promise<T> {
  const webhook = awaitWebhook(
    "issue_comment",
    (p) => p.action === match.action && p.comment?.user?.login === match.username && p.issue?.number === match.prNumber,
  )
  return callback(webhook)
}

export async function expectPullRequestCommentWebhook<T>(
  match: { action: string; branch: string },
  callback: (webhook: Promise<any>) => Promise<T>,
): Promise<T> {
  const webhook = awaitWebhook("pull_request_comment", (p) => p.action === match.action && hasBranch(p, match.branch))
  return callback(webhook)
}

// --- Setup / teardown ---

export async function setupWebhooks(corey: User, spike: User, coreyRepo: string, spikeRepo: string) {
  const tsResult = Bun.spawnSync(["tailscale", "status", "--self", "--json"])
  ensure(tsResult.success, `Failed to get tailscale status: ${tsResult.stderr.toString()}`)
  const tailscaleUrl = `https://${JSON.parse(tsResult.stdout.toString()).Self.DNSName.replace(/\.$/, "")}`

  const funnel = Bun.spawn(["tailscale", "funnel", String(captureServer.port)], { stdout: "ignore", stderr: "ignore" })
  const funnelUrl = `${tailscaleUrl}/gitea/webhook`

  // Wait for funnel to be reachable
  const start = Date.now()
  while (Date.now() - start < 10000) {
    try {
      const res = await fetch(funnelUrl, { method: "POST", body: "{}", headers: { "Content-Type": "application/json" } })
      if (res.ok) break
    } catch {}
    await Bun.sleep(500)
  }

  await ensureRepo(spikeRepo, spike)
  await addCollaborator(spikeRepo, spike, corey.username)
  await upsertWebhook(coreyRepo, funnelUrl, corey)
  await upsertWebhook(spikeRepo, funnelUrl, spike)

  // Flush stale webhook deliveries that Gitea retried when our funnel came
  // back up. Even with branch-scoped matching these can saturate the queue.
  await Bun.sleep(3000)
  captured.length = 0
  expectations.length = 0

  return function teardown() {
    captureServer.stop()
    funnel.kill()
  }
}

// --- Scaffolding: open a PR ready for testing ---

export type TestPR = {
  repo: string
  number: number
  branch: string
  filename: string
  dir: string
  author: User
}

export async function openTestPR(repo: string, author: User, branch = `test-${Date.now()}`): Promise<TestPR> {
  const { dir, filename } = await pushBranch(repo, branch, author)
  const pr = await createPR(repo, `Test PR (${branch})`, branch, author)

  afterAll(async () => {
    try { git(["push", "origin", "--delete", branch], dir) } catch {}
    await rm(dir, { recursive: true, force: true })
  })

  return { repo, number: pr.number, branch, filename, dir, author }
}

// --- Gitea API ---

async function giteaFetch(path: string, user: User, options: RequestInit = {}) {
  const response = await fetch(`${giteaUrl}/api/v1${path}`, {
    ...options,
    headers: {
      Authorization: `token ${user.token}`,
      "Content-Type": "application/json",
      ...(options.headers as Record<string, string>),
    },
  })
  if (!response.ok) {
    const body = await response.text().catch(() => "")
    throw new Error(`Gitea ${response.status}: ${options.method ?? "GET"} ${path}\n${body}`)
  }
  if (response.headers.get("content-type")?.includes("json")) {
    return response.json()
  }
}

async function ensureRepo(repo: string, owner: User) {
  const res = await fetch(`${giteaUrl}/api/v1/repos/${repo}`, {
    headers: { Authorization: `token ${owner.token}` },
  })
  if (res.ok) return
  const repoName = repo.split("/")[1]
  await giteaFetch("/user/repos", owner, {
    method: "POST",
    body: JSON.stringify({ name: repoName, auto_init: true, default_branch: "main" }),
  })
}

async function addCollaborator(repo: string, owner: User, collaborator: string) {
  await giteaFetch(`/repos/${repo}/collaborators/${collaborator}`, owner, {
    method: "PUT",
    body: JSON.stringify({ permission: "write" }),
  })
}

async function upsertWebhook(repo: string, webhookUrl: string, user: User) {
  // Delete all existing webhooks to prevent duplicates from stale test runs
  const hooks = (await giteaFetch(`/repos/${repo}/hooks`, user)) as Array<{ id: number; config: { url: string } }>
  for (const hook of hooks) {
    await giteaFetch(`/repos/${repo}/hooks/${hook.id}`, user, { method: "DELETE" })
  }

  await giteaFetch(`/repos/${repo}/hooks`, user, {
    method: "POST",
    body: JSON.stringify({
      type: "gitea",
      active: true,
      config: { url: webhookUrl, content_type: "json" },
      events: ["pull_request", "issue_comment", "pull_request_comment"],
    }),
  })
}

async function createPR(repo: string, title: string, head: string, user: User) {
  return giteaFetch(`/repos/${repo}/pulls`, user, {
    method: "POST",
    body: JSON.stringify({ title, head, base: "main", body: "Test PR" }),
  })
}

export async function commentOnPR(repo: string, prNumber: number, body: string, user: User) {
  return giteaFetch(`/repos/${repo}/issues/${prNumber}/comments`, user, {
    method: "POST",
    body: JSON.stringify({ body }),
  })
}

export async function mergePR(repo: string, prNumber: number, user: User) {
  // Gitea returns 404/405 while computing mergeability after PR creation
  const path = `/repos/${repo}/pulls/${prNumber}/merge`

  for (let attempt = 0; attempt < 10; attempt++) {
    const response = await fetch(`${giteaUrl}/api/v1${path}`, {
      method: "POST",
      headers: {
        Authorization: `token ${user.token}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ Do: "merge" }),
    })

    if (response.ok) return
    if (response.status === 404 || response.status === 405) {
      await Bun.sleep(1000)
      continue
    }

    const body = await response.text().catch(() => "")
    throw new Error(`Gitea ${response.status}: POST ${path}\n${body}`)
  }

  throw new Error(`Gitea merge failed after 10 retries for ${path}`)
}

export async function createReview(repo: string, prNumber: number, user: User, filePath: string) {
  return giteaFetch(`/repos/${repo}/pulls/${prNumber}/reviews`, user, {
    method: "POST",
    body: JSON.stringify({
      body: "Review from integration test",
      event: "COMMENT",
      comments: [{ path: filePath, new_position: 1, body: `Line comment from ${user.username}` }],
    }),
  })
}

// --- Git ---

function git(args: string[], cwd: string) {
  const result = Bun.spawnSync(["git", ...args], { cwd })
  if (!result.success) {
    throw new Error(`git ${args.join(" ")} failed:\n${result.stderr.toString()}`)
  }
}

async function pushBranch(repo: string, branch: string, user: User) {
  const dir = await mkdtemp(join(tmpdir(), "spike-test-"))
  const url = `https://${user.username}:${user.token}@git.nose.space/${repo}.git`
  const filename = `test-${Date.now()}.md`
  git(["clone", url, dir], dir)
  git(["config", "user.name", user.username], dir)
  git(["config", "user.email", `${user.username}@test`], dir)
  git(["checkout", "-b", branch], dir)
  await Bun.write(join(dir, filename), "test file")
  git(["add", "."], dir)
  git(["commit", "-m", "test commit"], dir)
  git(["push", "origin", branch], dir)
  return { dir, filename }
}
