import { greet } from "@colyseus/greeting-banner";
import type express from 'express';
import type { Server as HttpServer } from 'node:http';

import { debugAndPrintError } from './Debug.ts';
import * as matchMaker from './MatchMaker.ts';
import { RegisteredHandler } from './matchmaker/RegisteredHandler.ts';

import { type OnCreateOptions, Room } from './Room.ts';
import { Deferred, registerGracefulShutdown, dynamicImport, type Type } from './utils/Utils.ts';

import type { Presence } from "./presence/Presence.ts";

import { setTransport, Transport } from './Transport.ts';
import { logger, setLogger } from './Logger.ts';
import { setDevMode, isDevMode } from './utils/DevMode.ts';
import { type Router, bindRouterToTransport } from './router/index.ts';
import { prereadRequestBodies } from './router/node.ts';
import { type SDKTypes as SharedSDKTypes } from '@colyseus/shared-types';
import { getDefaultRouter } from './router/default_routes.ts';

export type ServerOptions = {
  publicAddress?: string,
  presence?: Presence,
  driver?: matchMaker.MatchMakerDriver,
  transport?: Transport,
  gracefullyShutdown?: boolean,
  logger?: any;

  /**
   * Optional callback to execute before the server listens.
   * This is useful for example to connect into a database or other services before the server listens.
   */
  beforeListen?: () => Promise<void> | void,

  /**
   * Optional callback to configure Express routes.
   * When provided, the transport layer will initialize an Express-compatible app
   * and pass it to this callback for custom route configuration.
   *
   * For uWebSockets transport, this uses the uwebsockets-express module.
   */
  express?: (app: express.Application) => Promise<void> | void,

  /**
   * Custom function to determine which process should handle room creation.
   * Default: assign new rooms the process with least amount of rooms created
   */
  selectProcessIdToCreateRoom?: matchMaker.SelectProcessIdCallback;

  /**
   * Whether this process is running as a standalone match-maker or not. (default: false)
   * When enabled, this process will not spawn rooms and will only be responsible for matchmaking.
   */
  isStandaloneMatchMaker?: boolean; 

  /**
   * If enabled, rooms are going to be restored in the server-side upon restart,
   * clients are going to automatically re-connect when server reboots.
   *
   * Beware of "schema mismatch" issues. When updating Schema structures and
   * reloading existing data, you may see "schema mismatch" errors in the
   * client-side.
   *
   * (This operation is costly and should not be used in a production
   * environment)
   */
  devMode?: boolean,

  /**
   * Display greeting message on server start.
   * Default: true
   */
  greet?: boolean,
};

/**
 * Exposed types for the client-side SDK.
 * Re-exported from @colyseus/shared-types with specific type constraints.
 */
export interface SDKTypes<
  RoomTypes extends Record<string, RegisteredHandler> = any,
  Routes extends Router = any
> extends SharedSDKTypes<RoomTypes, Routes> {}

export class Server<
  RoomTypes extends Record<string, RegisteredHandler> = any,
  Routes extends Router = any
> implements SDKTypes<RoomTypes, Routes> {
  '~rooms': RoomTypes;
  '~routes': Routes;

  public transport: Transport;
  public router: Routes;
  public options: ServerOptions;

  protected presence: Presence;
  protected driver: matchMaker.MatchMakerDriver;

  protected port: number | string;
  protected greet: boolean;

  protected _onTransportReady = new Deferred<Transport>();

  private _originalRoomOnMessage: typeof Room.prototype['_onMessage'] | null = null;

  constructor(options: ServerOptions = {}) {
    const {
      gracefullyShutdown = true,
      greet = true
    } = options;

    setDevMode(options.devMode === true);

    this.options = options;
    this.greet = greet;

    this.attach(options);

    // Pass options.presence/driver through as-is (possibly undefined).
    // matchMaker.setup() falls back to getDefaultPresence/getDefaultDriver,
    // which auto-select RedisPresence/RedisDriver on Colyseus Cloud.
    matchMaker.setup(
      options.presence,
      options.driver,
      options.publicAddress,
      options.selectProcessIdToCreateRoom,
    ).then(() => {
      this.presence = matchMaker.presence;
      this.driver = matchMaker.driver;
    });

    if (gracefullyShutdown) {
      registerGracefulShutdown((err) => this.gracefullyShutdown(true, err));
    }

    if (options.logger) {
      setLogger(options.logger);
    }
  }

  public async attach(options: ServerOptions) {
    this.transport = options.transport || await this.getDefaultTransport(options);

    // Initialize Express if callback is provided
    if (options.express && this.transport.getExpressApp) {
      const expressApp = await this.transport.getExpressApp();
      await options.express(expressApp);
    }

    // Resolve the promise when the transport is ready
    this._onTransportReady.resolve(this.transport);
  }

  /**
   * Bind the server into the port specified.
   *
   * @param port - Port number or Unix socket path
   * @param hostname
   * @param backlog
   * @param listeningListener
   */
  public async listen(port: number | string, hostname?: string, backlog?: number, listeningListener?: Function) {
    if (this.options.beforeListen) {
      await this.options.beforeListen();
    }

    //
    // if Colyseus Cloud is detected, use @colyseus/tools to listen
    //
    if (process.env.COLYSEUS_CLOUD !== undefined ) {
      if (typeof(hostname) === "number") {
        //
        // workaround, @colyseus/tools calls server.listen() again with the port as a string
        //
        hostname = undefined;

      } else {
        try {
          return (await dynamicImport("@colyseus/tools")).listen(this);
        } catch (error) {
          const err = new Error("Please install @colyseus/tools to be able to host on Colyseus Cloud.");
          err.cause = error;
          throw err;
        }
      }
    }

    //
    // otherwise, listen on the port directly
    //
    this.port = port;

    //
    // Make sure matchmaker is ready before accepting connections
    // (isDevMode: matchmaker may take extra milliseconds to restore the rooms)
    //
    await matchMaker.accept(this.options.isStandaloneMatchMaker);

    /**
     * Greetings!
     */
    if (this.greet) {
      greet();
    }

    // Wait for the transport to be ready
    await this._onTransportReady;

    return new Promise<void>((resolve, reject) => {
      this.transport.listen(port, hostname, backlog, (err) => {
        if (this.transport.server) {
          this.transport.server.on('error', (err) => reject(err));
        }

        this.bindRoutes();

        if (listeningListener) {
          listeningListener(err);
        }

        if (err) {
          reject(err);

        } else {
          resolve();
        }
      });
    });
  }

  /**
   * Prepare matchmaking and HTTP routes, then return the underlying
   * `http.Server` *without* binding to a port.
   *
   * Use this on serverless platforms that consume an exported HTTP server
   * instead of a listening one. Vercel, for example, drives the request handler
   * of a default-exported server (its Express/Hono WebSocket examples use
   * `export default server`), whereas calling `listen()` there selects Vercel's
   * "captured server" path, which does not invoke Express-style app handlers.
   *
   * The transport must be created with an `http.Server` so it can be exported.
   * Vercel additionally needs `package.json` `"main"` pointing at the entrypoint:
   *
   * ```ts
   * import { createServer } from "node:http";
   * const httpServer = createServer();
   * const server = new Server({
   *   transport: new WebSocketTransport({ server: httpServer }),
   *   express: (app) => { app.get("/hello", (req, res) => res.json({ ok: true })); },
   * });
   * server.define("my_room", MyRoom);
   * export default await server.serverless(); // no listen()
   * ```
   */
  public async serverless(): Promise<HttpServer> {
    if (this.options.beforeListen) {
      await this.options.beforeListen();
    }

    // transport (and its HTTP server) must be ready before binding routes
    await this._onTransportReady;

    const server = this.transport.server as HttpServer | undefined;
    if (!server) {
      throw new Error("serverless() requires a transport backed by an http.Server (pass `{ server }` to the transport).");
    }

    this.bindRoutes();

    // When the server is consumed via `export default` (rather than listen()),
    // the request body must be available synchronously — buffer it up-front so
    // `req.body` is set before the router runs.
    prereadRequestBodies(server);

    await matchMaker.accept(this.options.isStandaloneMatchMaker);

    if (this.greet) {
      greet();
    }

    return server;
  }

  /**
   * Set the active transport globally and bind the matchmaking/HTTP routes.
   * Shared by {@link listen} and {@link serverless}.
   *
   * `setTransport()` runs here rather than before `transport.listen()`: the
   * global transport is only read by the matchmaking route handler, which can't
   * run until `bindRouterToTransport()` below registers the route.
   */
  private bindRoutes() {
    // set transport globally, to be used by the matchmaking route
    setTransport(this.transport);

    // default router is used if no router is provided
    if (!this.router) {
      this.router = getDefaultRouter() as unknown as Routes;

    } else {
      // make sure default routes are included
      // https://github.com/Bekacru/better-call/pull/67
      this.router = this.router.extend({ ...getDefaultRouter().endpoints }) as unknown as Routes;
    }

    bindRouterToTransport(this.transport, this.router, this.options.express !== undefined);
  }

  /**
   * Define a new type of room for matchmaking.
   *
   * @param name public room identifier for match-making.
   * @param roomClass Room class definition
   * @param defaultOptions default options for `onCreate`
   */
  public define<T extends Type<Room>>(
    roomClass: T,
    defaultOptions?: OnCreateOptions<T>,
  ): RegisteredHandler
  public define<T extends Type<Room>>(
    name: string,
    roomClass: T,
    defaultOptions?: OnCreateOptions<T>,
  ): RegisteredHandler
  public define<T extends Type<Room>>(
    nameOrHandler: string | T,
    handlerOrOptions: T | OnCreateOptions<T>,
    defaultOptions?: OnCreateOptions<T>,
  ): RegisteredHandler {
    const name = (typeof(nameOrHandler) === "string")
      ? nameOrHandler
      : nameOrHandler.name;

    const roomClass = (typeof(nameOrHandler) === "string")
      ? handlerOrOptions
      : nameOrHandler;

    const options = (typeof(nameOrHandler) === "string")
      ? defaultOptions
      : handlerOrOptions;

    return matchMaker.defineRoomType(name, roomClass, options);
  }

  /**
   * Remove a room definition from matchmaking.
   * This method does not destroy any room. It only dissallows matchmaking
   */
  public removeRoomType(name: string): void {
    matchMaker.removeRoomType(name);
  }

  public async gracefullyShutdown(exit: boolean = true, err?: Error) {
    if (matchMaker.state === matchMaker.MatchMakerState.SHUTTING_DOWN) {
      return;
    }

    try {
      // custom "before shutdown" method
      await this.onBeforeShutdownCallback();

      // this is going to lock all rooms and wait for them to be disposed
      await matchMaker.gracefullyShutdown();

      this.transport.shutdown();
      this.presence?.shutdown();
      await this.driver?.shutdown();

      // custom "after shutdown" method
      await this.onShutdownCallback();

    } catch (e) {
      debugAndPrintError(`error during shutdown: ${e}`);

    } finally {
      if (exit) {
        process.exit((err && !isDevMode) ? 1 : 0);
      }
    }
  }

  /**
   * Add simulated latency between client and server.
   * @param milliseconds round trip latency in milliseconds.
   */
  public simulateLatency(milliseconds: number) {
    if (milliseconds > 0) {
      logger.warn(`📶️❗ Colyseus latency simulation enabled → ${milliseconds}ms latency for round trip.`);
    } else {
      logger.warn(`📶️❗ Colyseus latency simulation disabled.`);
    }

    const halfwayMS = (milliseconds / 2);
    this.transport.simulateLatency(halfwayMS);

    if (this._originalRoomOnMessage == null) {
      this._originalRoomOnMessage = Room.prototype['_onMessage'];
    }

    const originalOnMessage = this._originalRoomOnMessage;

    Room.prototype['_onMessage'] = milliseconds <= Number.EPSILON ? originalOnMessage : function (this: Room, client, buffer) {
      // uWebSockets.js: duplicate buffer because it is cleared at native layer before the timeout.
      const cachedBuffer = Buffer.from(buffer);
      setTimeout(() => originalOnMessage.call(this, client, cachedBuffer), halfwayMS);
    };
  }

  /**
   * Register a callback that is going to be executed before the server shuts down.
   * @param callback
   */
  public onShutdown(callback: () => void | Promise<any>) {
    this.onShutdownCallback = callback;
  }

  public onBeforeShutdown(callback: () => void | Promise<any>) {
    this.onBeforeShutdownCallback = callback;
  }

  protected async getDefaultTransport(options: any): Promise<Transport> {
    try {
      const module = await dynamicImport('@colyseus/ws-transport');
      const WebSocketTransport = module.WebSocketTransport;
      return new WebSocketTransport(options);

    } catch (error) {
      this._onTransportReady.reject(error);
      throw new Error("Please provide a 'transport' layer. Default transport not set.");
    }
  }

  protected onShutdownCallback: () => void | Promise<any> =
    () => Promise.resolve()

  protected onBeforeShutdownCallback: () => void | Promise<any> =
    () => Promise.resolve()
}

export type RoomDefinitions = Record<string, RegisteredHandler | Type<Room>>;

function isRegisteredHandler(value: RegisteredHandler | Type<Room>): value is RegisteredHandler {
  return value instanceof RegisteredHandler || (
    typeof(value) === "object" &&
    value !== null &&
    'klass' in (value as object)
  );
}

export function registerRoomDefinitions<T extends RoomDefinitions>(rooms: T): string[] {
  const roomNames: string[] = [];

  for (const [name, value] of Object.entries(rooms)) {
    if (isRegisteredHandler(value)) {
      value.name = name;
      matchMaker.addRoomType(value);

    } else {
      matchMaker.defineRoomType(name, value);
    }

    roomNames.push(name);
  }

  return roomNames;
}

export function unregisterRoomDefinitions(roomNames: Iterable<string>) {
  for (const roomName of roomNames) {
    matchMaker.removeRoomType(roomName);
  }
}

export type DefineServerOptions<
  T extends Record<string, RegisteredHandler>,
  R extends Router
> = ServerOptions & {
  rooms: T,
  routes?: R,
};

export function defineServer<
  T extends Record<string, RegisteredHandler>,
  R extends Router
>(
  options: DefineServerOptions<T, R>,
): Server<T, R> {
  const { rooms, routes, ...serverOptions } = options;

  if (isDevMode) {
    // In dev mode, the Vite plugin manages Server/matchMaker lifecycle.
    // Return a config-only object — no Server instance, no matchMaker.setup().
    return {
      options: serverOptions,
      router: routes,
      '~rooms': rooms,
    } as unknown as Server<T, R>;
  }

  const server = new Server<T, R>(serverOptions);
  server.router = routes;

  registerRoomDefinitions(rooms);

  return server;
}

export function defineRoom<T extends Type<Room>>(
  roomKlass: T,
  defaultOptions?: Parameters<NonNullable<InstanceType<T>['onCreate']>>[0],
): RegisteredHandler<InstanceType<T>> {
  return new RegisteredHandler(roomKlass, defaultOptions) as unknown as RegisteredHandler<InstanceType<T>>;
}
