import { expect, Page } from "@playwright/test"
import { spawn } from "child_process"
import { randomUUID } from "crypto"
import path from "path"
import express from "express"
import { TarWriter } from "utils/tar"
import {
  Agent,
  App,
  AppSharingLevel,
  Response,
  ParseComplete,
  PlanComplete,
  ApplyComplete,
  Resource,
  RichParameter,
} from "./provisionerGenerated"
import { port } from "./playwright.config"
import * as ssh from "ssh2"
import { Duplex } from "stream"
import { WorkspaceBuildParameter } from "api/typesGenerated"

// createWorkspace creates a workspace for a template.
// It does not wait for it to be running, but it does navigate to the page.
export const createWorkspace = async (
  page: Page,
  templateName: string,
  richParameters: RichParameter[] = [],
  buildParameters: WorkspaceBuildParameter[] = [],
): Promise<string> => {
  await page.goto("/templates/" + templateName + "/workspace", {
    waitUntil: "networkidle",
  })
  const name = randomName()
  await page.getByLabel("name").fill(name)

  await fillParameters(page, richParameters, buildParameters)
  await page.getByTestId("form-submit").click()

  await expect(page).toHaveURL("/@admin/" + name)
  await page.waitForSelector(
    "span[data-testid='build-status'] >> text=Running",
    {
      state: "visible",
    },
  )
  return name
}

export const verifyParameters = async (
  page: Page,
  workspaceName: string,
  richParameters: RichParameter[],
  expectedBuildParameters: WorkspaceBuildParameter[],
) => {
  await page.goto("/@admin/" + workspaceName + "/settings/parameters", {
    waitUntil: "networkidle",
  })
  await expect(page).toHaveURL(
    "/@admin/" + workspaceName + "/settings/parameters",
  )

  for (const buildParameter of expectedBuildParameters) {
    const richParameter = richParameters.find(
      (richParam) => richParam.name === buildParameter.name,
    )
    if (!richParameter) {
      throw new Error(
        "build parameter is expected to be present in rich parameter schema",
      )
    }

    const parameterLabel = await page.waitForSelector(
      "[data-testid='parameter-field-" + richParameter.name + "']",
      { state: "visible" },
    )

    const muiDisabled = richParameter.mutable ? "" : ".Mui-disabled"

    if (richParameter.type === "bool") {
      const parameterField = await parameterLabel.waitForSelector(
        "[data-testid='parameter-field-bool'] .MuiRadio-root.Mui-checked" +
          muiDisabled +
          " input",
      )
      const value = await parameterField.inputValue()
      expect(value).toEqual(buildParameter.value)
    } else if (richParameter.options.length > 0) {
      const parameterField = await parameterLabel.waitForSelector(
        "[data-testid='parameter-field-options'] .MuiRadio-root.Mui-checked" +
          muiDisabled +
          " input",
      )
      const value = await parameterField.inputValue()
      expect(value).toEqual(buildParameter.value)
    } else if (richParameter.type === "list(string)") {
      throw new Error("not implemented yet") // FIXME
    } else {
      // text or number
      const parameterField = await parameterLabel.waitForSelector(
        "[data-testid='parameter-field-text'] input" + muiDisabled,
      )
      const value = await parameterField.inputValue()
      expect(value).toEqual(buildParameter.value)
    }
  }
}

// createTemplate navigates to the /templates/new page and uploads a template
// with the resources provided in the responses argument.
export const createTemplate = async (
  page: Page,
  responses?: EchoProvisionerResponses,
): Promise<string> => {
  // Required to have templates submit their provisioner type as echo!
  await page.addInitScript({
    content: "window.playwright = true",
  })
  await page.goto("/templates/new", { waitUntil: "networkidle" })
  await page.getByTestId("file-upload").setInputFiles({
    buffer: await createTemplateVersionTar(responses),
    mimeType: "application/x-tar",
    name: "template.tar",
  })
  const name = randomName()
  await page.getByLabel("Name *").fill(name)
  await page.getByTestId("form-submit").click()
  await expect(page).toHaveURL("/templates/" + name, {
    timeout: 30000,
  })
  return name
}

// sshIntoWorkspace spawns a Coder SSH process and a client connected to it.
export const sshIntoWorkspace = async (
  page: Page,
  workspace: string,
  binaryPath = "go",
  binaryArgs: string[] = [],
): Promise<ssh.Client> => {
  if (binaryPath === "go") {
    binaryArgs = ["run", coderMainPath()]
  }
  const sessionToken = await findSessionToken(page)
  return new Promise<ssh.Client>((resolve, reject) => {
    const cp = spawn(binaryPath, [...binaryArgs, "ssh", "--stdio", workspace], {
      env: {
        ...process.env,
        CODER_SESSION_TOKEN: sessionToken,
        CODER_URL: "http://localhost:3000",
      },
    })
    cp.on("error", (err) => reject(err))
    const proxyStream = new Duplex({
      read: (size) => {
        return cp.stdout.read(Math.min(size, cp.stdout.readableLength))
      },
      write: cp.stdin.write.bind(cp.stdin),
    })
    // eslint-disable-next-line no-console -- Helpful for debugging
    cp.stderr.on("data", (data) => console.log(data.toString()))
    cp.stdout.on("readable", (...args) => {
      proxyStream.emit("readable", ...args)
      if (cp.stdout.readableLength > 0) {
        proxyStream.emit("data", cp.stdout.read())
      }
    })
    const client = new ssh.Client()
    client.connect({
      sock: proxyStream,
      username: "coder",
    })
    client.on("error", (err) => reject(err))
    client.on("ready", () => {
      resolve(client)
    })
  })
}

export const stopWorkspace = async (page: Page, workspaceName: string) => {
  await page.goto("/@admin/" + workspaceName, {
    waitUntil: "domcontentloaded",
  })
  await expect(page).toHaveURL("/@admin/" + workspaceName)

  await page.getByTestId("workspace-stop-button").click()

  await page.waitForSelector(
    "span[data-testid='build-status'] >> text=Stopped",
    {
      state: "visible",
    },
  )
}

export const buildWorkspaceWithParameters = async (
  page: Page,
  workspaceName: string,
  richParameters: RichParameter[] = [],
  buildParameters: WorkspaceBuildParameter[] = [],
  confirm: boolean = false,
) => {
  await page.goto("/@admin/" + workspaceName, {
    waitUntil: "domcontentloaded",
  })
  await expect(page).toHaveURL("/@admin/" + workspaceName)

  await page.getByTestId("build-parameters-button").click()

  await fillParameters(page, richParameters, buildParameters)
  await page.getByTestId("build-parameters-submit").click()
  if (confirm) {
    await page.getByTestId("confirm-button").click()
  }

  await page.waitForSelector(
    "span[data-testid='build-status'] >> text=Running",
    {
      state: "visible",
    },
  )
}

// startAgent runs the coder agent with the provided token.
// It awaits the agent to be ready before returning.
export const startAgent = async (page: Page, token: string): Promise<void> => {
  return startAgentWithCommand(page, token, "go", "run", coderMainPath())
}

// downloadCoderVersion downloads the version provided into a temporary dir and
// caches it so subsequent calls are fast.
export const downloadCoderVersion = async (
  version: string,
): Promise<string> => {
  if (version.startsWith("v")) {
    version = version.slice(1)
  }

  const binaryName = "coder-e2e-" + version
  const tempDir = "/tmp/coder-e2e-cache"
  // The install script adds `./bin` automatically to the path :shrug:
  const binaryPath = path.join(tempDir, "bin", binaryName)

  const exists = await new Promise<boolean>((resolve) => {
    const cp = spawn(binaryPath, ["version"])
    cp.on("close", (code) => {
      resolve(code === 0)
    })
    cp.on("error", () => resolve(false))
  })
  if (exists) {
    return binaryPath
  }

  // Runs our public install script using our options to
  // install the binary!
  await new Promise<void>((resolve, reject) => {
    const cp = spawn(
      "sh",
      [
        "-c",
        [
          "curl",
          "-L",
          "https://coder.com/install.sh",
          "|",
          "sh",
          "-s",
          "--",
          "--version",
          version,
          "--method",
          "standalone",
          "--prefix",
          tempDir,
          "--binary-name",
          binaryName,
        ].join(" "),
      ],
      {
        env: {
          ...process.env,
          XDG_CACHE_HOME: "/tmp/coder-e2e-cache",
        },
      },
    )
    // eslint-disable-next-line no-console -- Needed for debugging
    cp.stderr.on("data", (data) => console.log(data.toString()))
    cp.on("close", (code) => {
      if (code === 0) {
        resolve()
      } else {
        reject(new Error("curl failed with code " + code))
      }
    })
  })
  return binaryPath
}

export const startAgentWithCommand = async (
  page: Page,
  token: string,
  command: string,
  ...args: string[]
): Promise<void> => {
  const cp = spawn(command, [...args, "agent", "--no-reap"], {
    env: {
      ...process.env,
      CODER_AGENT_URL: "http://localhost:" + port,
      CODER_AGENT_TOKEN: token,
    },
  })
  let buffer = Buffer.of()
  cp.stderr.on("data", (data: Buffer) => {
    buffer = Buffer.concat([buffer, data])
  })
  try {
    await page.getByTestId("agent-status-ready").waitFor({ state: "visible" })
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- The error is a string
  } catch (ex: any) {
    throw new Error(ex.toString() + "\n" + buffer.toString())
  }
}

const coderMainPath = (): string => {
  return path.join(
    __dirname,
    "..",
    "..",
    "enterprise",
    "cmd",
    "coder",
    "main.go",
  )
}

// Allows users to more easily define properties they want for agents and resources!
type RecursivePartial<T> = {
  [P in keyof T]?: T[P] extends (infer U)[]
    ? RecursivePartial<U>[]
    : T[P] extends object | undefined
    ? RecursivePartial<T[P]>
    : T[P]
}

interface EchoProvisionerResponses {
  // parse is for observing any Terraform variables
  parse?: RecursivePartial<Response>[]
  // plan occurs when the template is imported
  plan?: RecursivePartial<Response>[]
  // apply occurs when the workspace is built
  apply?: RecursivePartial<Response>[]
}

// createTemplateVersionTar consumes a series of echo provisioner protobufs and
// converts it into an uploadable tar file.
const createTemplateVersionTar = async (
  responses?: EchoProvisionerResponses,
): Promise<Buffer> => {
  if (!responses) {
    responses = {}
  }
  if (!responses.parse) {
    responses.parse = [
      {
        parse: {},
      },
    ]
  }
  if (!responses.apply) {
    responses.apply = [
      {
        apply: {},
      },
    ]
  }
  if (!responses.plan) {
    responses.plan = responses.apply.map((response) => {
      if (response.log) {
        return response
      }
      return {
        plan: {
          error: response.apply?.error ?? "",
          resources: response.apply?.resources ?? [],
          parameters: response.apply?.parameters ?? [],
          gitAuthProviders: response.apply?.gitAuthProviders ?? [],
        },
      }
    })
  }

  const tar = new TarWriter()
  responses.parse.forEach((response, index) => {
    response.parse = {
      templateVariables: [],
      error: "",
      readme: new Uint8Array(),
      ...response.parse,
    } as ParseComplete
    tar.addFile(
      `${index}.parse.protobuf`,
      Response.encode(response as Response).finish(),
    )
  })

  const fillResource = (resource: RecursivePartial<Resource>) => {
    if (resource.agents) {
      resource.agents = resource.agents?.map(
        (agent: RecursivePartial<Agent>) => {
          if (agent.apps) {
            agent.apps = agent.apps?.map((app: RecursivePartial<App>) => {
              return {
                command: "",
                displayName: "example",
                external: false,
                icon: "",
                sharingLevel: AppSharingLevel.PUBLIC,
                slug: "example",
                subdomain: false,
                url: "",
                ...app,
              } as App
            })
          }
          return {
            apps: [],
            architecture: "amd64",
            connectionTimeoutSeconds: 300,
            directory: "",
            env: {},
            id: randomUUID(),
            metadata: [],
            motdFile: "",
            name: "dev",
            operatingSystem: "linux",
            shutdownScript: "",
            shutdownScriptTimeoutSeconds: 0,
            startupScript: "",
            startupScriptBehavior: "",
            startupScriptTimeoutSeconds: 300,
            troubleshootingUrl: "",
            token: randomUUID(),
            ...agent,
          } as Agent
        },
      )
    }
    return {
      agents: [],
      dailyCost: 0,
      hide: false,
      icon: "",
      instanceType: "",
      metadata: [],
      name: "dev",
      type: "echo",
      ...resource,
    } as Resource
  }

  responses.apply.forEach((response, index) => {
    response.apply = {
      error: "",
      state: new Uint8Array(),
      resources: [],
      parameters: [],
      gitAuthProviders: [],
      ...response.apply,
    } as ApplyComplete
    response.apply.resources = response.apply.resources?.map(fillResource)

    tar.addFile(
      `${index}.apply.protobuf`,
      Response.encode(response as Response).finish(),
    )
  })
  responses.plan.forEach((response, index) => {
    response.plan = {
      error: "",
      resources: [],
      parameters: [],
      gitAuthProviders: [],
      ...response.plan,
    } as PlanComplete
    response.plan.resources = response.plan.resources?.map(fillResource)

    tar.addFile(
      `${index}.plan.protobuf`,
      Response.encode(response as Response).finish(),
    )
  })
  const tarFile = await tar.write()
  return Buffer.from(
    tarFile instanceof Blob ? await tarFile.arrayBuffer() : tarFile,
  )
}

const randomName = () => {
  return randomUUID().slice(0, 8)
}

// Awaiter is a helper that allows you to wait for a callback to be called.
// It is useful for waiting for events to occur.
export class Awaiter {
  private promise: Promise<void>
  private callback?: () => void

  constructor() {
    this.promise = new Promise((r) => (this.callback = r))
  }

  public done(): void {
    if (this.callback) {
      this.callback()
    } else {
      this.promise = Promise.resolve()
    }
  }

  public wait(): Promise<void> {
    return this.promise
  }
}

export const createServer = async (
  port: number,
): Promise<ReturnType<typeof express>> => {
  const e = express()
  await new Promise<void>((r) => e.listen(port, r))
  return e
}

const findSessionToken = async (page: Page): Promise<string> => {
  const cookies = await page.context().cookies()
  const sessionCookie = cookies.find((c) => c.name === "coder_session_token")
  if (!sessionCookie) {
    throw new Error("session token not found")
  }
  return sessionCookie.value
}

export const echoResponsesWithParameters = (
  richParameters: RichParameter[],
): EchoProvisionerResponses => {
  return {
    parse: [
      {
        parse: {},
      },
    ],
    plan: [
      {
        plan: {
          parameters: richParameters,
        },
      },
    ],
    apply: [
      {
        apply: {
          resources: [
            {
              name: "example",
            },
          ],
        },
      },
    ],
  }
}

export const fillParameters = async (
  page: Page,
  richParameters: RichParameter[] = [],
  buildParameters: WorkspaceBuildParameter[] = [],
) => {
  for (const buildParameter of buildParameters) {
    const richParameter = richParameters.find(
      (richParam) => richParam.name === buildParameter.name,
    )
    if (!richParameter) {
      throw new Error(
        "build parameter is expected to be present in rich parameter schema",
      )
    }

    const parameterLabel = await page.waitForSelector(
      "[data-testid='parameter-field-" + richParameter.name + "']",
      { state: "visible" },
    )

    if (richParameter.type === "bool") {
      const parameterField = await parameterLabel.waitForSelector(
        "[data-testid='parameter-field-bool'] .MuiRadio-root input[value='" +
          buildParameter.value +
          "']",
      )
      await parameterField.check()
    } else if (richParameter.options.length > 0) {
      const parameterField = await parameterLabel.waitForSelector(
        "[data-testid='parameter-field-options'] .MuiRadio-root input[value='" +
          buildParameter.value +
          "']",
      )
      await parameterField.check()
    } else if (richParameter.type === "list(string)") {
      throw new Error("not implemented yet") // FIXME
    } else {
      // text or number
      const parameterField = await parameterLabel.waitForSelector(
        "[data-testid='parameter-field-text'] input",
      )
      await parameterField.fill(buildParameter.value)
    }
  }
}
