/**
 * RemoteServer - Stream Hypen apps over WebSocket with Session Management
 *
 * Usage with inline UI:
 * ```typescript
 * import { RemoteServer, app } from "@hypen-space/core";
 *
 * const counter = app
 *   .defineState({ count: 0 })
 *   .onAction("increment", ({ state }) => state.count++)
 *   .build();
 *
 * new RemoteServer()
 *   .module("Counter", counter)
 *   .ui(`Column { Text("Count: \${state.count}") }`)
 *   .listen(3000);
 * ```
 *
 * Usage with directory-based discovery (auto-resolves imports):
 * ```typescript
 * import { RemoteServer } from "@hypen-space/core";
 * import chatModule from "./apps/chat/Chat/component.js";
 *
 * await new RemoteServer()
 *   .source("./apps/chat")
 *   .module("Chat", chatModule)
 *   .listen(3001);
 * ```
 */

import { Engine } from "../engine.js";
import { HypenModuleInstance } from "../app.js";
import type { HypenModule, HypenModuleDefinition } from "../app.js";
import type {
  RemoteMessage,
  RemoteClient,
  RemoteServerConfig,
  InitialTreeMessage,
  PatchMessage,
  StateUpdateMessage,
  UpdateStateMessage,
  DispatchActionMessage,
  HelloMessage,
  SessionAckMessage,
  SessionExpiredMessage,
  Session,
  SessionConfig,
} from "./types.js";
import type { Patch, ResolvedComponent } from "../types.js";
import type { ServerWebSocket } from "bun";
import { SessionManager } from "./session.js";
import { frameworkLoggers } from "../logger.js";
import {
  discoverComponents,
  loadDiscoveredComponents,
  type DiscoveredComponent,
} from "../discovery.js";

const log = frameworkLoggers.remote;

interface ClientData {
  id: string;
  engine: Engine;
  moduleInstance: HypenModuleInstance<any>;
  revision: number;
  connectedAt: Date;
  /** Session ID for this client */
  sessionId: string;
  /** Whether we've received the hello message */
  helloReceived: boolean;
  /** Timeout for legacy clients that don't send hello */
  helloTimeout?: ReturnType<typeof setTimeout>;
  /** Whether this client subscribes to state updates after each render */
  stateSubscribed: boolean;
}

/**
 * Builder pattern for hosting Hypen apps over WebSocket
 */
export class RemoteServer {
  private _module: HypenModule<any> | null = null;
  private _moduleName: string = "App";
  private _ui: string = "";
  private _sourceDir: string | null = null;
  private _discoveredComponents: Map<string, { template: string; module?: HypenModuleDefinition<any> }> = new Map();
  private _config: RemoteServerConfig = {};
  private _sessionConfig: SessionConfig = {};
  private _onConnectionCallbacks: Array<(client: RemoteClient) => void> = [];
  private _onDisconnectionCallbacks: Array<(client: RemoteClient) => void> = [];
  private clients = new Map<ServerWebSocket<unknown>, ClientData>();
  private nextClientId = 1;
  private server: ReturnType<typeof Bun.serve> | null = null;
  private sessionManager: SessionManager | null = null;
  private _syncActions: boolean = false;

  /**
   * Set the module for this app
   */
  module(name: string, module: HypenModule<any>): this {
    this._moduleName = name;
    this._module = module;
    return this;
  }

  /**
   * Set the UI DSL string
   */
  ui(dsl: string): this {
    this._ui = dsl;
    return this;
  }

  /**
   * Set a source directory for automatic component discovery.
   *
   * All .hypen and .ts components in the directory (and subdirectories) are
   * discovered automatically. The entry component's template is used as the
   * UI, so calling .ui() is optional when .source() is set.
   *
   * @example
   * ```typescript
   * new RemoteServer()
   *   .source("./apps/chat")
   *   .module("Chat", chatModule)
   *   .listen(3001);
   * ```
   */
  source(dir: string): this {
    this._sourceDir = dir;
    return this;
  }

  /**
   * Set server configuration
   */
  config(config: RemoteServerConfig): this {
    this._config = { ...this._config, ...config };
    return this;
  }

  /**
   * Configure session management
   */
  session(config: SessionConfig): this {
    this._sessionConfig = config;
    return this;
  }

  /**
   * Enable action synchronization across all connected clients.
   * When enabled, an action dispatched by any client is also dispatched
   * to every other client's engine, keeping all clients in sync.
   */
  syncActions(): this {
    this._syncActions = true;
    return this;
  }

  /**
   * Register connection callback
   */
  onConnection(callback: (client: RemoteClient) => void): this {
    this._onConnectionCallbacks.push(callback);
    return this;
  }

  /**
   * Register disconnection callback
   */
  onDisconnection(callback: (client: RemoteClient) => void): this {
    this._onDisconnectionCallbacks.push(callback);
    return this;
  }

  /**
   * Start the WebSocket server
   */
  async listen(port?: number): Promise<this> {
    if (!this._module) {
      throw new Error("Module not set. Call .module() before .listen()");
    }

    // Run component discovery if source directory is set
    if (this._sourceDir) {
      await this.discoverFromSource();
    }

    if (!this._ui) {
      throw new Error(
        "UI not set. Call .ui() or .source() before .listen()"
      );
    }

    // Initialize session manager
    this.sessionManager = new SessionManager(this._sessionConfig);

    const finalPort = port ?? this._config.port ?? 3000;
    const hostname = this._config.hostname ?? "0.0.0.0";

    this.server = Bun.serve({
      port: finalPort,
      hostname,
      websocket: {
        open: (ws) => this.handleOpen(ws),
        message: (ws, message) => this.handleMessage(ws, message),
        close: (ws) => this.handleClose(ws),
      },
      fetch: (req, server) => {
        const url = new URL(req.url);

        // Upgrade to WebSocket
        if (server.upgrade(req, { data: undefined })) {
          return; // Connection upgraded
        }

        // Health check endpoint
        if (url.pathname === "/health") {
          return new Response("OK", { status: 200 });
        }

        // Stats endpoint
        if (url.pathname === "/stats") {
          const stats = this.sessionManager?.getStats() ?? {
            activeSessions: 0,
            pendingSessions: 0,
            totalConnections: 0,
          };
          return new Response(JSON.stringify(stats), {
            headers: { "Content-Type": "application/json" },
          });
        }

        return new Response("Hypen Remote Server", { status: 200 });
      },
    });

    log.info(`Hypen app streaming on ws://${hostname}:${finalPort}`);

    return this;
  }

  /**
   * Stop the server
   */
  stop(): void {
    if (this.server) {
      this.server.stop();
      this.server = null;
    }
    if (this.sessionManager) {
      this.sessionManager.destroy();
      this.sessionManager = null;
    }
  }

  /**
   * Get the server URL
   */
  get url(): string | null {
    if (!this.server) return null;
    const hostname = this._config.hostname ?? "localhost";
    const port = this._config.port ?? 3000;
    return `ws://${hostname}:${port}`;
  }

  /**
   * Discover components from the source directory.
   * Populates _discoveredComponents and sets _ui from the entry component
   * if not already set via .ui().
   */
  private async discoverFromSource(): Promise<void> {
    const dir = this._sourceDir!;
    log.info(`Discovering components from ${dir}...`);

    const discovered = await discoverComponents(dir);
    const loaded = await loadDiscoveredComponents(discovered);

    for (const [name, { module, template }] of loaded) {
      this._discoveredComponents.set(name, { template, module });
    }

    log.info(
      `Discovered ${this._discoveredComponents.size} components: ${Array.from(this._discoveredComponents.keys()).join(", ")}`
    );

    // If .ui() was not called, use the entry component's template
    if (!this._ui) {
      const entry = this._discoveredComponents.get(this._moduleName);
      if (entry) {
        this._ui = entry.template;
      }
    }
  }

  /**
   * Set up a component resolver on a client engine so that imports
   * and component references in templates are resolved from discovered
   * components.
   */
  private setupComponentResolver(engine: Engine): void {
    if (this._discoveredComponents.size === 0) return;

    engine.setComponentResolver(
      (componentName: string, _contextPath: string | null) => {
        const comp = this._discoveredComponents.get(componentName);
        if (!comp) {
          log.warn(
            `Component "${componentName}" not found. Available: ${Array.from(this._discoveredComponents.keys()).join(", ") || "(none)"}`
          );
          return null;
        }

        return {
          source: comp.template,
          path: componentName,
        };
      }
    );
  }

  /**
   * Handle new WebSocket connection
   * Waits for hello message before fully initializing
   */
  private handleOpen(ws: ServerWebSocket<unknown>) {
    try {
      const clientId = `client_${this.nextClientId++}`;
      const connectedAt = new Date();

      // Create engine instance for this client
      const engine = new Engine();
      // init() body is synchronous for wasm-node (just new WasmEngine())
      // but declared async, so call it and catch the rejected promise if it fails
      engine.init().catch((err) => log.error("Engine init failed:", err));

      // Wire up component resolver if we have discovered components
      this.setupComponentResolver(engine);

      // Create module instance
      const moduleInstance = new HypenModuleInstance(engine, this._module!);

      log.info(`Client ${clientId} connected, engine initialized`);

      // Store client data SYNCHRONOUSLY so handleMessage can find it
      const clientData: ClientData = {
        id: clientId,
        engine,
        moduleInstance,
        revision: 0,
        connectedAt,
        sessionId: "",
        helloReceived: false,
        stateSubscribed: false,
      };

      this.clients.set(ws, clientData);

      // Set timeout for legacy clients that don't send hello
      clientData.helloTimeout = setTimeout(() => {
        if (!clientData.helloReceived) {
          // Legacy client - create new session automatically
          this.initializeSession(ws, clientData, undefined, undefined).catch(
            (err) => log.error("Error initializing legacy session:", err)
          );
        }
      }, 1000); // 1 second grace period

    } catch (error) {
      log.error("Error handling WebSocket open:", error);
      ws.close(1011, "Internal server error");
    }
  }

  /**
   * Initialize session for a client (new or resumed)
   */
  private async initializeSession(
    ws: ServerWebSocket<unknown>,
    clientData: ClientData,
    requestedSessionId: string | undefined,
    props: Record<string, any> | undefined
  ): Promise<void> {
    if (clientData.helloReceived) return;
    clientData.helloReceived = true;
    log.info(`Initializing session for ${clientData.id} (sessionId: ${requestedSessionId ?? "new"})`);

    // Clear hello timeout
    if (clientData.helloTimeout) {
      clearTimeout(clientData.helloTimeout);
      clientData.helloTimeout = undefined;
    }

    let session: Session;
    let isNew = true;
    let isRestored = false;
    let restoredState: unknown = null;

    if (requestedSessionId && this.sessionManager) {
      // Try to resume pending session
      const resumed = this.sessionManager.resumeSession(requestedSessionId);
      if (resumed) {
        session = resumed.session;
        restoredState = resumed.savedState;
        isNew = false;
        isRestored = true;

        // Call onReconnect hook
        await this.triggerReconnect(clientData, session, restoredState);
      } else {
        // Check for concurrent active session
        const activeSession = this.sessionManager.getActiveSession(requestedSessionId);
        if (activeSession) {
          const handled = await this.handleConcurrentConnection(
            ws,
            clientData,
            activeSession,
            props
          );
          if (!handled) return; // Connection was rejected
          session = activeSession;
          isNew = false;
        } else {
          // Session not found, create new
          session = this.sessionManager.createSession(props);
        }
      }
    } else if (this.sessionManager) {
      // New session
      session = this.sessionManager.createSession(props);
    } else {
      // No session manager (shouldn't happen, but fallback)
      session = {
        id: crypto.randomUUID(),
        ttl: 3600,
        createdAt: new Date(),
        lastConnectedAt: new Date(),
        props,
      };
    }

    clientData.sessionId = session.id;
    this.sessionManager?.trackConnection(session.id, ws);

    // Send session acknowledgment
    const sessionAck: SessionAckMessage = {
      type: "sessionAck",
      sessionId: session.id,
      isNew,
      isRestored,
    };
    ws.send(JSON.stringify(sessionAck));
    log.info(`Sent sessionAck to ${clientData.id} (session: ${session.id})`);

    // Render initial tree — capture patches synchronously
    const initialPatches: Patch[] = [];
    clientData.engine.setRenderCallback((patches) => {
      initialPatches.push(...patches);
    });

    try {
      clientData.engine.renderSource(this._ui);
    } catch (renderError) {
      log.error(`Failed to render UI for ${clientData.id}:`, renderError);
      ws.close(1011, "Render failed");
      return;
    }

    // Now set up streaming render callback for subsequent updates
    this.setupRenderCallback(ws, clientData);

    // Send initial tree
    const initialMessage: InitialTreeMessage = {
      type: "initialTree",
      module: this._moduleName,
      state: clientData.moduleInstance.getState(),
      patches: initialPatches,
      revision: 0,
    };

    try {
      ws.send(JSON.stringify(initialMessage));
    } catch (sendError) {
      log.error(`Failed to send initialTree to ${clientData.id}:`, sendError);
      return;
    }
    log.info(`Sent initialTree to ${clientData.id} (${initialPatches.length} patches)`);

    // Notify connection callbacks
    const client: RemoteClient = {
      id: clientData.id,
      socket: ws,
      connectedAt: clientData.connectedAt,
    };
    this._onConnectionCallbacks.forEach((cb) => cb(client));
  }

  /**
   * Set up the render callback for streaming patches
   */
  private setupRenderCallback(
    ws: ServerWebSocket<unknown>,
    clientData: ClientData
  ): void {
    clientData.engine.setRenderCallback((patches) => {
      const data = this.clients.get(ws);
      if (!data) return;

      data.revision++;

      const patchMessage: PatchMessage = {
        type: "patch",
        module: this._moduleName,
        patches,
        revision: data.revision,
      };

      ws.send(JSON.stringify(patchMessage));

      // Only send state snapshot to clients that opted in (e.g. Studio)
      if (data.stateSubscribed) {
        const stateMessage: StateUpdateMessage = {
          type: "stateUpdate",
          module: this._moduleName,
          state: data.moduleInstance.getState(),
          revision: data.revision,
        };
        ws.send(JSON.stringify(stateMessage));
      }

      // If allow-multiple, broadcast to other connections on same session
      if (
        this.sessionManager?.getConcurrentPolicy() === "allow-multiple" &&
        data.sessionId
      ) {
        const connections = this.sessionManager.getConnections(data.sessionId);
        if (connections) {
          for (const conn of connections) {
            if (conn !== ws) {
              const otherWs = conn as ServerWebSocket<unknown>;
              const otherData = this.clients.get(otherWs);
              otherWs.send(JSON.stringify(patchMessage));
              if (otherData?.stateSubscribed) {
                const stateMessage: StateUpdateMessage = {
                  type: "stateUpdate",
                  module: this._moduleName,
                  state: data.moduleInstance.getState(),
                  revision: data.revision,
                };
                otherWs.send(JSON.stringify(stateMessage));
              }
            }
          }
        }
      }
    });
  }

  /**
   * Handle concurrent connection based on policy
   * Returns true if connection is allowed, false if rejected
   */
  private async handleConcurrentConnection(
    ws: ServerWebSocket<unknown>,
    clientData: ClientData,
    existingSession: Session,
    props: Record<string, any> | undefined
  ): Promise<boolean> {
    const policy = this.sessionManager?.getConcurrentPolicy() ?? "kick-old";

    switch (policy) {
      case "kick-old": {
        // Kick existing connections
        const existingConnections = this.sessionManager?.getConnections(
          existingSession.id
        );
        if (existingConnections) {
          for (const conn of existingConnections) {
            const oldWs = conn as ServerWebSocket<unknown>;
            const expiredMsg: SessionExpiredMessage = {
              type: "sessionExpired",
              sessionId: existingSession.id,
              reason: "kicked",
            };
            oldWs.send(JSON.stringify(expiredMsg));
            oldWs.close(1000, "Session taken by new connection");
          }
        }
        return true;
      }

      case "reject-new": {
        // Reject this connection
        const expiredMsg: SessionExpiredMessage = {
          type: "sessionExpired",
          sessionId: existingSession.id,
          reason: "kicked",
        };
        ws.send(JSON.stringify(expiredMsg));
        ws.close(1000, "Session already active");
        return false;
      }

      case "allow-multiple": {
        // Allow both connections
        return true;
      }

      default:
        return true;
    }
  }

  /**
   * Trigger onReconnect hook
   */
  private async triggerReconnect(
    clientData: ClientData,
    session: Session,
    savedState: unknown
  ): Promise<void> {
    const handler = this._module?.handlers.onReconnect;
    if (!handler) return;

    let restored = false;
    const restore = (state: unknown) => {
      if (state === null || typeof state !== 'object') {
        log.warn("restore() called with non-object state, ignoring:", typeof state);
        return;
      }
      restored = true;
      clientData.moduleInstance.updateState(state as Record<string, unknown>);
    };

    await handler({ session, restore });
  }

  /**
   * Handle incoming WebSocket message
   */
  private handleMessage(
    ws: ServerWebSocket<unknown>,
    message: string | Buffer
  ) {
    try {
      const clientData = this.clients.get(ws);
      if (!clientData) return;

      const msg = JSON.parse(message.toString()) as RemoteMessage;

      switch (msg.type) {
        case "hello": {
          const helloMsg = msg as HelloMessage;
          this.initializeSession(
            ws,
            clientData,
            helloMsg.sessionId,
            helloMsg.props
          ).catch((err) => log.error("Error initializing session from hello:", err));
          break;
        }

        case "dispatchAction": {
          const actionMsg = msg as DispatchActionMessage;
          clientData.engine.dispatchAction(actionMsg.action, actionMsg.payload);

          // Sync mode: dispatch to all other clients' engines
          if (this._syncActions) {
            for (const [otherWs, otherClient] of this.clients) {
              if (otherWs === ws || !otherClient.helloReceived) continue;
              otherClient.engine.dispatchAction(actionMsg.action, actionMsg.payload);
            }
          }
          break;
        }

        case "updateState": {
          const stateMsg = msg as UpdateStateMessage;
          clientData.moduleInstance.updateState(stateMsg.state);

          // Sync mode: update state on all other clients' engines
          if (this._syncActions) {
            for (const [otherWs, otherClient] of this.clients) {
              if (otherWs === ws || !otherClient.helloReceived) continue;
              otherClient.moduleInstance.updateState(stateMsg.state);
            }
          }
          break;
        }

        case "subscribeState": {
          clientData.stateSubscribed = true;
          log.info(`Client ${clientData.id} subscribed to state updates`);
          break;
        }

        default:
          // Unknown message type
          break;
      }
    } catch (error) {
      log.error("Error handling WebSocket message:", error);
    }
  }

  /**
   * Handle WebSocket close - suspend session instead of destroying
   */
  private async handleClose(ws: ServerWebSocket<unknown>) {
    const clientData = this.clients.get(ws);
    if (!clientData) return;

    // Clear hello timeout if still pending
    if (clientData.helloTimeout) {
      clearTimeout(clientData.helloTimeout);
    }

    // Get current state for session save
    const currentState = clientData.moduleInstance.getState();

    // Trigger onDisconnect hook
    if (clientData.sessionId && this._module?.handlers.onDisconnect) {
      const session = this.sessionManager?.getActiveSession(clientData.sessionId);
      if (session) {
        await this._module.handlers.onDisconnect({
          state: currentState,
          session,
        });
      }
    }

    // Suspend session (don't destroy immediately)
    if (clientData.sessionId && this.sessionManager) {
      this.sessionManager.untrackConnection(clientData.sessionId, ws);

      // Only suspend if no other connections for this session
      if (this.sessionManager.getConnectionCount(clientData.sessionId) === 0) {
        const session = this.sessionManager.getActiveSession(clientData.sessionId);
        if (session) {
          this.sessionManager.suspendSession(
            clientData.sessionId,
            currentState,
            async (expiredSession) => {
              // Trigger onExpire hook
              if (this._module?.handlers.onExpire) {
                await this._module.handlers.onExpire({ session: expiredSession });
              }
            }
          );
        }
      }
    }

    // Cleanup module instance
    await clientData.moduleInstance.destroy();
    this.clients.delete(ws);

    // Notify disconnection callbacks
    const client: RemoteClient = {
      id: clientData.id,
      socket: ws,
      connectedAt: clientData.connectedAt,
    };
    this._onDisconnectionCallbacks.forEach((cb) => cb(client));
  }

  /**
   * Reload components from the source directory and re-render all connected clients.
   * Call this when source files change to implement hot reload.
   *
   * The engine's reconciler diffs the old tree against the new one, producing
   * minimal patches (Create/Remove/SetProp) that are streamed to clients.
   */
  async reload(): Promise<void> {
    if (this._sourceDir) {
      // Reset _ui so discoverFromSource picks up the latest entry template
      this._ui = "";
      await this.discoverFromSource();
    }

    if (!this._ui) {
      log.warn("Reload skipped: no UI template available");
      return;
    }

    // Re-render all connected clients — reconciler diffs old vs new tree
    for (const [ws, clientData] of this.clients) {
      if (!clientData.helloReceived) continue;

      // Update component resolver with newly discovered components
      this.setupComponentResolver(clientData.engine);

      try {
        clientData.engine.renderSource(this._ui);
        log.info(`Hot-reloaded client ${clientData.id}`);
      } catch (e) {
        log.error(`Failed to hot-reload client ${clientData.id}:`, e);
      }
    }
  }

  /**
   * Get current client count
   */
  getClientCount(): number {
    return this.clients.size;
  }

  /**
   * Get session stats
   */
  getSessionStats(): {
    activeSessions: number;
    pendingSessions: number;
    totalConnections: number;
  } {
    return this.sessionManager?.getStats() ?? {
      activeSessions: 0,
      pendingSessions: 0,
      totalConnections: 0,
    };
  }

  /**
   * Broadcast a message to all connected clients
   */
  broadcast(message: RemoteMessage): void {
    const json = JSON.stringify(message);
    this.clients.forEach((_, ws) => {
      ws.send(json);
    });
  }
}

/**
 * Convenience function to create and start a RemoteServer
 */
export async function serve(options: {
  module: HypenModule<any>;
  moduleName?: string;
  ui?: string;
  source?: string;
  port?: number;
  hostname?: string;
  session?: SessionConfig;
  onConnection?: (client: RemoteClient) => void;
  onDisconnection?: (client: RemoteClient) => void;
}): Promise<RemoteServer> {
  const server = new RemoteServer()
    .module(options.moduleName ?? "App", options.module);

  if (options.source) {
    server.source(options.source);
  }

  if (options.ui) {
    server.ui(options.ui);
  }

  if (options.port || options.hostname) {
    server.config({
      port: options.port,
      hostname: options.hostname,
    });
  }

  if (options.session) {
    server.session(options.session);
  }

  if (options.onConnection) {
    server.onConnection(options.onConnection);
  }

  if (options.onDisconnection) {
    server.onDisconnection(options.onDisconnection);
  }

  return server.listen(options.port);
}
