import { Server } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import type { Server as HTTPServer } from 'node:http';
import { and, eq } from 'drizzle-orm';
import { z } from 'zod';

import { redis } from '@/lib/redis';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import {
  userProfiles,
  userPortRoles,
  portRoleOverrides,
  userPermissionOverrides,
} from '@/lib/db/schema/users';
import type { RolePermissions } from '@/lib/db/schema/users';
import { berths } from '@/lib/db/schema/berths';
import { clients } from '@/lib/db/schema/clients';
import { interests } from '@/lib/db/schema/interests';
import { deepMerge } from '@/lib/api/helpers';
import { logger } from '@/lib/logger';
import type { ServerToClientEvents, ClientToServerEvents } from './events';

let io: Server<ClientToServerEvents, ServerToClientEvents> | null = null;

/**
 * L20(b): allow-list + uuid validation for the `join:entity` / `leave:entity`
 * payloads. The TS union on `ClientToServerEvents` is a compile-time contract
 * only - the wire payload is attacker-controllable, so we re-validate at the
 * trust boundary before any DB lookup. Fails closed: a malformed `{type,id}`
 * is dropped rather than reaching `userCanJoinEntity`.
 */
const ENTITY_JOIN_SCHEMA = z.object({
  type: z.enum(['berth', 'client', 'interest']),
  id: z.string().uuid(),
});

/**
 * M11: each joinable entity room is gated by the matching `<resource>.view`
 * leaf so a socket can only subscribe to entity broadcasts the REST API would
 * also grant (`GET /clients/[id]/notes` etc. require `clients.view`). Without
 * this, room membership was port-membership-only, leaking note-content
 * previews to users whose role grants zero permissions on that resource.
 */
const ENTITY_VIEW_RESOURCE: Record<'berth' | 'client' | 'interest', keyof RolePermissions> = {
  berth: 'berths',
  client: 'clients',
  interest: 'interests',
};

const DEV_ORIGIN_PATTERNS = [
  /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/,
  /^https?:\/\/192\.168\.\d+\.\d+(:\d+)?$/,
  /^https?:\/\/10\.\d+\.\d+\.\d+(:\d+)?$/,
  /^https?:\/\/172\.(1[6-9]|2\d|3[01])\.\d+\.\d+(:\d+)?$/,
];

function socketCorsOrigin(
  origin: string | undefined,
  cb: (err: Error | null, allow?: boolean) => void,
): void {
  if (!origin) return cb(null, true);
  if (process.env.NODE_ENV === 'production') {
    return cb(null, origin === process.env.APP_URL);
  }
  cb(
    null,
    DEV_ORIGIN_PATTERNS.some((re) => re.test(origin)),
  );
}

/**
 * Returns true if the user is a super-admin OR holds a userPortRoles row
 * for the given portId. The Socket.IO auth middleware uses this to decide
 * whether to honour a client-supplied `auth.portId` - the prior code
 * trusted whatever the client passed and thereby joined the socket to a
 * foreign tenant's broadcast room.
 */
async function userCanAccessPort(userId: string, portId: string): Promise<boolean> {
  const profile = await db.query.userProfiles.findFirst({
    where: eq(userProfiles.userId, userId),
  });
  // M10: a deactivated profile has no access regardless of role rows. Mirrors
  // the HTTP `withAuth` gate (helpers.ts:152) which 403s `!profile.isActive`.
  // Short-circuit BEFORE the super-admin branch so a deactivated super-admin
  // is also denied.
  if (!profile || !profile.isActive) return false;
  if (profile.isSuperAdmin) return true;
  const role = await db.query.userPortRoles.findFirst({
    where: and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, portId)),
  });
  return Boolean(role);
}

/**
 * Verify the user can join an entity-scoped room. Two gates, both required
 * (super-admins bypass both):
 *
 *  - tenant: the user must hold a role in the entity's port (cross-tenant
 *    subscription prevention).
 *  - M11 permission: the user must hold the matching `<resource>.view`
 *    leaf (`berths.view` / `clients.view` / `interests.view`). Previously this
 *    was membership-only, so a user whose role granted zero permissions on the
 *    resource could still join the room and receive note-content previews that
 *    the REST API (`GET /clients/[id]/notes`, gated `withPermission`) would 403.
 *    We resolve the *effective* permission map (role → port-role override →
 *    per-user residential toggle → per-user override), mirroring the HTTP
 *    `withAuth` chain in src/lib/api/helpers.ts so the two surfaces agree.
 */
async function userCanJoinEntity(
  userId: string,
  type: 'berth' | 'client' | 'interest',
  id: string,
): Promise<boolean> {
  const profile = await db.query.userProfiles.findFirst({
    where: eq(userProfiles.userId, userId),
  });
  // M10: deactivated users may not join any room (deny before super-admin).
  if (!profile || !profile.isActive) return false;
  if (profile.isSuperAdmin) return true;

  let entityPortId: string | null = null;
  if (type === 'berth') {
    const row = await db.query.berths.findFirst({ where: eq(berths.id, id) });
    entityPortId = row?.portId ?? null;
  } else if (type === 'client') {
    const row = await db.query.clients.findFirst({ where: eq(clients.id, id) });
    entityPortId = row?.portId ?? null;
  } else if (type === 'interest') {
    const row = await db.query.interests.findFirst({ where: eq(interests.id, id) });
    entityPortId = row?.portId ?? null;
  }
  if (!entityPortId) return false;

  // M11: resolve effective permissions for this user in the entity's port and
  // require the matching `<resource>.view` leaf. A null/missing role row means
  // no port access; a missing leaf means no view permission.
  const permissions = await resolveEffectivePermissions(userId, entityPortId);
  if (!permissions) return false;

  const resource = ENTITY_VIEW_RESOURCE[type];
  const resourcePerms = permissions[resource] as Record<string, boolean> | undefined;
  return Boolean(resourcePerms?.view);
}

/**
 * Resolve the effective `RolePermissions` map for a non-super-admin user in a
 * given port, applying the same layering as the HTTP `withAuth` path
 * (src/lib/api/helpers.ts:174-238): base role → port-level role override
 * (deep-merge) → per-user residential toggle → per-user override (deep-merge).
 *
 * Returns null when the user holds no role in the port. Super-admins are
 * handled by the caller (they bypass permission checks entirely), so this is
 * only invoked for non-super-admins.
 */
async function resolveEffectivePermissions(
  userId: string,
  portId: string,
): Promise<RolePermissions | null> {
  const portRole = await db.query.userPortRoles.findFirst({
    where: and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, portId)),
    with: { role: true },
  });
  if (!portRole) return null;

  let permissions: RolePermissions = { ...(portRole.role.permissions as RolePermissions) };

  // Port-specific role overrides (deep-merge on top of the base role).
  const override = await db.query.portRoleOverrides.findFirst({
    where: and(eq(portRoleOverrides.portId, portId), eq(portRoleOverrides.roleId, portRole.roleId)),
  });
  if (override?.permissionOverrides) {
    permissions = deepMerge(
      permissions as unknown as Record<string, unknown>,
      override.permissionOverrides as Record<string, unknown>,
    ) as unknown as RolePermissions;
  }

  // Per-user residential toggle.
  if (portRole.residentialAccess) {
    permissions = {
      ...permissions,
      residential_clients: { view: true, create: true, edit: true, delete: true },
      residential_interests: {
        view: true,
        create: true,
        edit: true,
        delete: true,
        change_stage: true,
      },
    };
  }

  // Per-user permission overrides (final layer - wins over role + port override).
  const userOverride = await db.query.userPermissionOverrides.findFirst({
    where: and(
      eq(userPermissionOverrides.userId, userId),
      eq(userPermissionOverrides.portId, portId),
    ),
  });
  if (userOverride?.permissionOverrides) {
    permissions = deepMerge(
      permissions as unknown as Record<string, unknown>,
      userOverride.permissionOverrides as Record<string, unknown>,
    ) as unknown as RolePermissions;
  }

  return permissions;
}

export function initSocketServer(
  httpServer: HTTPServer,
): Server<ClientToServerEvents, ServerToClientEvents> {
  const pubClient = redis.duplicate();
  const subClient = redis.duplicate();

  io = new Server<ClientToServerEvents, ServerToClientEvents>(httpServer, {
    path: '/socket.io/',
    adapter: createAdapter(pubClient, subClient),
    cors: {
      // In prod, lock to the canonical APP_URL. In dev, allow localhost
      // + private-LAN origins so the same dev server serves the Mac
      // (localhost) and a phone on Wi-Fi (192.168.x.x) without a config
      // edit per network. Mirrors the trustedOrigins pattern in auth.
      origin: socketCorsOrigin,
      credentials: true,
    },
    connectionStateRecovery: { maxDisconnectionDuration: 2 * 60 * 1000 },
    maxHttpBufferSize: 1e6, // 1MB message limit
  });

  // Auth middleware - validate session cookie + verify the user actually
  // holds a role in the requested port. The handshake's auth.portId is
  // user-supplied; we MUST cross-check it against userPortRoles or any
  // authenticated user could subscribe to a foreign tenant's broadcasts.
  //
  // L20(c): `connectionStateRecovery` restores a socket's prior rooms on a
  // brief reconnect, but Socket.IO re-runs this middleware on every
  // (re)connection, so the cookie/session and `isActive` are re-validated each
  // time. A session revoked or a profile deactivated mid-disconnect is rejected
  // on the recovery attempt; the only residual is the <=2-min disconnection
  // window during which an already-open transport retains its old rooms, which
  // is acceptable for this push-only broadcast surface.
  io.use(async (socket, next) => {
    try {
      const cookie = socket.handshake.headers.cookie;
      if (!cookie) return next(new Error('Authentication required'));

      // Parse session from cookie
      const session = await auth.api.getSession({
        headers: new Headers({ cookie }),
      });
      if (!session?.user) return next(new Error('Invalid session'));

      // M10: reject deactivated users. A valid session cookie outlives a
      // profile deactivation, so without this gate a deactivated rep's live
      // tab keeps its socket and receives every port-scoped broadcast (new
      // clients, invoice totals + names, document-signed, payment amounts,
      // note previews) until the cookie expires. Mirrors the HTTP `withAuth`
      // gate (helpers.ts:152) which 403s `!profile.isActive`.
      const profile = await db.query.userProfiles.findFirst({
        where: eq(userProfiles.userId, session.user.id),
      });
      if (!profile || !profile.isActive) {
        return next(new Error('Account disabled'));
      }

      // Enforce max 10 connections per user
      const userSockets = await io!.in(`user:${session.user.id}`).fetchSockets();
      if (userSockets.length >= 10) {
        return next(new Error('Maximum connections reached'));
      }

      const requestedPortId = socket.handshake.auth.portId as string | undefined;
      if (requestedPortId) {
        const allowed = await userCanAccessPort(session.user.id, requestedPortId);
        if (!allowed) {
          return next(new Error('No access to this port'));
        }
      }
      // L20(a): a port-less connection is intentionally permitted - it joins
      // only the personal `user:<id>` room (no `port:` broadcast room) and can
      // still emit `join:entity`, but every entity room is independently gated
      // by `userCanJoinEntity` (tenant + `<resource>.view`), so no port/entity
      // data leaks to a connection that never proved port access. Used by
      // super-admins and the pre-port-selection window. Not rejected so those
      // flows keep working.

      socket.data = {
        userId: session.user.id,
        portId: requestedPortId,
      };
      next();
    } catch {
      next(new Error('Authentication failed'));
    }
  });

  // Connection handler
  io.on('connection', (socket) => {
    const { userId, portId } = socket.data as { userId: string; portId: string | undefined };
    logger.debug({ userId, portId }, 'Socket connected');

    // Auto-join personal and port rooms
    socket.join(`user:${userId}`);
    if (portId) socket.join(`port:${portId}`);

    // Entity-level room management - verify the user can access the
    // entity's port AND holds the matching `<resource>.view` permission
    // (M11) before joining. Without this, any authenticated user could
    // subscribe to a foreign-tenant entity's broadcast (note previews,
    // signer emails, etc.) by guessing or harvesting an id.
    socket.on('join:entity', async (payload) => {
      // L20(b): validate the wire payload (allow-listed type + uuid id) before
      // any DB lookup - the TS union is compile-time only and the payload is
      // attacker-controllable. Fails closed on malformed input.
      const parsed = ENTITY_JOIN_SCHEMA.safeParse(payload);
      if (!parsed.success) {
        logger.warn({ userId, payload }, 'Socket rejected malformed join:entity');
        return;
      }
      const { type, id } = parsed.data;
      try {
        const ok = await userCanJoinEntity(userId, type, id);
        if (ok) socket.join(`${type}:${id}`);
        else logger.warn({ userId, type, id }, 'Socket denied join:entity');
      } catch (err) {
        logger.warn({ err, userId, type, id }, 'join:entity check failed');
      }
    });
    socket.on('leave:entity', (payload) => {
      // Leave is harmless (removing yourself from a room you may not even be
      // in), but validate for symmetry so a malformed payload can't reach
      // `socket.leave` with a non-string room key.
      const parsed = ENTITY_JOIN_SCHEMA.safeParse(payload);
      if (!parsed.success) return;
      const { type, id } = parsed.data;
      socket.leave(`${type}:${id}`);
    });

    // NOTE: there is no manual idle-disconnect timer here. The previous
    // implementation forcibly disconnected every socket after 30s of
    // client-emitted activity, which (a) would have torn down every
    // dashboard connection in production every half minute (the server-
    // push events don't reset the timer because `onAny` fires only on
    // inbound messages from the client) and (b) was redundant - Socket.IO's
    // built-in pingTimeout / pingInterval handles dead-transport detection
    // at the protocol level.

    socket.on('disconnect', () => {
      logger.debug({ userId }, 'Socket disconnected');
    });
  });

  return io;
}

export function getIO(): Server<ClientToServerEvents, ServerToClientEvents> {
  if (!io) throw new Error('Socket.io not initialized');
  return io;
}

/**
 * Idempotent shutdown for graceful-stop on SIGTERM/SIGINT - closes the
 * Socket.io server and (if present) its Redis adapter pub/sub clients so
 * the process can exit cleanly.
 */
export async function closeSocketServer(): Promise<void> {
  if (!io) return;
  await new Promise<void>((resolve) => io!.close(() => resolve()));
  io = null;
}

/**
 * Emit an event to a specific room. Used by service layer after mutations.
 */
export function emitToRoom<E extends keyof ServerToClientEvents>(
  room: string,
  event: E,
  ...args: Parameters<ServerToClientEvents[E]>
): void {
  if (!io) return;
  io.to(room).emit(event, ...args);
}
