import { io, type Socket } from "socket.io-client";

import { api } from "@/client";

import { ChannelHttpFallback } from "./fallback";
import { channelSocketStore } from "./state";
import { DedupManager } from "./dedup";
import { ReconnectManager } from "./reconnect";
import { RoomSubscriptionManager } from "./subscription";
import type {
  ChannelEventHandler,
  ChannelMessage,
  ChannelOutEvent,
  ChannelRequestOptions,
  ChannelSocketOptions,
  ChannelTransport,
  WsTokenRequest,
  WsTokenResponse,
} from "./types";

type PendingRequest<T = unknown> = {
  id: string;
  message: ChannelMessage;
  resolve: (value: T) => void;
  reject: (reason?: unknown) => void;
  timeout: ReturnType<typeof setTimeout>;
  fallback: boolean;
};

const defaultOptions = {
  namespace: "/channel",
  path: "/api/v1/ws/socket.io",
  restBasePath: "/api/v1/ws",
  eventName: "channel.message",
  windowSize: 16,
  timeoutMs: 30_000,
  fallback: true,
  autoConnect: true,
} satisfies Required<Omit<ChannelSocketOptions, "url">>;

export class ChannelSocketClient {
  private readonly options: Required<ChannelSocketOptions>;
  private readonly fallback: ChannelHttpFallback;
  private readonly handlers = new Set<ChannelEventHandler>();
  private readonly queue: PendingRequest[] = [];
  private readonly inFlight = new Map<string, PendingRequest>();
  private socket: Socket | null = null;
  private accessToken: string | null = null;
  private isConnecting = false;
  private readonly deviceId: string;
  /** Offline message queue — buffers user-action messages during disconnect. */
  private readonly offlineQueue: ChannelMessage[] = [];
  private readonly OFFLINE_QUEUE_MAX = 100;
  /** Dedup prevents processing the same event twice. */
  private readonly dedup = new DedupManager();
  /** Exponential-backoff reconnect manager. */
  private readonly reconnect = new ReconnectManager();
  /** Room subscription tracker — auto-resubscribes on reconnect. */
  private readonly subscriptions = new RoomSubscriptionManager();

  constructor(options: ChannelSocketOptions = {}) {
    this.options = { url: "", ...defaultOptions, ...options };
    this.fallback = new ChannelHttpFallback(undefined, this.options.restBasePath);
    this.deviceId = crypto.randomUUID();
    this.reconnect.setCallback(() => this.initConnection());
    this.subscriptions.setCallbacks(
      () => {}, // subscribe — no-op, handled by ChannelSocketClient.subscribe
      () => {}, // unsubscribe — no-op
    );
    // Defer autoConnect to first usage — ChannelSocketProvider calls connect()
    // when the user is authenticated. Module-level instantiation should not
    // connect before auth state is ready.
  }

  connect(): void {
    if (this.isConnecting) {
      return;
    }
    if (this.socket?.connected || this.socket?.active) {
      return;
    }

    channelSocketStore.setState({
      status: "connecting",
      connected: false,
      lastError: null,
    });

    this.isConnecting = true;
    this.initConnection().finally(() => {
      this.isConnecting = false;
    });
  }

  /** Fetch token, create socket — for both initial connect and reconnect. */
  private async initConnection(): Promise<void> {
    channelSocketStore.setState({
      status: this.socket ? 'reconnecting' : 'connecting',
      connected: false,
      transport: 'unknown',
    });

    // Fetch WS access token before connecting.
    try {
      await this.fetchAccessToken();
    } catch {
      // Token unavailable, continue with session cookie only.
    }

    // Clean up old socket before creating new one — prevents WebSocket leak.
    if (this.socket) {
      this.socket.off();
      this.socket.disconnect();
    }

    const authData = this.accessToken
      ? { access_token: this.accessToken }
      : undefined;

    const endpoint = `${this.options.url}${this.options.namespace}`;
    this.socket = io(endpoint, {
      path: this.options.path,
      transports: ["websocket", "polling"],
      withCredentials: true,
      autoConnect: true,
      reconnection: false,
      auth: authData,
    });

    this.bindSocketEvents();
  }

  private async fetchAccessToken(): Promise<string> {
    const body: WsTokenRequest = {
      device_id: this.deviceId,
      client_id: "web",
    };
    const res = await api.post<WsTokenResponse>(
      `${this.options.restBasePath}/token`,
      body,
    );
    this.accessToken = res.data.access_token;
      Date.now() + res.data.expires_in_secs * 1000;
    return this.accessToken;
  }

  private bindSocketEvents(): void {
    if (!this.socket) {
      return;
    }

    this.socket.on("connect", () => {
      this.isConnecting = false;
      this.reconnect.succeed();
      channelSocketStore.setState({
        status: "connected",
        connected: true,
        transport: this.currentTransport(),
        lastError: null,
      });
      this.flushOfflineQueue();
      // Re-subscribe all tracked rooms on reconnect
      for (const roomId of this.subscriptions.getSubscribedRooms()) {
        this.send({ type: "subscribe", room: roomId });
      }
      this.drain();
    });
    this.socket.io.on("reconnect_attempt", () => {
      // Socket.IO internal reconnect is disabled; ReconnectManager handles it.
      // Update state only — no fail() call here.
      channelSocketStore.setState({
        status: "reconnecting",
        connected: false,
        transport: "unknown",
      });
    });
    this.socket.on("disconnect", (reason) => {
      channelSocketStore.setState({
        status: "disconnected",
        connected: false,
        transport: "unknown",
        lastError: reason,
      });
      this.rejectAllPending(
        new Error(`Socket disconnected: ${reason}`),
      );
      this.flushToHttp();
      // Buffer user-action messages for reconnect
      this.enqueueForReconnect();
      // Trigger ReconnectManager to schedule reconnection
      if (this.options.autoConnect) {
        this.reconnect.fail();
      }
    });
    this.socket.on("connect_error", (error) => {
      channelSocketStore.setState({
        status: "error",
        connected: false,
        transport: "unknown",
        lastError: error.message,
      });
      this.rejectAllPending(
        new Error(`Socket connect error: ${error.message}`),
      );
      this.flushToHttp();
      // Trigger ReconnectManager to schedule reconnection
      if (this.options.autoConnect) {
        this.reconnect.fail();
      }
    });
    this.socket.on(this.options.eventName, (raw: unknown) => {
      this.handleEvent(raw);
    });
    // Backend broadcasts room-level events (typing, reactions, new messages,
    // member join/leave, etc.) on "room.message".  We convert them into the
    // same WsOutEvent-compatible shapes that handleEvent expects.
    this.socket.on("room.message", (raw: unknown) => {
      this.handleRoomEvent(raw);
    });
  }

  private rejectAllPending(reason: Error): void {
    for (const [id, pending] of this.inFlight) {
      clearTimeout(pending.timeout);
      pending.reject(reason);
      this.inFlight.delete(id);
    }
    for (const pending of this.queue) {
      clearTimeout(pending.timeout);
      pending.reject(reason);
    }
    this.queue.length = 0;
    this.syncWindowState();
  }

  disconnect(): void {
    this.reconnect.stop();
    this.socket?.disconnect();
    this.socket?.removeAllListeners();
    this.socket = null;
    this.isConnecting = false;
    this.accessToken = null;
    this.offlineQueue.length = 0;
    this.rejectAllPending(new Error("Channel socket disconnected"));
    channelSocketStore.setState({
      status: "disconnected",
      connected: false,
      transport: "unknown",
    });
  }

  on(handler: ChannelEventHandler): () => void {
    this.handlers.add(handler);
    return () => {
      this.handlers.delete(handler);
    };
  }

  request<T = unknown>(
    message: ChannelMessage,
    options: ChannelRequestOptions = {},
  ): Promise<T> {
    if (!this.isConnected() && (options.fallback ?? this.options.fallback)) {
      channelSocketStore.setState({ transport: "http" });
      return this.fallback.request<T>(message);
    }

    if (!this.socket) {
      this.connect();
    }

    return new Promise<T>((resolve, reject) => {
      const id = crypto.randomUUID();
      const timeoutMs = options.timeoutMs ?? this.options.timeoutMs;
      const pending: PendingRequest<T> = {
        id,
        message: { ...message, _request_id: id },
        resolve,
        reject,
        fallback: options.fallback ?? this.options.fallback,
        timeout: setTimeout(() => {
          this.inFlight.delete(id);
          this.removeQueued(id);
          this.syncWindowState();
          reject(new Error(`Channel request timed out: ${message.type}`));
          this.drain();
        }, timeoutMs),
      };
      this.queue.push(pending as PendingRequest);
      this.syncWindowState();
      this.drain();
    });
  }

  send(message: ChannelMessage, options?: ChannelRequestOptions): Promise<void> {
    // When disconnected, buffer user-action messages for re-delivery on reconnect.
    // Do NOT also call request() — that would send via HTTP fallback and cause duplicates.
    if (!this.isConnected()) {
      if (this.isBuffereable(message.type)) {
        this.offlineQueue.push(message);
        if (this.offlineQueue.length > this.OFFLINE_QUEUE_MAX) {
          this.offlineQueue.shift();
        }
      }
      // For buffered messages, resolve immediately — they'll be sent on reconnect.
      // For non-buffered, still try HTTP fallback.
      if (this.isBuffereable(message.type)) {
        if (!(options?.fallback ?? this.options.fallback)) {
          return Promise.resolve();
        }
      }
    }
    return this.request(message, options).then(() => undefined);
  }

  private isBuffereable(type: string): boolean {
    const bufs = new Set([
      "message_create", "message_update", "message_revoke",
      "reaction_add", "reaction_remove",
      "read_receipt", "draft_save", "draft_clear",
    ]);
    return bufs.has(type);
  }

  private flushOfflineQueue(): void {
    const batch = this.offlineQueue.splice(0);
    for (const msg of batch) {
      if (!this.socket?.connected) break;
      this.socket.emit(this.options.eventName, msg);
    }
  }

  private enqueueForReconnect(): void {
    // In-flight requests that are buffereable get re-queued
    for (const [, pending] of this.inFlight) {
      if (this.isBuffereable(pending.message.type)) {
        this.offlineQueue.push(pending.message);
      }
    }
  }

  subscribe(room: string): Promise<void> {
    if (this.subscriptions.isSubscribed(room)) return Promise.resolve();
    // Track locally first, then send WS message
    this.subscriptions.subscribe(room);
    return this.send({ type: "subscribe", room });
  }

  unsubscribe(room: string): Promise<void> {
    if (!this.subscriptions.isSubscribed(room)) return Promise.resolve();
    this.subscriptions.unsubscribe(room);
    return this.send({ type: "unsubscribe", room });
  }

  private drain(): void {
    if (!this.socket?.connected) {
      return;
    }

    while (
      this.queue.length > 0 &&
      this.inFlight.size < this.options.windowSize
    ) {
      const pending = this.queue.shift();
      if (!pending) {
        continue;
      }
      this.inFlight.set(pending.id, pending);
      this.socket.emit(this.options.eventName, pending.message);
    }
    this.syncWindowState();
  }

  private handleEvent(raw: unknown): void {
    const event = parseEvent(raw);
    if (!event) {
      return;
    }

    if (event.type === "response") {
      const resp = event as Extract<typeof event, { type: "response" }>;
      this.resolveResponse(resp);
      return;
    }

    // Client-side dedup: skip already-seen message events.
    if (this.shouldDedup(event)) return;

    for (const handler of this.handlers) {
      handler(event);
    }
  }

  /** Returns true if the event was already seen (duplicate). */
  private shouldDedup(event: ChannelOutEvent): boolean {
    if (event.type === "message_new") {
      const data = event.data as { id?: string };
      const roomId = (event as { room?: { id?: string } }).room?.id ?? "";
      if (data?.id) return this.dedup.checkAndMark(data.id, roomId);
    }
    return false;
  }

  /**
   * Convert a backend ChannelEvent (received on \"room.message\") into the
   * WsOutEvent-compatible shape expected by registered handlers.
   */
  private handleRoomEvent(raw: unknown): void {
    const event = roomEventToOutEvent(raw);
    if (!event) {
      return;
    }
    // Client-side dedup on room events too.
    if (this.shouldDedup(event)) return;
    for (const handler of this.handlers) {
      handler(event);
    }
  }

  private resolveResponse(event: { request_id: string; data: unknown }): void {
    const requestId = event.request_id;
    if (typeof requestId !== "string") {
      return;
    }

    const pending = this.inFlight.get(requestId);
    if (!pending) {
      return;
    }

    this.inFlight.delete(requestId);
    clearTimeout(pending.timeout);
    this.syncWindowState();

    if (isErrorPayload(event.data)) {
      pending.reject(toError(event.data));
    } else {
      pending.resolve(event.data);
    }
    this.drain();
  }

  private flushToHttp(): void {
    if (!this.options.fallback) {
      return;
    }

    const pending = [...this.inFlight.values(), ...this.queue].filter(
      (item) => item.fallback,
    );
    const nonFallback = [...this.inFlight.values(), ...this.queue].filter(
      (item) => !item.fallback,
    );
    this.inFlight.clear();
    this.queue.length = 0;
    this.syncWindowState();

    for (const item of nonFallback) {
      clearTimeout(item.timeout);
      item.reject(new Error("Connection lost, no HTTP fallback available"));
    }

    for (const item of pending) {
      clearTimeout(item.timeout);
      this.fallback
        .request(item.message)
        .then(item.resolve)
        .catch(item.reject);
    }
  }

  private isConnected(): boolean {
    return this.socket?.connected === true;
  }

  private currentTransport(): ChannelTransport {
    const name = this.socket?.io.engine?.transport?.name;
    if (name === "websocket" || name === "polling") {
      return name;
    }
    return "unknown";
  }

  private removeQueued(id: string): void {
    const index = this.queue.findIndex((item) => item.id === id);
    if (index >= 0) {
      this.queue.splice(index, 1);
    }
  }

  private syncWindowState(): void {
    channelSocketStore.setState({
      inFlight: this.inFlight.size,
      queued: this.queue.length,
    });
  }
}

export const channelSocket = new ChannelSocketClient();

function parseEvent(raw: unknown): ChannelOutEvent | null {
  if (typeof raw === "string") {
    try {
      return parseEvent(JSON.parse(raw));
    } catch {
      return null;
    }
  }

  if (raw && typeof raw === "object" && "type" in raw && typeof (raw as Record<string, unknown>).type === "string") {
    return raw as ChannelOutEvent;
  }

  return null;
}

function isErrorPayload(value: unknown): value is { error: string; message?: string } {
  return (
    value !== null &&
    typeof value === "object" &&
    "error" in value &&
    typeof (value as { error?: unknown }).error === "string"
  );
}

function toError(payload: { error: string; message?: string }): Error {
  const error = new Error(payload.message ?? payload.error);
  error.name = payload.error;
  return error;
}

// --- Backend ChannelEvent → Frontend WsOutEvent converter ---
// The backend broadcasts room-level events on "room.message" as
// ChannelEvent { room, seq, event_type, message, payload }.
// We convert them into the WsOutEvent shapes expected by the frontend.

interface RawChannelEvent {
  room: string;
  seq: number | null;
  event_type: string;
  message: unknown;
  payload: unknown;
  sender_user?: { id: string; username: string; display_name: string; avatar_url: string };
}

const EVENT_TYPE_MAP: Record<string, string> = {
  "message.created": "message_new",
  "message.edited": "message_edited",
  "message.revoked": "message_revoked",
  "message.stream_start": "message_stream_start",
  "message.stream_chunk": "message_stream_chunk",
  "message.stream_done": "message_stream_done",
  "message.read_batch": "message_read_batch",
  "message.read": "message_read",
  "message.forwarded": "message_forwarded",
  "typing.start": "typing_start",
  "typing.stop": "typing_stop",
  "reaction.added": "reaction_added",
  "reaction.removed": "reaction_removed",
  "member.read_receipt": "read_receipt",
  "member.joined": "member_joined",
  "member.removed": "member_removed",
  "member.dnd_updated": "dnd_updated",
  "thread.created": "thread_created",
  "thread.updated": "thread_updated",
  "thread.resolved": "thread_resolved",
  "thread.archived": "thread_archived",
  "pin.added": "pin_added",
  "pin.removed": "pin_removed",
  "room.created": "room_created",
  "room.deleted": "room_deleted",
  "room.renamed": "room_renamed",
  "room.topic_updated": "room_topic_updated",
  "room.settings_updated": "room_settings_updated",
  "room.moved": "room_moved",
  "category.created": "category_created",
  "category.updated": "category_updated",
  "category.deleted": "category_deleted",
  "voice.channel_joined": "voice_channel_joined",
  "voice.channel_left": "voice_channel_left",
  "voice.mute_updated": "voice_mute_updated",
  "voice.deaf_updated": "voice_deaf_updated",
  "voice.screen_share": "screen_share_started",
  "presence.changed": "presence_changed",
  "custom_status.updated": "custom_status_updated",
  "draft.saved": "draft_saved",
  "draft.cleared": "draft_cleared",
  "invite.created": "invite_created",
  "invite.accepted": "invite_accepted",
  "notify.created": "notify_created",
  "notify.read": "notify_read",
  "attachment.uploaded": "attachment_uploaded",
  "attachment.deleted": "attachment_deleted",
  "ban.created": "user_banned",
  "ban.removed": "user_unbanned",
};

/** Convert a RoomMessageModel (from backend ChannelEvent.message) into
 *  MessageNewService shape that the frontend expects.
 *  Uses sender_user from the ChannelEvent when available. */
function messageModelToService(
  m: Record<string, unknown>,
  roomId: string,
  senderUser?: { id: string; username: string; display_name: string; avatar_url: string },
): Record<string, unknown> {
  return {
    id: m.id,
    seq: m.seq,
    room: { id: m.room ?? roomId, name: "" },
    sender_type: "user",
    sender: senderUser ?? {
      id: m.author ?? "",
      username: "",
      display_name: "Unknown",
      avatar_url: "",
    },
    thread: m.thread ?? null,
    in_reply_to: m.parent ?? m.in_reply_to ?? null,
    content: m.content ?? "",
    content_type: m.content_type ?? "text",
    pinned: m.pinned ?? false,
    system_type: m.system_type ?? null,
    metadata: m.metadata ?? {},
    thinking_content: null,
    thinking_is_chunked: null,
    send_at: m.created_at ?? new Date().toISOString(),
    reactions: [],
  };
}

function roomEventToOutEvent(raw: unknown): ChannelOutEvent | null {
  const ev = normalizePayload(raw);
  if (!ev) return null;

  // message.created: the backend sends RoomMessageModel in ev.message.
  // We need to rewrite author → sender for the frontend MessageNewService.
  if (ev.event_type === "message.created" && ev.message) {
    return {
      type: "message_new",
      room: { id: ev.room, name: "" },
      data: messageModelToService(
        ev.message as Record<string, unknown>,
        ev.room,
        ev.sender_user,
      ),
    } as ChannelOutEvent;
  }

  const outType = EVENT_TYPE_MAP[ev.event_type];
  if (!outType) return null;

  // All other events carry structured data in ev.payload.
  return {
    type: outType,
    room: { id: ev.room, name: "" },
    data: ev.payload,
  } as ChannelOutEvent;
}

function normalizePayload(raw: unknown): RawChannelEvent | null {
  if (typeof raw === "string") {
    try { return normalizePayload(JSON.parse(raw)); } catch { return null; }
  }
  if (
    raw &&
    typeof raw === "object" &&
    "event_type" in raw &&
    typeof (raw as Record<string, unknown>).event_type === "string"
  ) {
    return raw as RawChannelEvent;
  }
  return null;
}
