import { errors, BaseClient, custom } from "openid-client";
import {
  globalConfigPath,
  rootDirectory,
  GlobalConfig,
  getAuthHeader,
  bigBrainAPI,
} from "./utils.js";
import open from "open";
import chalk from "chalk";
import { provisionHost } from "./config.js";
import { version } from "../../index.js";
import axios, { AxiosRequestConfig } from "axios";
import { Context } from "./context.js";
import { Issuer } from "openid-client";
import inquirer from "inquirer";
import { hostname } from "os";
import { execSync } from "child_process";

const SCOPE = "openid email profile";

// Per https://github.com/panva/node-openid-client/tree/main/docs#customizing
custom.setHttpOptionsDefaults({
  timeout: 10000,
});

async function writeGlobalConfig(ctx: Context, config: GlobalConfig) {
  const dirName = rootDirectory();
  ctx.fs.mkdir(dirName, { allowExisting: true });
  const path = globalConfigPath();
  try {
    ctx.fs.writeUtf8File(path, JSON.stringify(config));
  } catch (err) {
    console.log(
      chalk.red(`Failed to write auth config to ${path} with error: ${err}`)
    );
    return await ctx.fatalError(1, "fs", err);
  }
  console.log(
    chalk.green(`Successfully wrote your auth credentials to ${path}!`)
  );
}

export async function checkAuthorization(ctx: Context): Promise<boolean> {
  const header = await getAuthHeader(ctx);
  if (!header) {
    return false;
  }
  try {
    const resp = await axios.head(`${provisionHost}/api/${version}/authorize`, {
      headers: { Authorization: header },
      // Don't throw an error if this request returns a non-200 status.
      // Big Brain responds with a variety of error codes -- 401 if the token is correctly-formed but not valid, and either 400 or 500 if the token is ill-formed.
      // We only care if this check returns a 200 code (so we can skip logging in again) -- any other errors should be silently skipped and we'll run the whole login flow again.
      validateStatus: _ => true,
    });
    return resp.status == 200;
  } catch (e: any) {
    // This `catch` block should only be hit if a network error was encountered and axios didn't receive any sort of response.
    console.log(chalk.gray(`Unexpected error when authorizing: ${e}`));
    return false;
  }
}

async function performDeviceAuthorization(
  ctx: Context,
  auth0Client: BaseClient,
  shouldOpen: boolean
): Promise<string> {
  // Device authorization flow follows this guide: https://github.com/auth0/auth0-device-flow-cli-sample/blob/9f0f3b76a6cd56ea8d99e76769187ea5102d519d/cli.js

  // Device Authorization Request - https://tools.ietf.org/html/rfc8628#section-3.1
  // Get authentication URL
  const handle = await auth0Client.deviceAuthorization({
    scope: SCOPE,
    audience: "https://console.convex.dev/api/",
  });

  // Device Authorization Response - https://tools.ietf.org/html/rfc8628#section-3.2
  // Open authentication URL
  const { verification_uri_complete, user_code, expires_in } = handle;
  console.log(
    `Visit ${verification_uri_complete} to finish logging in. You should see the following code which expires in ${
      expires_in % 60 === 0
        ? `${expires_in / 60} minutes`
        : `${expires_in} seconds`
    }: ${user_code}`
  );
  if (shouldOpen) {
    shouldOpen = (
      await inquirer.prompt([
        {
          name: "openBrowser",
          message: `Open in browser?`,
          type: "confirm",
          default: true,
        },
      ])
    ).openBrowser;
  }

  if (shouldOpen) {
    console.log(
      `Opening ${verification_uri_complete} in your browser to log in...`
    );
    try {
      await open(verification_uri_complete);
    } catch (err: any) {
      console.log(chalk.red(`Unable to open browser.`));
      console.log(
        `Manually open ${verification_uri_complete} in your browser to log in.`
      );
    }
  } else {
    console.log(`Open ${verification_uri_complete} in your browser to log in.`);
  }

  // Device Access Token Request - https://tools.ietf.org/html/rfc8628#section-3.4
  // Device Access Token Response - https://tools.ietf.org/html/rfc8628#section-3.5
  try {
    const tokens = await handle.poll();
    if (typeof tokens.access_token == "string") {
      return tokens.access_token;
    } else {
      throw Error("Access token is missing");
    }
  } catch (err: any) {
    switch (err.error) {
      case "access_denied": // end-user declined the device confirmation prompt, consent or rules failed
        console.error("Access denied.");
        return await ctx.fatalError(1, err);
      case "expired_token": // end-user did not complete the interaction in time
        console.error("Device flow expired.");
        return await ctx.fatalError(1, err);
      default:
        if (err instanceof errors.OPError) {
          console.error(
            `Error = ${err.error}; error_description = ${err.error_description}`
          );
        } else {
          console.error(`Login failed with error: ${err}`);
        }
        return await ctx.fatalError(1, err);
    }
  }
}

async function performPasswordAuthentication(
  ctx: Context,
  issuer: string,
  clientId: string,
  username: string,
  password: string
): Promise<string> {
  // Unfortunately, `openid-client` doesn't support the resource owner password credentials flow so we need to manually send the requests.
  const options: AxiosRequestConfig = {
    method: "POST",
    url: new URL("/oauth/token", issuer).href,
    headers: { "content-type": "application/x-www-form-urlencoded" },
    data: new URLSearchParams({
      grant_type: "password",
      username: username,
      password: password,
      scope: SCOPE,
      client_id: clientId,
      audience: "https://console.convex.dev/api/",
      // Note that there is no client secret provided, as Auth0 refuses to require it for untrusted apps.
    }),
  };

  try {
    const response = await axios.request(options);
    if (typeof response.data.access_token == "string") {
      return response.data.access_token;
    } else {
      throw Error("Access token is missing");
    }
  } catch (err: any) {
    console.log(`Password flow failed: ${err}`);
    if (err.response) {
      console.log(`${JSON.stringify(err.response.data)}`);
    }
    return await ctx.fatalError(1, err);
  }
}

export async function performLogin(
  ctx: Context,
  overrideAuthUrl?: string,
  overrideAuthClient?: string,
  overrideAuthUsername?: string,
  overrideAuthPassword?: string,
  open = true,
  deviceNameOverride?: string
) {
  // Get access token from big-brain
  // Default the device name to the hostname, but allow the user to change this if the terminal is interactive.
  // On Macs, the `hostname()` may be a weirdly-truncated form of the computer name. Attempt to read the "real" name before falling back to hostname.
  let deviceName = deviceNameOverride ?? "";
  if (!deviceName && process.platform == "darwin") {
    try {
      deviceName = execSync("scutil --get ComputerName").toString().trim();
    } catch {
      // Just fall back to the hostname default below.
    }
  }
  if (!deviceName) {
    deviceName = hostname();
  }
  if (process.stdin.isTTY && !deviceNameOverride) {
    const answers = await inquirer.prompt([
      {
        type: "input",
        name: "deviceName",
        message: "Enter a name for the device being authorized:",
        default: deviceName,
      },
    ]);
    deviceName = answers.deviceName;
  }

  const issuer = overrideAuthUrl ?? "https://auth.convex.dev";
  const auth0 = await Issuer.discover(issuer);
  const clientId = overrideAuthClient ?? "HFtA247jp9iNs08NTLIB7JsNPMmRIyfi";
  const auth0Client = new auth0.Client({
    client_id: clientId,
    token_endpoint_auth_method: "none",
    id_token_signed_response_alg: "RS256",
  });

  let accessToken: string;
  if (overrideAuthUsername && overrideAuthPassword) {
    accessToken = await performPasswordAuthentication(
      ctx,
      issuer,
      clientId,
      overrideAuthUsername,
      overrideAuthPassword
    );
  } else {
    accessToken = await performDeviceAuthorization(ctx, auth0Client, open);
  }
  interface AuthorizeArgs {
    authnToken: string;
    deviceName: string;
  }
  const authorizeArgs: AuthorizeArgs = {
    authnToken: accessToken,
    deviceName: deviceName,
  };
  const data = await bigBrainAPI(ctx, "POST", "authorize", authorizeArgs);
  const globalConfig = { accessToken: data.accessToken };
  try {
    await writeGlobalConfig(ctx, globalConfig);
  } catch (err: any) {
    return await ctx.fatalError(1, "fs", err);
  }
  console.log(chalk.green("Successfully logged in and authorized device"));
}
