import crypto from "crypto";
import "dotenv/config";
import debug from "debug";
import express from "express";
import basicAuth from "express-basic-auth";
import { createBearerMiddleware } from "../../utils/backend/bearer_middleware";
import { JSONFileDatabase } from "../database/database";

/**
 * Prefix your start command with `DEBUG=express:route:oauth` to enable debug logging
 * for this middleware
 */
const debugLogger = debug("express:route:oauth");

/**
 * These are the hard-coded credentials for logging in to this template.
 */
const USERNAME = "username";
const PASSWORD = "password";

const COOKIE_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
const TOKEN_EXPIRY_S = 4 * 60 * 60; // 4 hours
const REFRESH_EXPIRY_S = 30 * 24 * 3600; // 30 days

const CLIENT_ID = "client_id";
const CLIENT_SECRET = "client_secret";
const REDIRECT_URI = "https://www.canva.com/apps/oauth/authorized";

/**
 * For simplicity, we simply generate random token values and store them in our database
 * for production you may want to use JWTs or other stateless methods.
 */
interface Token {
  value: string;
  expiry: number; // Unix timestamp
}

export interface User {
  id: string;
  authorizationCode?: Token;
  accessToken?: Token;
  refreshToken?: Token;
}

interface RedirectUriRecord {
  redirectUri: string;
  authorizationCode: string;
}

export interface Data {
  users: User[];
  redirect_uris: RedirectUriRecord[];
}

/**
 * For more information on Authentication, refer to our documentation: {@link https://www.canva.dev/docs/apps/authenticating-users/#types-of-authentication}.
 */
export const createAuthRouter = () => {
  /**
   * Set up a database with a "users" table. In this example code, the
   * database is simply a JSON file.
   */
  const db = new JSONFileDatabase<Data>({ users: [], redirect_uris: [] });

  const router = express.Router();

  /**
   * The urlencoded middleware allows us to parse form data from the request body.
   * This is required for the OAuth 2.0 Token Exchange endpoint.
   */
  router.use(express.urlencoded({ extended: true }));

  /**
   * This is the OAuth 2.0 Authorization endpoint
   * https://datatracker.ietf.org/doc/html/rfc6749#section-3.1
   *
   * This endpoint does not implement PKCE, which you would want to do
   * for production.
   */
  router.get(
    "/auth/authorize",
    basicAuth({
      users: { [USERNAME]: PASSWORD },
      challenge: true,
    }),
    async (req, res) => {
      const redirectUri = decodeURIComponent(
        req.query.redirect_uri?.toString() ?? REDIRECT_URI,
      );
      const clientId = req.query.client_id?.toString();
      const responseType = req.query.response_type?.toString();
      const state = req.query.state?.toString();

      if (!clientId) {
        return res.status(400).send("Missing required client ID parameter");
      }

      if (clientId !== CLIENT_ID) {
        return res.status(400).send("Invalid client ID");
      }

      if (redirectUri !== REDIRECT_URI) {
        return res.status(400).send(`Invalid redirect URI ${redirectUri}`);
      }

      const failureResponse = (error: string) => {
        const params = new URLSearchParams({
          error,
          state: state || "",
        });
        res.redirect(302, `${redirectUri}?${params}`);
      };

      if (responseType !== "code") {
        return failureResponse("unsupported_response_type");
      }

      // You should implement PKCE to protect against authorization code interception attacks
      // https://datatracker.ietf.org/doc/html/rfc7636
      // this is left off for simplicity in this example

      // Generate a unique authorization code
      const authorizationCode = crypto.randomUUID();

      // Load the database
      const data = await db.read();

      // Add the user to the database if they aren't there already
      if (!data.users.find((user) => user.id === USERNAME)) {
        data.users.push({
          id: USERNAME,
          authorizationCode: {
            value: authorizationCode,
            expiry: Date.now() + COOKIE_EXPIRY_MS,
          },
        });
      } else {
        // If the user is there, update the authorization code
        // In a production system you would allow at least one authorization code
        // per user per client, but here we simplify and have one per user.
        data.users = data.users.map((user) => {
          if (user.id === USERNAME) {
            return {
              ...user,
              authorizationCode: {
                value: authorizationCode,
                expiry: Date.now() + COOKIE_EXPIRY_MS,
              },
            };
          }
          return user;
        });
      }
      if (
        !data.redirect_uris.find((record) => record.redirectUri === redirectUri)
      ) {
        data.redirect_uris.push({ redirectUri, authorizationCode });
      } else {
        data.redirect_uris = data.redirect_uris.map((record) => {
          if (record.redirectUri === redirectUri) {
            return { ...record, authorizationCode };
          }
          return record;
        });
      }
      await db.write(data);

      res.redirect(
        302,
        `${redirectUri}?code=${authorizationCode}&state=${state}`,
      );
    },
  );

  interface AuthorizationExchangeRequest {
    grant_type: "authorization_code";
    code: string;
    redirect_uri?: string;
  }

  interface RefreshTokenExchangeRequest {
    grant_type: "refresh_token";
    refresh_token: string;
  }

  const failureResponse = (res: express.Response, error: string) => {
    return res.status(400).send(`{"error": "${error}"}`);
  };

  const authorizationCodeExchange = async (
    res: express.Response,
    body: AuthorizationExchangeRequest,
  ) => {
    const refresh_token = crypto.randomUUID();
    const access_token = crypto.randomUUID();

    const code = body.code?.toString();

    if (!code) {
      return failureResponse(res, "invalid_request");
    }

    // Load the database
    const data = await db.read();
    const lastRedirectUrl = data.redirect_uris.find(
      (record) => record.authorizationCode === code,
    )?.redirectUri;

    // The specification requires that if the redirect uri was present in the authorization request,
    // it must be included and the same in the token exchange request
    if (lastRedirectUrl !== body.redirect_uri?.toString()) {
      return failureResponse(res, "invalid_request");
    }

    // Find the user who was issued this authorization code
    const user = data.users.find(
      (user) => user.authorizationCode?.value === code,
    );

    if (!user || !user.authorizationCode) {
      return failureResponse(res, "invalid_grant");
    }

    if (user.authorizationCode.expiry < Date.now()) {
      return failureResponse(res, "invalid_grant");
    }

    // Update the user with the new access token and refresh token
    // and clear out their authorization code. Authorization codes
    // are single use, so attempting to use the old one will now fail
    data.users = data.users.map((user) => {
      if (user.id === USERNAME) {
        return {
          ...user,
          authorizationCode: undefined,
          accessToken: {
            value: access_token,
            expiry: Date.now() + TOKEN_EXPIRY_S * 1000,
          },
          refreshToken: {
            value: refresh_token,
            expiry: Date.now() + REFRESH_EXPIRY_S * 1000,
          },
        };
      }
      return user;
    });
    await db.write(data);

    return res.status(200).send(
      JSON.stringify({
        access_token,
        refresh_token,
        token_type: "bearer",
        expires_in: TOKEN_EXPIRY_S,
      }),
    );
  };

  const refreshTokenExchange = async (
    res: express.Response,
    body: RefreshTokenExchangeRequest,
  ) => {
    const refresh_token = crypto.randomUUID();
    const access_token = crypto.randomUUID();

    const old_refresh_token = body.refresh_token?.toString();
    if (!old_refresh_token) {
      return failureResponse(res, "invalid_request");
    }

    // Find the user who was issued this refresh token
    const data = await db.read();
    const user = data.users.find(
      (user) => user.refreshToken?.value === old_refresh_token,
    );

    if (!user) {
      return failureResponse(res, "invalid_grant");
    }

    // Update the user with the new refresh token
    // refresh tokens are single use, so attempting
    // to use the old one will now fail
    data.users = data.users.map((u) => {
      if (u.id === user.id) {
        return {
          ...u,
          accessToken: {
            value: access_token,
            expiry: Date.now() + TOKEN_EXPIRY_S * 1000,
          },
          refreshToken: {
            value: refresh_token,
            expiry: Date.now() + REFRESH_EXPIRY_S * 1000,
          },
        };
      }
      return u;
    });
    await db.write(data);

    return res.status(200).send(
      JSON.stringify({
        access_token,
        refresh_token,
        token_type: "bearer",
        expires_in: TOKEN_EXPIRY_S,
      }),
    );
  };

  /**
   * This endpoint is the Oauth 2.0 Token endpoint
   * https://datatracker.ietf.org/doc/html/rfc6749#section-3.2
   * It serves both exchanging authorization codes for access tokens (and refresh tokens), and
   * exchanging refresh tokens for new access tokens (and refresh tokens)
   */
  router.post(
    "/auth/token",
    basicAuth({
      users: { [CLIENT_ID]: CLIENT_SECRET },
      challenge: false,
    }),
    async (req, res) => {
      const body = (await req.body) as
        | AuthorizationExchangeRequest
        | RefreshTokenExchangeRequest;

      const grantType = body.grant_type?.toString();

      if (!grantType) {
        return failureResponse(res, "invalid_request");
      }

      if (grantType !== "authorization_code" && grantType !== "refresh_token") {
        return failureResponse(res, "unsupported_grant_type");
      }

      if (grantType === "refresh_token") {
        refreshTokenExchange(res, body as RefreshTokenExchangeRequest);
      }

      return authorizationCodeExchange(
        res,
        body as AuthorizationExchangeRequest,
      );
    },
  );

  /**
   * TODO: Add this middleware to all routes that will receive requests from
   * your app.
   */
  const bearerMiddleware = createBearerMiddleware(
    async (access_token: string): Promise<string | undefined> => {
      const data = await db.read();
      const user = data.users.find(
        (user) => user.accessToken?.value === access_token,
      );
      debugLogger(
        `User found: ${user?.id}, expires ${user?.accessToken?.expiry} compare ${Date.now()}`,
      );

      const isAuthenticated =
        user && user.accessToken && user.accessToken?.expiry > Date.now();
      if (!isAuthenticated) {
        return;
      }
      return user.id;
    },
  );

  /**
   * All routes that start with /api will be protected by bearer authentication
   */
  router.use("/api", bearerMiddleware);

  /**
   * This endpoint checks if a user is authenticated.
   */
  router.post("/api/authentication/status", async (req, res) => {
    // Check if the user is authenticated
    const isAuthenticated = !!req["user_id"];

    // Return the authentication status
    res.send({
      isAuthenticated,
    });
  });

  return router;
};
