import { randomUUID } from "node:crypto";
import { GatewayError, type ComputerResponse, type ErrorCode, ERROR_CODES } from "./protocol.js";

// Minimal surface the registry needs from a WS connection. Keeps the registry pure
// and unit-testable with a FakeSocket; the real `.on(...)` wiring lives in the plugin.
export type GatewaySocket = {
  send(data: string): void;
  close(code?: number, reason?: string): void;
};

type LogFn = (obj: object, msg?: string) => void;
export type Logger = { debug: LogFn; info: LogFn; warn: LogFn };

type Pending = {
  resolve: (payload: Record<string, unknown>) => void;
  reject: (err: GatewayError) => void;
  timer: ReturnType<typeof setTimeout>;
  type: string;
  startedAt: number;
};

type Entry = {
  socket: GatewaySocket;
  pending: Map<string, Pending>;
};

const DEFAULT_REQUEST_TIMEOUT_MS = 600_000;

// Application close code (3000-4999 range) used when a newer registration displaces
// an older one. The Lua client treats this as terminal (yield, no reconnect).
export const DISPLACED_CLOSE_CODE = 4000;

function isErrorCode(code: string): code is ErrorCode {
  return (ERROR_CODES as readonly string[]).includes(code);
}

// Registry of live computer connections keyed by (networkId, traposId), newest-wins.
// Each entry owns its own pending map so eviction / close rejects only that socket's
// in-flight requests.
export class GatewayRegistry {
  private readonly entries = new Map<string, Entry>();

  constructor(
    private readonly log: Logger,
    private readonly defaultTimeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS,
  ) {}

  private key(networkId: string, traposId: string): string {
    return `${networkId}\u0000${traposId}`;
  }

  register(networkId: string, traposId: string, socket: GatewaySocket): void {
    const k = this.key(networkId, traposId);
    const existing = this.entries.get(k);
    if (existing) {
      this.log.info(
        { networkId, traposId, reason: "duplicate_hello" },
        "evicting previous connection (newest-wins)",
      );
      this.rejectAll(existing, new GatewayError("not_connected", "connection displaced by newer registration"));
      try {
        // Distinct close code so the displaced client can tell a displacement apart
        // from a normal drop and yield instead of reconnecting into an eviction loop.
        existing.socket.close(DISPLACED_CLOSE_CODE, "displaced");
      } catch {
        // already closed / errored - nothing to do
      }
    }
    this.entries.set(k, { socket, pending: new Map() });
  }

  // Identity-checked: a late close from an evicted socket must not remove the socket
  // that replaced it.
  unregister(networkId: string, traposId: string, socket: GatewaySocket): void {
    const k = this.key(networkId, traposId);
    const entry = this.entries.get(k);
    if (!entry || entry.socket !== socket) {
      return;
    }
    this.entries.delete(k);
    this.rejectAll(entry, new GatewayError("not_connected", "connection closed"));
  }

  has(networkId: string, traposId: string): boolean {
    return this.entries.has(this.key(networkId, traposId));
  }

  count(): number {
    return this.entries.size;
  }

  // Registered traposIds for a network (newest-wins, so at most one per traposId).
  // Used by the MCP `probe-computers` tool to enumerate addressable computers.
  list(networkId: string): string[] {
    const prefix = this.key(networkId, "");
    const out: string[] = [];
    for (const key of this.entries.keys()) {
      if (key.startsWith(prefix)) {
        out.push(key.slice(prefix.length));
      }
    }
    return out;
  }

  // Gateway -> computer request/reply. Rejects with a coded GatewayError on
  // not_connected / timeout / response ok:false; resolves with the response payload.
  // The deadline is the caller's concern; defaultTimeoutMs is only a safety ceiling.
  request(
    networkId: string,
    traposId: string,
    type: string,
    payload: Record<string, unknown> = {},
    timeoutMs: number = this.defaultTimeoutMs,
  ): Promise<Record<string, unknown>> {
    const entry = this.entries.get(this.key(networkId, traposId));
    if (!entry) {
      return Promise.reject(
        new GatewayError("not_connected", `no computer registered for ${traposId}`),
      );
    }

    const messageId = randomUUID();
    const frame = {
      event: "server_request",
      traposReceiverId: traposId,
      messageId,
      type,
      payload,
    };

    return new Promise<Record<string, unknown>>((resolve, reject) => {
      const timer = setTimeout(() => {
        entry.pending.delete(messageId);
        this.log.warn(
          { networkId, traposId, messageId, type, timeoutMs, durationMs: Date.now() - startedAt, pendingCount: entry.pending.size },
          "gateway request timed out",
        );
        reject(new GatewayError("timeout", `request "${type}" timed out after ${timeoutMs}ms`));
      }, timeoutMs);

      const startedAt = Date.now();
      entry.pending.set(messageId, { resolve, reject, timer, type, startedAt });

      try {
        entry.socket.send(JSON.stringify(frame));
        this.log.debug(
          { networkId, traposId, messageId, type, timeoutMs, pendingCount: entry.pending.size },
          "gateway request sent",
        );
      } catch (err) {
        clearTimeout(timer);
        entry.pending.delete(messageId);
        this.log.warn(
          { networkId, traposId, messageId, type, pendingCount: entry.pending.size, err },
          "gateway request send failed",
        );
        reject(new GatewayError("internal", err instanceof Error ? err.message : String(err)));
      }
    });
  }

  // Resolve a pending server_request from a matching computer_response. Silently
  // dropped (with a debug log) when there is no matching pending.
  resolveResponse(socket: GatewaySocket, networkId: string, traposId: string, frame: ComputerResponse): void {
    const entry = this.entries.get(this.key(networkId, traposId));
    if (!entry || entry.socket !== socket) {
      this.log.debug({ messageId: frame.messageId, reason: "no_pending" }, "no entry for computer_response");
      return;
    }
    const pending = entry.pending.get(frame.messageId);
    if (!pending) {
      this.log.debug({ messageId: frame.messageId, reason: "no_pending" }, "unmatched computer_response");
      return;
    }
    entry.pending.delete(frame.messageId);
    clearTimeout(pending.timer);
    this.log.debug(
      {
        networkId,
        traposId,
        messageId: frame.messageId,
        type: pending.type,
        ok: frame.ok,
        durationMs: Date.now() - pending.startedAt,
        pendingCount: entry.pending.size,
      },
      "gateway response received",
    );

    if (frame.ok) {
      pending.resolve(frame.payload);
      return;
    }
    const code = frame.error && isErrorCode(frame.error.code) ? frame.error.code : "internal";
    pending.reject(new GatewayError(code, frame.error?.message));
  }

  private rejectAll(entry: Entry, err: GatewayError): void {
    for (const pending of entry.pending.values()) {
      clearTimeout(pending.timer);
      pending.reject(err);
    }
    entry.pending.clear();
  }
}
