// src/lib/controller.js
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved

import env from "../config/env.js";

import path from "node:path";
import { v4 as uuidv4 } from "uuid";
import { rateLimit } from "express-rate-limit";

import {
  Router,
  Request,
  Response,
  NextFunction,
  RequestHandler,
} from "express";

import multer from "multer";

import dayjs from "dayjs";

export interface CsrfTokenOptions {
  name: string;
  expiresMinutes: number;
  allowReuse: boolean;
}

import { ApiClientStatus } from "../models/api-client.js";
import { CsrfToken, ICsrfToken } from "../models/csrf-token.js";
import { GadgetId, IUser } from "@gadget/api";

import { GadgetComponent, GadgetLog } from "@gadget/api";
import { DtpPaginationParameters } from "./pagination-parameters.js";

import ApiClientService from "../services/api-client.js";
import CryptoService from "../services/crypto.js";

/**
 * The base class for all Web application controllers. A controller binds to an
 * HTTP application REST route, and processes requests received on that route.
 *
 * This is usually accomplished by calling service methods congigured using
 * request parameters, headers, and body content, then rendering HTML page or
 * JSON object responses.
 */
export abstract class DtpController implements GadgetComponent {
  log: GadgetLog;
  router: Router;

  abstract get name(): string;
  abstract get slug(): string;
  abstract get route(): string;

  constructor() {
    this.log = new GadgetLog(this);
    this.router = Router();
    this.router.use(this.middleware.bind(this));
  }

  abstract start(): Promise<void>;

  /**
   * Middleware common to all controllers that populates the view model with
   * some commonly expected values.
   * @param _req Request The request being processed.
   * @param res  Response The response being generated.
   * @param next NextFunction The next function to call when done.
   */
   middleware(req: Request, res: Response, next: NextFunction) {
     res.locals.request = req;
     res.locals.currentView = this.slug;
     next();
   }

  hmacMiddleware() {
    return async (req: Request, res: Response, next: NextFunction) => {
      if (!req.rawBody) {
        this.log.error("No raw body found");
        res.status(401).json({
          success: false,
          message: "No raw body found",
        });
        return;
      }

      const apiClientId = req.headers["x-dtp-client-id"];
      if (!apiClientId) {
        this.log.error("API Client ID is required");
        res.status(401).json({
          success: false,
          message: "API Client ID is required",
        });
        return;
      }

      const apiClient = await ApiClientService.getById(apiClientId as GadgetId);
      if (!apiClient) {
        this.log.error("API client not found", { _id: apiClientId });
        res.status(404).json({
          success: false,
          message: "API Client not found",
        });
        return;
      }

      if (apiClient.status !== ApiClientStatus.Active) {
        this.log.error("inactive API client", {
          _id: apiClientId,
          status: apiClient.status,
        });
        res.status(403).json({
          success: false,
          message: "API client is not active",
        });
        return;
      }

      const hmacHeader = req.headers["x-dtp-hmac"];
      if (!hmacHeader) {
        this.log.error("No HMAC header found");
        res.status(401).json({
          success: false,
          message: "No HMAC header found",
        });
        return;
      }

      const hmacBody = CryptoService.createHmac(apiClient.secret, req.rawBody);
      if (hmacHeader !== hmacBody) {
        this.log.error("HMAC header does not match body", {
          hmacHeader,
          hmacBody,
        });
        res.status(401).json({
          success: false,
          message: "HMAC header does not match body",
        });
        return;
      }

      this.log.debug("HMAC header matches body");

      next();
    };
  }

  requireUser(): RequestHandler {
    return async (
      req: Request,
      res: Response,
      next: NextFunction,
    ): Promise<void> => {
      if (!req.user) {
        res
          .status(403)
          .json({ success: false, message: "Authentication required" });
        return;
      }
      next();
    };
  }

  requireAdmin(): RequestHandler {
    return async (
      req: Request,
      res: Response,
      next: NextFunction,
    ): Promise<void> => {
      const user: IUser | null | undefined = req.user;
      if (!user || !user.flags.isAdmin) {
        res
          .status(403)
          .json({ success: false, message: "Admin access required" });
        return;
      }
      next();
    };
  }

  async loadChild(filename: string): Promise<DtpController> {
    const pathObj = path.parse(filename);
    this.log.info("loading child controller", {
      script: pathObj.name,
      path: filename,
    });

    const ControllerClass = (await import(filename)).default;
    if (!ControllerClass) {
      this.log.error(
        "failed to receive a default export class from child controller",
        {
          script: pathObj.name,
        },
      );
      throw new Error("Child controller failed to provide a default export");
    }

    const controller: DtpController = new ControllerClass(ControllerClass);

    this.log.info("starting child controller", {
      name: ControllerClass.name,
    });
    await controller.start();

    const childRoute = this.route + controller.route;
    this.log.info("mounting child controller", {
      name: ControllerClass.name,
      childRoute,
    });
    this.router.use(controller.route, controller.router);

    return controller;
  }

  /**
   * Retrieves set pagination parameters from the request.
   * @param req Request The request being processed.
   * @param maxPerPage number The maximum number of records to display per page.
   * @param pageParamName string The name of the page index parameter.
   * @param cppParamName string The name of the count-per-page parameter.
   * @returns A new DtpPaginationParameters instance containing pagination
   * parameter values.
   */
  getPaginationParameters(
    req: Request,
    maxPerPage: number,
    pageParamName: string = "p",
    cppParamName: string = "cpp",
  ): DtpPaginationParameters {
    const pageParam: string = req.query[pageParamName]
      ? (req.query[pageParamName] as string)
      : "1";
    const cppParam: string = req.query[cppParamName]
      ? (req.query[cppParamName] as string)
      : maxPerPage.toString();
    const pagination: DtpPaginationParameters = {
      p: parseInt(pageParam, 10),
      skip: 0,
      cpp: parseInt(cppParam, 10),
    };
    if (pagination.p < 1) {
      pagination.p = 1;
    }
    if (pagination.cpp > maxPerPage) {
      pagination.cpp = maxPerPage;
    }
    pagination.skip = (pagination.p - 1) * pagination.cpp;
    return pagination;
  }

  createLimiter(
    seconds: number,
    limit: number,
    message: string,
    keyGenerator?: (req: Request, res: Response) => string,
  ): RequestHandler {
    return rateLimit({
      windowMs: seconds * 1000,
      limit,
      message: message || "Too many requests. Please try again later.",
      standardHeaders: "draft-8",
      legacyHeaders: false,
      statusCode: 429,
      keyGenerator,
    });
  }

  /**
   * Creates a `multipart/form-encoded` HTTP POST processor using the
   * [multer](https://www.npmjs.com/package/multer) estension.
   * @param slug string The path slug into which files will be stored.
   * @param options multer.Options Options for the form processor.
   * @returns An ExpressJS middleware that enables a route to receive files.
   */
  createMulter(slug: string, options: multer.Options): multer.Multer {
    if (!!slug && typeof slug === "object") {
      options = slug;
      slug = this.slug;
    } else {
      slug = slug || this.slug;
    }

    options = Object.assign(
      {
        dest: path.join(env.https.uploadPath, slug),
      },
      options || {},
    );

    return multer(options);
  }

  /**
   * Generates a CSRF token used in forms to help prevent XSS attacks.
   * @param req Request The request for which a CSRF token will be generated.
   * @param options CsrfTokenOptions The options to be used when creating the
   * token.
   * @returns The new CsrfToken for use.
   */
  async createCsrfToken(
    req: Request,
    options: CsrfTokenOptions,
  ): Promise<ICsrfToken> {
    const NOW = new Date();

    options = Object.assign(
      {
        expiresMinutes: 30,
      },
      options,
    );

    if (options.expiresMinutes > 120) {
      const e = new Error("CSRF tokens have a max lifespan of 120 minutes");
      e.statusCode = 400;
      throw e;
    }

    const token = new CsrfToken();
    token.created = NOW;
    token.expires = dayjs(NOW).add(options.expiresMinutes, "minute").toDate();
    if (req.user) {
      token.user = req.user._id;
    }
    if (req.ip) {
      token.ip = req.ip;
    }
    token.token = uuidv4();
    await token.save();

    const tokenObj = token.toObject();
    tokenObj.name = `csrf-token-${options.name}`;

    return tokenObj;
  }

  /**
   * Force-save the current user session and ensure it's written before
   * proceeding.
   * @param req Express.Request The request being processed.
   * @returns A promise that resolves when the session is saved.
   */
  async saveSession(req: Request): Promise<void> {
    return new Promise((resolve, reject) => {
      req.session.save((err) => {
        if (err) {
          return reject(err);
        }
        resolve();
      });
    });
  }
}
