// web-app.ts
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved

import env from "./config/env.js";
import assert from "node:assert";

import path from "node:path";
import http from "node:http";

import "./lib/db.js";
import redis from "./lib/redis.js";

import cookieParser from "cookie-parser";
import compress from "compression";
import methodOverride from "method-override";
import favicon from "serve-favicon";

import session, { SessionOptions } from "express-session";
import { RedisStore } from "connect-redis";

import express, { NextFunction, Request, Response } from "express";
import morgan from "morgan";
import * as rfs from "rotating-file-stream";

import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime.js";
dayjs.extend(relativeTime);

import numeral from "numeral";

type SameSiteOption = boolean | "lax" | "strict" | "none" | undefined;

import { GadgetComponent, GadgetLog } from "@gadget/api";

import { User } from "./models/user.js";

import { ApiController } from "./controllers/api.js";
import { HomeController } from "./controllers/home.js";
import { UserController } from "./controllers/user.js";

import {
  ApiClientService,
  SessionService,
  SocketService,
  startServices,
  stopServices,
} from "./services/index.js";

class DtpWebAppServer implements GadgetComponent {
  private log: GadgetLog;

  private app?: express.Application;
  private server?: http.Server;

  get name(): string {
    return "DtpWebAppServer";
  }
  get slug(): string {
    return "web-app";
  }

  constructor() {
    this.log = new GadgetLog(this);
  }

  async start(): Promise<void> {
    this.hookProcessSignals();

    await startServices();

    await this.createExpressApp();
    await this.startHttpServer();

    this.log.info(
      `DTP application server online at http://${env.https.address}:${env.https.port}/`,
    );
  }

  async stop(): Promise<number> {
    await this.stopHttpServer();
    await stopServices();

    return 0;
  }

  async createExpressApp(): Promise<void> {
    this.log.info("creating ExpressJS app");
    this.app = express();
    this.app.set("trust proxy", true);
    this.app.set("x-powered-by", false);

    this.app.locals.env = env;
    this.app.locals.pkg = env.pkg;
    this.app.locals.site = env.site;
    this.app.locals.dayjs = dayjs;
    this.app.locals.numeral = numeral;

    if (env.log.https.enabled) {
      this.log.info("creating HTTPS access log", {
        name: env.log.https.name,
        path: env.log.https.path,
      });
      const httpLogStream = rfs.createStream(
        env.log.https.name || "gadget-code-access.log",
        {
          interval: "1d",
          path: env.log.https.path,
          compress: "gzip",
        },
      );
      this.app.use(morgan(env.log.https.format, { stream: httpLogStream }));
    }

    /*
     * Static file services
     */
    this.app.use(
      favicon(path.join(env.installDir, "assets", "icon", "icon-32x32.png")),
    );
    this.app.use(
      "/assets",
      express.static(path.resolve(env.installDir, "assets")),
    );
    this.app.use(
      "/client",
      express.static(path.resolve(env.installDir, "dist", "client")),
    );

    /*
     * REST, User, and Session support
     */

    this.app.use(async (req: Request, res: Response, next: NextFunction) => {
      await SessionService.recordWebVisit(req);

      if (req.query) {
        res.locals.query = req.query;
      }

      const contentType = req.get("content-type") || "";
      if (!contentType.includes("application/json")) {
        return next();
      }

      let data: string = "";
      req.on("data", (chunk) => {
        data += chunk.toString();
      });
      req.on("end", () => {
        req.rawBody = data;
        if (!data) {
          req.body = {};
          return next();
        }
        try {
          const parsedData = JSON.parse(data);
          req.body = parsedData;
        } catch (err) {
          return next(err);
        }
        next();
      });
    });

    this.app.use(express.json());
    this.app.use(express.urlencoded({ extended: true }));
    this.app.use(cookieParser(env.session.secret));
    this.app.use(compress());
    this.app.use(methodOverride());

    const ONE_WEEK = 1000 * 60 * 60 * 24 * 7;
    const SESSION_DURATION = ONE_WEEK;

    /*
     * ExpressJS session configuration
     */

    const store = new RedisStore({
      client: redis,
    });

    assert(env.session.secret, "Must define an HTTP session secret");
    const sessionConfig: SessionOptions = {
      name: `mtsess-${env.site.domainKey}-${env.NODE_ENV}`,
      secret: env.session.secret,
      resave: true,
      saveUninitialized: true,
      proxy: env.session.trustProxy,
      cookie: {
        path: "/",
        httpOnly: true,
        secure: env.session.cookie.secure,
        sameSite: env.session.cookie.sameSite as SameSiteOption,
        maxAge: SESSION_DURATION,
      },
      store,
    };

    this.app.use(session(sessionConfig));
    this.app.use(ApiClientService.middleware());
    this.app.use(this.restoreUserSession.bind(this));

    this.app.use("/", await this.createAppRouter());

    this.app.use(this.defaultErrorHandler.bind(this));
  }

  async createAppRouter(): Promise<express.Router> {
    const router: express.Router = express.Router();

    const apiController = new ApiController();
    await apiController.start();
    this.log.info("mounting ApiController", {
      route: apiController.route,
    });
    router.use(apiController.route, apiController.router);

    const userController = new UserController();
    await userController.start();
    this.log.info("mounting UserController", {
      route: userController.route,
    });
    router.use(userController.route, userController.router);

    const homeController = new HomeController();
    await homeController.start();
    this.log.info("mounting HomeController", { route: homeController.route });
    router.use(homeController.route, homeController.router);

    return router;
  }

  async startHttpServer(): Promise<void> {
    return new Promise<void>(async (resolve) => {
      assert(this.app, "ExpressJS app instance is required");
      this.log.info("starting HTTP server", {
        address: env.https.address,
        port: env.https.port,
      });

      /*
       * Create HTTP server
       */
      this.server = http.createServer(this.app);

      /*
       * Start the Socket.IO service
       */
      await SocketService.listen(this.server);

      /*
       * Start HTTP server
       */
      this.server.listen(
        env.https.port,
        env.https.address,
        env.https.backlog,
        () => {
          this.log.debug("HTTP server started");
          resolve();
        },
      );
    });
  }

  async stopHttpServer(): Promise<void> {
    return new Promise<void>((resolve, reject) => {
      if (!this.server) {
        return;
      }

      this.log.info("closing all HTTP connections");
      this.server.closeAllConnections();

      if (!this.server.listening) {
        delete this.server;
        return resolve();
      }

      this.log.info("closing HTTP server");
      this.server.close((error) => {
        if (error) {
          return reject(error);
        }
        delete this.server;
        resolve();
      });
    });
  }

  async defaultErrorHandler(
    err: Error,
    _req: Request,
    res: Response,
    _next: NextFunction,
  ) {
    this.log.error("ExpressJS error", { error: err });
    res.locals.errorCode = err.statusCode || 500;
    res.locals.error = err;
    res.status(res.locals.errorCode).json({
      success: false,
      message: err.message,
    });
  }

  async restoreUserSession(
    req: Request,
    res: Response,
    next: NextFunction,
  ): Promise<void> {
    let token;
    if (req.headers["authorization"]) {
      const tokens = req.headers["authorization"].split(" ");
      token = tokens[tokens.length - 1];
    }
    if (!token && (!req.session || !req.session.user)) {
      return next();
    }

    if (token) {
      req.user = await SessionService.verifyJsonWebToken(token);
    } else {
      req.user = await User.findOne({ _id: req.session.user._id });
    }

    res.locals.user = req.user;
    if (!res.locals.user) {
      req.session.destroy((err: Error) => {
        if (err) {
          return next(err);
        }
        return next();
      });
      return;
    }

    return next();
  }

  hookProcessSignals(): void {
    process.title = this.name;
    process.on("unhandledRejection", async (error: Error, p) => {
      this.log.error("Unhandled rejection", {
        error: error,
        promise: p,
        stack: error.stack,
      });

      const exitCode: number = await this.stop();
      process.nextTick(() => {
        process.exit(exitCode);
      });
    });

    process.on("warning", (error) => {
      if (error.name === "DeprecationWarning") {
        return;
      }
      this.log.alert("warning", { error });
    });

    process.once("SIGINT", async () => {
      this.log.info("SIGINT received (requesting shutdown)");
      const exitCode = await this.stop();
      process.nextTick(() => {
        process.exit(exitCode);
      });
    });
  }
}

(async () => {
  try {
    const server = new DtpWebAppServer();
    await server.start();
  } catch (error) {
    console.error("DTP Web App server error", { error });
    process.exit(-1);
  }
})();
