import {
  BadRequestException,
  ForbiddenException,
  GoneException,
  Injectable,
  Logger,
  NotFoundException,
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { JwtService } from '@nestjs/jwt';
import { createHash } from 'crypto';

import { Meeting, MeetingStatus } from '../entities/meeting.entity';
import {
  InviteChannel,
  InviteScope,
  InviteStatus,
  MeetingInvite,
} from '../entities/meeting-invite.entity';
import { CreateInviteDto } from '../dto/create-invite.dto';
import { RedeemInviteDto } from '../dto/redeem-invite.dto';
import { SendInviteDto } from '../dto/send-invite.dto';
import { RoomCodeGeneratorService } from './room-code-generator.service';
import { MailsService } from '../../mails/mails.service';
import {
  buildMeetingInviteEmail,
  buildMeetingInviteText,
  MeetingInviteEmailContext,
} from '../templates/meeting-invite-email.template';

const DEFAULT_TTL_HOURS = 4;
/**
 * 45 minutes, not 10.
 *
 * The real flow is: auditor generates the link, sends WhatsApp, client
 * notices the message, opens it, types the code. Ten minutes is easy to miss
 * and produces a "code expired" dead end right at the moment the client is
 * trying to join.
 */
const OTP_TTL_MINUTES = 45;
const MAX_OTP_ATTEMPTS = 5;

export interface DeliveryPayload {
  invite: MeetingInvite;
  url: string;
  otp?: string;
  /** tap-to-open link — no WhatsApp Business API needed */
  whatsapp_url?: string;
  sms_url?: string;
  message_text: string;
  email_sent: boolean;
}

@Injectable()
export class MeetingInviteService {
  private readonly logger = new Logger('MeetingInviteService');

  constructor(
    @InjectDataSource('scheme_dbs')
    private readonly dataSource: DataSource,
    private readonly codeGenerator: RoomCodeGeneratorService,
    private readonly mailsService: MailsService,
    private readonly jwt: JwtService,
  ) {}

  /**
   * A short-lived JWT that lets a redeemed guest connect to the meeting
   * socket. It carries NO real user id (guests have no account) — instead it
   * is stamped `guest: true` and scoped to one room. The gateway trusts the
   * room_code and role FROM THIS TOKEN, so a guest cannot join another room
   * or upgrade their own role. Signed with the same JWT_SECRET the gateway
   * verifies with (same JwtModule).
   */
  private signGuestToken(invite: MeetingInvite, displayName: string): string {
    return this.jwt.sign(
      {
        guest: true,
        sub: `guest:${invite.id}`,
        invite_id: invite.id,
        meeting_id: invite.meeting_id,
        room_code: invite.meeting.room_code,
        name: displayName,
        role: (invite.grant_role ?? 'CLIENT').toLowerCase(),
      },
      { expiresIn: '12h' },
    );
  }

  private get publicBase(): string {
    return process.env.PUBLIC_JOIN_URL || 'https://qrsyst.com';
  }

  private hashOtp(otp: string, token: string): string {
    // salted with the token so the same code on two invites hashes differently
    return createHash('sha256').update(`${token}:${otp}`).digest('hex');
  }

  /** strips everything that is not a digit — wa.me will not accept + or spaces */
  private waNumber(mobile?: string | null): string | null {
    if (!mobile) return null;
    const digits = mobile.replace(/\D/g, '');
    return digits.length >= 8 ? digits : null;
  }

  // ═══════════════════════════════════════════════════════════════════════
  // CREATE
  // ═══════════════════════════════════════════════════════════════════════

  async create(
    meetingId: number,
    dto: CreateInviteDto,
    currentUserId: number,
  ): Promise<DeliveryPayload> {
    const meeting = await this.loadMeeting(meetingId);

    if (meeting.status === MeetingStatus.ENDED) {
      throw new BadRequestException('Cannot invite to a meeting that has ended');
    }
    if (meeting.status === MeetingStatus.CANCELLED) {
      throw new BadRequestException('This meeting was cancelled');
    }

    const scope = dto.scope ?? InviteScope.VERIFIED;

    if (scope === InviteScope.VERIFIED && !dto.recipient_mobile && !dto.recipient_email) {
      throw new BadRequestException(
        'A mobile number or email is required for a verified invite — it is ' +
          'where the passcode goes. Use scope PUBLIC if you accept that a ' +
          'forwarded link would let anyone join.',
      );
    }

    const token = this.codeGenerator.inviteToken();
    const ttl = dto.ttl_hours ?? DEFAULT_TTL_HOURS;
    const expiresAt = new Date(Date.now() + ttl * 3600 * 1000);

    let otp: string | undefined;
    let otpHash: string | null = null;
    let otpExpires: Date | null = null;

    if (scope === InviteScope.VERIFIED) {
      otp = this.codeGenerator.otp();
      otpHash = this.hashOtp(otp, token);
      otpExpires = new Date(Date.now() + OTP_TTL_MINUTES * 60 * 1000);
    }

    const repo = this.dataSource.getRepository(MeetingInvite);
    const invite = await repo.save(
      repo.create({
        meeting_id: meetingId,
        token,
        recipient_name: dto.recipient_name ?? null,
        recipient_email: dto.recipient_email ?? null,
        recipient_mobile: dto.recipient_mobile ?? null,
        recipient_user_id: dto.recipient_user_id ?? null,
        scope,
        channel: dto.channel ?? InviteChannel.LINK,
        status: InviteStatus.PENDING,
        grant_role: (dto.grant_role ?? 'CLIENT').toUpperCase(),
        expires_at: expiresAt,
        otp_hash: otpHash,
        otp_expires_at: otpExpires,
        otp_attempts: 0,
        created_by_id: currentUserId,
      }),
    );

    this.logger.log(
      `Invite created (meeting=${meetingId}, scope=${scope}, by=${currentUserId})`,
    );

    const payload = this.buildDelivery(meeting, invite, otp);

    // send straight away if asked
    if (dto.send_now && dto.channel === InviteChannel.EMAIL) {
      payload.email_sent = await this.sendEmail(
        meeting,
        invite,
        otp,
        currentUserId,
      );
      if (payload.email_sent) {
      payload.invite = await this.markSent(invite.id, InviteChannel.EMAIL);
    }
    }

    return payload;
  }

  // ═══════════════════════════════════════════════════════════════════════
  // SEND
  // ═══════════════════════════════════════════════════════════════════════

  /**
   * Send an existing invite over a chosen channel.
   *
   * EMAIL goes out from the server through MailsService.
   * WHATSAPP and SMS return a tap-to-open link — the sender's own WhatsApp
   * opens with the message prefilled. No Business API, no template approval.
   *
   * A VERIFIED invite's passcode can only be delivered once from the create
   * call, so `regenerate_otp` issues a fresh one when re-sending.
   */
  async send(
    inviteId: number,
    dto: SendInviteDto,
    currentUserId: number,
  ): Promise<DeliveryPayload> {
    const repo = this.dataSource.getRepository(MeetingInvite);
    const invite = await repo.findOne({
      where: { id: inviteId },
      relations: ['meeting'],
    });
    if (!invite) throw new NotFoundException('Invite not found');

    this.assertUsable(invite);

    const meeting = await this.loadMeeting(invite.meeting_id);

    // The stored passcode is a one-way hash — to send it again we must
    // issue a new one.
    let otp: string | undefined;
    if (invite.scope === InviteScope.VERIFIED) {
      if (dto.regenerate_otp !== false) {
        otp = this.codeGenerator.otp();
        invite.otp_hash = this.hashOtp(otp, invite.token);
        invite.otp_expires_at = new Date(
          Date.now() + OTP_TTL_MINUTES * 60 * 1000,
        );
        invite.otp_attempts = 0;
        await repo.save(invite);
      }
    }

    const payload = this.buildDelivery(meeting, invite, otp);

    if (dto.channel === InviteChannel.EMAIL) {
      const target = dto.override_email ?? invite.recipient_email;
      if (!target) {
        throw new BadRequestException(
          'This invite has no email address — add one or send by WhatsApp',
        );
      }
      payload.email_sent = await this.sendEmail(
        meeting,
        invite,
        otp,
        currentUserId,
        target,
      );
    }

    payload.invite = await this.markSent(invite.id, dto.channel);
    return payload;
  }

  private async sendEmail(
    meeting: Meeting,
    invite: MeetingInvite,
    otp: string | undefined,
    senderUserId: number,
    overrideTo?: string,
  ): Promise<boolean> {
    const to = overrideTo ?? invite.recipient_email;
    if (!to) return false;

    const html = buildMeetingInviteEmail(this.emailContext(meeting, invite, otp));

    try {
      // sent from the inviter's own mailbox, matching how the rest of the
      // app sends — replies go to a real person
      await this.mailsService.sendAsUser(senderUserId, {
        to,
        subject: `Meeting invitation — ${meeting.title}`,
        html,
      });
      this.logger.log(`Invite ${invite.id} emailed to ${to}`);
      return true;
    } catch (e: any) {
      this.logger.warn(`Invite ${invite.id} email failed: ${e.message}`);
      return false;
    }
  }

  private emailContext(
    meeting: Meeting,
    invite: MeetingInvite,
    otp?: string,
  ): MeetingInviteEmailContext {
    const host = meeting.host_user as any;
    const standards = (meeting.audit_row as any)?.standards
      ?.map((s: any) => s.name)
      .join(', ');

    return {
      recipientName: invite.recipient_name ?? 'there',
      meetingTitle: meeting.title,
      companyName: meeting.company?.name ?? null,
      roomCode: meeting.room_code,
      joinUrl: `${this.publicBase}/j/${invite.token}`,
      passcode: otp,
      scheduledAt: meeting.scheduled_at,
      hostName: host
        ? `${host.firstName ?? ''} ${host.lastName ?? ''}`.trim()
        : null,
      standards: standards ?? null,
      isRecorded: meeting.is_recorded,
      expiresAt: invite.expires_at,
      passcodeValidMinutes: OTP_TTL_MINUTES,
      senderName: host
        ? `${host.firstName ?? ''} ${host.lastName ?? ''}`.trim()
        : undefined,
      senderEmail: host?.email,
    };
  }

  private buildDelivery(
    meeting: Meeting,
    invite: MeetingInvite,
    otp?: string,
  ): DeliveryPayload {
    const ctx = this.emailContext(meeting, invite, otp);
    const url = ctx.joinUrl;

    const waText = buildMeetingInviteText(ctx, 'WHATSAPP');
    const smsText = buildMeetingInviteText(ctx, 'SMS');
    const waNum = this.waNumber(invite.recipient_mobile);

    return {
      invite,
      url,
      otp,
      // with a number → opens the chat with that person
      // without   → opens the share sheet so the sender picks
      whatsapp_url: waNum
        ? `https://wa.me/${waNum}?text=${encodeURIComponent(waText)}`
        : `https://wa.me/?text=${encodeURIComponent(waText)}`,
      sms_url: invite.recipient_mobile
        ? `sms:${invite.recipient_mobile}?body=${encodeURIComponent(smsText)}`
        : undefined,
      message_text: waText,
      email_sent: false,
    };
  }

  // ═══════════════════════════════════════════════════════════════════════
  // LIST / STATUS
  // ═══════════════════════════════════════════════════════════════════════

  async listForMeeting(meetingId: number): Promise<MeetingInvite[]> {
    return this.dataSource.getRepository(MeetingInvite).find({
      where: { meeting_id: meetingId },
      relations: ['recipient_user', 'created_by'],
      order: { created_at: 'DESC' },
    });
  }

  async markSent(
    inviteId: number,
    channel: InviteChannel,
  ): Promise<MeetingInvite> {
    const repo = this.dataSource.getRepository(MeetingInvite);
    const invite = await repo.findOne({ where: { id: inviteId } });
    if (!invite) throw new NotFoundException('Invite not found');

    // never walk the status backwards — someone already opened it
    if (
      invite.status === InviteStatus.PENDING ||
      invite.status === InviteStatus.SENT
    ) {
      invite.status = InviteStatus.SENT;
    }
    invite.channel = channel;
    invite.sent_at = invite.sent_at ?? new Date();
    return repo.save(invite);
  }

  async revoke(inviteId: number, currentUserId: number): Promise<MeetingInvite> {
    const repo = this.dataSource.getRepository(MeetingInvite);
    const invite = await repo.findOne({
      where: { id: inviteId },
      relations: ['meeting'],
    });
    if (!invite) throw new NotFoundException('Invite not found');

    if (
      invite.created_by_id !== currentUserId &&
      invite.meeting?.host_user_id !== currentUserId
    ) {
      throw new ForbiddenException(
        'Only the host or the person who sent it can revoke an invite',
      );
    }

    invite.status = InviteStatus.REVOKED;
    return repo.save(invite);
  }

  // ═══════════════════════════════════════════════════════════════════════
  // REDEEM — the public /j/:token path
  // ═══════════════════════════════════════════════════════════════════════

  async peek(token: string) {
    const repo = this.dataSource.getRepository(MeetingInvite);
    const invite = await repo.findOne({
      where: { token },
      relations: ['meeting', 'meeting.company', 'meeting.host_user'],
    });
    if (!invite) throw new NotFoundException('This invite link is not valid');

    this.assertUsable(invite);

    if (
      invite.status === InviteStatus.PENDING ||
      invite.status === InviteStatus.SENT
    ) {
      invite.status = InviteStatus.OPENED;
      invite.opened_at = invite.opened_at ?? new Date();
      await repo.save(invite);
    }

    const m = invite.meeting;
    const host = m.host_user as any;

    return {
      ok: true,
      requires_otp: invite.scope === InviteScope.VERIFIED,
      requires_login: invite.scope === InviteScope.AUTHENTICATED,
      /** true when the stored passcode has aged out — offer "resend" */
      otp_expired:
        invite.scope === InviteScope.VERIFIED &&
        !!invite.otp_expires_at &&
        invite.otp_expires_at < new Date(),
      meeting: {
        room_code: m.room_code,
        title: m.title,
        company: m.company?.name ?? null,
        host: host ? `${host.firstName ?? ''} ${host.lastName ?? ''}`.trim() : null,
        scheduled_at: m.scheduled_at,
        status: m.status,
        is_recorded: m.is_recorded,
      },
      recipient_name: invite.recipient_name,
      expires_at: invite.expires_at,
    };
  }

  async redeem(token: string, dto: RedeemInviteDto) {
    const repo = this.dataSource.getRepository(MeetingInvite);
    const invite = await repo.findOne({
      where: { token },
      relations: ['meeting'],
    });
    if (!invite) throw new NotFoundException('This invite link is not valid');

    this.assertUsable(invite);

    if (invite.scope === InviteScope.VERIFIED) {
      if (!dto.otp) {
        throw new BadRequestException('A passcode is required');
      }
      if (invite.otp_attempts >= MAX_OTP_ATTEMPTS) {
        invite.status = InviteStatus.REVOKED;
        await repo.save(invite);
        throw new ForbiddenException(
          'Too many incorrect codes — this invite has been revoked',
        );
      }
      if (invite.otp_expires_at && invite.otp_expires_at < new Date()) {
        throw new GoneException(
          'That passcode has expired — tap resend to get a new one',
        );
      }

      const ok = invite.otp_hash === this.hashOtp(dto.otp, token);
      if (!ok) {
        invite.otp_attempts += 1;
        await repo.save(invite);
        throw new ForbiddenException('That passcode is not correct');
      }
    }

    invite.status = InviteStatus.JOINED;
    invite.joined_at = invite.joined_at ?? new Date();
    await repo.save(invite);

    const displayName = dto.display_name ?? invite.recipient_name ?? 'Guest';

    return {
      ok: true,
      room_code: invite.meeting.room_code,
      grant_role: invite.grant_role,
      display_name: displayName,
      meeting_id: invite.meeting_id,
      invite_id: invite.id,
      // the key that lets this verified guest actually connect to the call
      token: this.signGuestToken(invite, displayName),
    };
  }

  /**
   * PUBLIC — issues a fresh passcode and delivers it over the channel the
   * invite already used. Returns no secret to the caller, because this
   * endpoint is unauthenticated: anyone with the link could otherwise read
   * the code straight out of the response.
   */
  async resendOtp(token: string): Promise<{ ok: true; sent_to: string }> {
    const repo = this.dataSource.getRepository(MeetingInvite);
    const invite = await repo.findOne({
      where: { token },
      relations: ['meeting'],
    });
    if (!invite) throw new NotFoundException('This invite link is not valid');

    this.assertUsable(invite);

    if (invite.scope !== InviteScope.VERIFIED) {
      throw new BadRequestException('This invite does not use a passcode');
    }

    const otp = this.codeGenerator.otp();
    invite.otp_hash = this.hashOtp(otp, token);
    invite.otp_expires_at = new Date(Date.now() + OTP_TTL_MINUTES * 60 * 1000);
    invite.otp_attempts = 0;
    await repo.save(invite);

    const meeting = await this.loadMeeting(invite.meeting_id);

    if (invite.recipient_email) {
      await this.sendEmail(meeting, invite, otp, invite.created_by_id);
      return { ok: true, sent_to: this.mask(invite.recipient_email) };
    }

    // No email on file. The passcode is regenerated and waiting, but there is
    // no channel to push it down — the inviter must resend from the app.
    this.logger.warn(
      `resendOtp: invite ${invite.id} has no email — inviter must resend`,
    );
    return { ok: true, sent_to: 'the person who invited you' };
  }

  private mask(email: string): string {
    const [user, domain] = email.split('@');
    if (!domain) return '***';
    const head = user.slice(0, 2);
    return `${head}${'*'.repeat(Math.max(1, user.length - 2))}@${domain}`;
  }

  private async loadMeeting(meetingId: number): Promise<Meeting> {
    const meeting = await this.dataSource
      .getRepository(Meeting)
      .createQueryBuilder('m')
      .leftJoinAndSelect('m.company', 'company')
      .leftJoinAndSelect('m.host_user', 'host_user')
      .leftJoinAndSelect('m.audit_row', 'audit_row')
      .leftJoinAndSelect('audit_row.standards', 'standards')
      .where('m.id = :id', { id: meetingId })
      .getOne();

    if (!meeting) throw new NotFoundException('Meeting not found');
    return meeting;
  }

  private assertUsable(invite: MeetingInvite): void {
    if (invite.status === InviteStatus.REVOKED) {
      throw new ForbiddenException('This invite has been revoked');
    }
    if (invite.expires_at < new Date()) {
      throw new GoneException('This invite link has expired');
    }
    if (invite.meeting?.status === MeetingStatus.CANCELLED) {
      throw new GoneException('This meeting was cancelled');
    }
  }
}