import {
  WebSocketGateway,
  WebSocketServer,
  SubscribeMessage,
  MessageBody,
  ConnectedSocket,
  OnGatewayConnection,
  OnGatewayDisconnect,
} from '@nestjs/websockets';
import { Logger, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Server, Socket } from 'socket.io';
import { RoomRegistry, RoomParticipant } from './room.registry';
import { MeetingService, AttendanceRow } from './services/meeting.service';

interface JoinPayload {
  roomId: string;
  auditId: string;
  /** display info only — identity comes from the verified JWT */
  user: {
    name: string;
    initials: string;
    role: 'auditor' | 'client' | 'observer';
  };
}

interface SignalPayload {
  /** user id of the recipient */
  to: string;
  /** opaque WebRTC blob: offer, answer or ICE candidate */
  data: any;
}

@Injectable()
@WebSocketGateway({
  namespace: '/meeting',
  cors: {
    origin: true,        // ← reflects the requesting origin
    credentials: true,
  },
})
export class MeetingGateway implements OnGatewayConnection, OnGatewayDisconnect {
  private readonly log = new Logger(MeetingGateway.name);

  @WebSocketServer()
  server: Server;

  constructor(
    private readonly rooms: RoomRegistry,
    private readonly jwtService: JwtService,
    private readonly meetings: MeetingService,
  ) {}

  // ─────────────────────────────── attendance persistence helper

  /**
   * Write ONE attendance row for a participant who has just left.
   *
   * The entity is "one row per join" by design, so persisting per-departure
   * (rather than once when the room empties) matches that shape exactly and
   * avoids re-writing rows for people who already left. `p.leftAt` is stamped
   * by RoomRegistry.removeParticipant before this runs.
   */
  private async persistLeave(roomCode: string, p: RoomParticipant) {
    try {
      const row: AttendanceRow = {
        userId: p.id,
        displayName: p.name,
        role: p.role,
        joinedAt: p.joinedAt,
        leftAt: p.leftAt ?? Date.now(),
      };
      await this.meetings.persistAttendance(roomCode, [row]);
    } catch (err: any) {
      // never let an evidence write break the live call
      this.log.error(`persistAttendance failed for ${roomCode}: ${err.message}`);
    }
  }

  // ─────────────────────────────── connection + auth

  handleConnection(client: Socket) {
    try {
      const token =
        client.handshake.auth?.token ||
        (client.handshake.headers?.token as string) ||
        client.handshake.headers?.authorization?.replace('Bearer ', '');

      if (!token) {
        this.log.warn(`rejected ${client.id}: no token`);
        client.disconnect();
        return;
      }

      // Signature IS verified here. Do not replace this with a bare
      // base64 decode — an unverified token lets anyone claim any identity
      // and join any audit room.
      const payload: any = this.jwtService.verify(token);

      // ── guest token (redeemed invite) ──
      // No real account. Authorised for exactly ONE room, with a role the
      // server set at redeem time — both are trusted from the token, never
      // from what the client sends on join.
      if (payload.guest) {
        client.data.userId = String(payload.sub); // e.g. "guest:12"
        client.data.isGuest = true;
        client.data.guestRoomCode = payload.room_code;
        client.data.guestRole = payload.role || 'client';
        client.data.guestName = payload.name || 'Guest';
        this.log.log(`connected: ${client.id} (guest, invite ${payload.invite_id})`);
        return;
      }

      // this app's JWT uses `sub` in some places and `id` in others
      const userId = payload.id ?? payload.sub ?? payload.userId;

      if (!userId) {
        this.log.warn(`rejected ${client.id}: no user id in payload`);
        client.disconnect();
        return;
      }

      client.data.userId = String(userId);
      client.data.roleIds = payload.roleIds || [];

      this.log.log(`connected: ${client.id} (user ${userId})`);
    } catch (err: any) {
      this.log.warn(`rejected ${client.id}: ${err.message}`);
      client.disconnect();
    }
  }

  async handleDisconnect(client: Socket) {
    const room = this.rooms.findRoomBySocket(client.id);
    if (!room) return;

    const roomCode = room.roomId;
    const p = this.rooms.removeParticipant(roomCode, client.id);
    if (p) {
      this.server.to(roomCode).emit('participant:left', {
        participantId: p.id,
        name: p.name,
        at: Date.now(),
      });
      this.log.log(`${p.name} left ${roomCode}`);

      // Save the attendance row that was, until now, computed and discarded.
      await this.persistLeave(roomCode, p);
    }
  }

  // ─────────────────────────────── room membership

  @SubscribeMessage('join')
  async onJoin(
    @MessageBody() body: JoinPayload,
    @ConnectedSocket() client: Socket,
  ) {
    const { roomId, auditId, user } = body ?? ({} as JoinPayload);
    const userId = client.data.userId;

    if (!roomId || !userId) {
      return { ok: false, error: 'roomId required, and you must be authenticated' };
    }

    // Authorisation. `roomId` from the client IS the meeting's room_code.
    // Without this, any logged-in user who knows a room code walks into a
    // confidential audit and bypasses every passcode check.
    if (client.data.isGuest) {
      // A guest is authorised for exactly the room named in their token.
      if (client.data.guestRoomCode !== roomId) {
        this.log.warn(`denied guest join to ${roomId}: token is for another room`);
        return { ok: false, error: 'This invitation is for a different meeting' };
      }
    } else {
      const allowed = await this.meetings.canJoin(Number(userId), roomId);
      if (!allowed) {
        this.log.warn(`denied user ${userId} join to ${roomId}: not a participant`);
        return { ok: false, error: 'You are not a participant of this meeting' };
      }
    }

    this.rooms.getOrCreate(roomId, auditId);

    // First person in flips the meeting to LIVE (no-op if already live).
    await this.meetings
      .markLive(roomId)
      .catch((e) => this.log.error(`markLive failed for ${roomId}: ${e.message}`));

    // For a guest, name and role come from the redeem-time token — never from
    // what the client sends here — so a guest can't relabel themselves as an
    // auditor to gain NC-confirm rights.
    const resolvedName = client.data.isGuest
      ? client.data.guestName
      : user?.name ?? `User ${userId}`;
    const resolvedRole = client.data.isGuest
      ? client.data.guestRole
      : user?.role ?? 'client';

    const participant = {
      socketId: client.id,
      id: userId,
      name: resolvedName,
      initials:
        (resolvedName?.[0] ?? user?.initials ?? '?').toString().toUpperCase(),
      role: resolvedRole,
      joinedAt: Date.now(),
    };

    this.rooms.addParticipant(roomId, participant);
    client.join(roomId);
    client.data.roomId = roomId;
    client.data.role = participant.role;

    client.to(roomId).emit('participant:joined', {
      id: participant.id,
      name: participant.name,
      initials: participant.initials,
      role: participant.role,
      connected: true,
    });

    this.log.log(`${participant.name} joined ${roomId}`);

    return {
      ok: true,
      roomId,
      you: { id: participant.id, role: participant.role },
      participants: this.rooms
        .listParticipants(roomId)
        .filter((p) => p.id !== userId)
        .map((p) => ({
          id: p.id,
          name: p.name,
          initials: p.initials,
          role: p.role,
          connected: true,
        })),
    };
  }

  @SubscribeMessage('leave')
  async onLeave(@ConnectedSocket() client: Socket) {
    const roomId = client.data.roomId;
    if (!roomId) return { ok: false };

    const report = this.rooms.attendanceReport(roomId);
    const p = this.rooms.removeParticipant(roomId, client.id);

    if (p) {
      this.server.to(roomId).emit('participant:left', {
        participantId: p.id,
        name: p.name,
        at: Date.now(),
      });
      await this.persistLeave(roomId, p);
    }
    client.leave(roomId);

    return { ok: true, attendance: report };
  }

  // ─────────────────────────────── WebRTC relay (phase 2)

  /**
   * Forwards offers, answers and ICE candidates between peers.
   * The server never inspects `data` — it is opaque WebRTC payload.
   */
  @SubscribeMessage('signal')
  onSignal(@MessageBody() body: SignalPayload, @ConnectedSocket() client: Socket) {
    const roomId = client.data.roomId;
    const room = this.rooms.get(roomId);
    if (!room) return { ok: false, error: 'not in a room' };

    const target = [...room.participants.values()].find((p) => p.id === body.to);
    if (!target) return { ok: false, error: 'recipient not in room' };

    this.server.to(target.socketId).emit('signal', {
      from: client.data.userId,
      data: body.data,
    });

    return { ok: true };
  }

  // ─────────────────────────────── mic state

  /**
   * A participant muted or unmuted their mic. The audio track is already
   * stopped on their device — this only tells the OTHER people so they can
   * show a muted icon instead of wondering why someone went quiet.
   */
  @SubscribeMessage('mute:state')
  onMuteState(
    @MessageBody() body: { muted: boolean },
    @ConnectedSocket() client: Socket,
  ) {
    const roomId = client.data.roomId;
    if (!roomId) return { ok: false };

    // to everyone EXCEPT the sender — their own UI already updated
    client.to(roomId).emit('participant:muted', {
      participantId: client.data.userId,
      muted: !!body.muted,
      at: Date.now(),
    });

    return { ok: true };
  }

  // ─────────────────────────────── live sync (phase 1)

  @SubscribeMessage('checklist:status')
  onChecklistStatus(
    @MessageBody() body: { id: string; status: string },
    @ConnectedSocket() client: Socket,
  ) {
    const roomId = client.data.roomId;
    if (!roomId) return { ok: false };

    // to everyone EXCEPT the sender — their UI already updated
    client.to(roomId).emit('checklist:status', {
      id: body.id,
      status: body.status,
      by: client.data.userId,
      at: Date.now(),
    });

    return { ok: true };
  }

  @SubscribeMessage('chat:send')
  onChat(@MessageBody() body: { body: string }, @ConnectedSocket() client: Socket) {
    const roomId = client.data.roomId;
    const room = this.rooms.get(roomId);
    if (!room) return { ok: false };

    const sender = room.participants.get(client.id);
    if (!sender) return { ok: false };

    const message = {
      id: `${Date.now()}-${sender.id}`,
      authorId: sender.id,
      authorName: sender.name,
      initials: sender.initials,
      body: body.body,
      at: new Date().toTimeString().slice(0, 5),
    };

    // to everyone INCLUDING the sender, so message ids stay consistent
    this.server.to(roomId).emit('chat:message', message);

    return { ok: true, message };
  }

  @SubscribeMessage('doc:received')
  onDocReceived(
    @MessageBody() body: { id: string },
    @ConnectedSocket() client: Socket,
  ) {
    const roomId = client.data.roomId;
    if (!roomId) return { ok: false };

    client.to(roomId).emit('doc:received', { id: body.id, at: Date.now() });
    return { ok: true };
  }

  @SubscribeMessage('nc:resolve')
  onNcResolve(
    @MessageBody() body: { id: string; status: 'confirmed' | 'dismissed' },
    @ConnectedSocket() client: Socket,
  ) {
    const roomId = client.data.roomId;
    const room = this.rooms.get(roomId);
    if (!room) return { ok: false };

    const sender = room.participants.get(client.id);

    // enforced server-side, not just hidden in the UI
    if (sender?.role !== 'auditor') {
      return { ok: false, error: 'only an auditor can resolve an NC' };
    }

    this.server.to(roomId).emit('nc:resolved', {
      id: body.id,
      status: body.status,
      by: sender.id,
      at: Date.now(),
    });

    return { ok: true };
  }

  /**
   * TEMPORARY. Pushes a fake AI-drafted NC into a live room so the card can
   * be exercised before transcription exists. Delete once the real pipeline
   * is in place.
   */
  @SubscribeMessage('nc:draft:test')
  onNcDraftTest(@MessageBody() body: any, @ConnectedSocket() client: Socket) {
    const roomId = client.data.roomId;
    if (!roomId) return { ok: false };

    this.server.to(roomId).emit('nc:draft', {
      id: `nc-${Date.now()}`,
      clause: body?.clause ?? 'Clause 4.2',
      finding:
        body?.finding ??
        'Client confirmed no fixed logging schedule for the walk-in fridge.',
      severity: body?.severity ?? 'minor',
      source: 'ai',
      status: 'draft',
    });

    return { ok: true };
  }
}