import path from "node:path";
import cookieParser from "cookie-parser";
import cors from "cors";
import express, {
  type ErrorRequestHandler,
  type Express,
  type RequestHandler,
  type Router,
} from "express";

/** HTTP verbs {@link ExpressServer.addRoute} accepts. */
export type HttpMethod = "get" | "post" | "put" | "patch" | "delete";

/** Options for {@link ExpressServer.setupCore}. */
export interface ExpressServerCoreOptions {
  /**
   * Origin(s) allowed by CORS — a single origin, or a list when more than
   * one frontend talks to this API from a different origin (e.g. the main
   * app plus a separate admin app). Passed straight through to the `cors`
   * package, which matches an incoming `Origin` against any entry of the
   * list.
   */
  corsOrigin: string | string[];
}

/**
 * Thin wrapper around an Express application: bundles the common
 * "set up the standard middleware stack, register routes without
 * duplicates, wire the error handler, start listening" concerns behind a
 * small typed API, instead of every service in the monorepo repeating the
 * same raw `express()` setup by hand.
 *
 * Framework-specific on purpose — unlike `ErrorHandlerService` (which has
 * no Express dependency at all), this class *is* the Express integration
 * layer. Business/domain code should never import `express` directly;
 * it goes through this instead.
 */
export class ExpressServer {
  /** The underlying Express application. */
  private readonly _app: Express;
  /** Tracks `"METHOD path"` keys already registered via {@link addRoute}, to warn instead of silently double-registering a route. */
  private readonly _registeredRoutes = new Set<string>();

  public constructor() {
    this._app = express();
  }

  /** The underlying Express application — needed by test tooling (e.g. supertest) that expects a raw `Express` instance. */
  public get instance(): Express {
    return this._app;
  }

  /**
   * Registers the standard middleware stack every service in this
   * monorepo needs: CORS (with credentials, for the session cookie),
   * JSON body parsing, and cookie parsing. Call once, before registering
   * any route.
   */
  public setupCore(options: ExpressServerCoreOptions): void {
    this._app.use(cors({ origin: options.corsOrigin, credentials: true }));
    this._app.use(express.json());
    this._app.use(cookieParser());
  }

  /** Registers a middleware that runs on every request (e.g. logging, a catch-all 404 handler). */
  public addMiddleware(middleware: RequestHandler): void {
    this._app.use(middleware);
  }

  /**
   * Registers the final Express error-handling middleware (the 4-argument
   * form). Must be added last — Express only treats a middleware as an
   * error handler by its arity, and only the last matching one runs.
   */
  public setErrorHandler(middleware: ErrorRequestHandler): void {
    this._app.use(middleware);
  }

  /** Mounts a whole `express.Router` under a base path (e.g. `mountRouter("/auth", authRouter)`). */
  public mountRouter(basePath: string, router: Router): void {
    this._app.use(basePath, router);
  }

  /**
   * Serves a built single-page app (static assets + SPA fallback) from
   * `distDir`. Call this **after** every `mountRouter`/`addRoute`, so API
   * routes always win, and **before** {@link addMiddleware}'s catch-all
   * 404 — any GET request that doesn't match an API route or a file in
   * `distDir` falls through to `index.html`, letting the client-side
   * router (e.g. react-router) handle it instead of a 404.
   *
   * Only meant for the production Docker image, where the frontend build
   * is copied alongside the API — native dev (`pnpm dev:api`) never calls
   * this, so `pnpm dev:web`'s own Vite dev server is unaffected.
   */
  public serveStaticFrontend(distDir: string): void {
    this._app.use(express.static(distDir));
    this._app.get("*", (_req, res) => {
      res.sendFile(path.join(distDir, "index.html"));
    });
  }

  /**
   * Registers a single route with its handler(s). Warns and skips instead
   * of registering if the same method+path was already added — catches a
   * copy-paste mistake at startup instead of silently shadowing a route.
   */
  public addRoute(method: HttpMethod, path: string, ...handlers: RequestHandler[]): void {
    const key = `${method.toUpperCase()} ${path}`;
    if (this._registeredRoutes.has(key)) {
      console.warn(`[ExpressServer] Route already registered, skipping: ${key}`);
      return;
    }
    this._registeredRoutes.add(key);
    this._app[method](path, ...handlers);
  }

  /** Starts listening on the given port. `onListening` is called once the server is up (e.g. to log the URL). */
  public listen(port: number, onListening?: () => void): void {
    this._app.listen(port, onListening);
  }
}
