import {
  Injectable,
  BadRequestException,
  UnauthorizedException,
  Logger,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import * as crypto from 'crypto';

import { ClientAccessToken } from '../entities/client-access-token.entity';
import { ClientOtpLog } from '../entities/client-otp-log.entity';
import { User } from '../../user/entities/user.entity';
import { UserService } from '../../user/user.service';
import { MailsService } from '../../mails/mails.service';
import { NotificationsService } from '../../notifications/notifications.service';
import { getErrorMessage } from '../../common/utils/error.helper';

// ✅ IMPORT EMAIL TEMPLATES
import { clientInviteEmailTemplate, clientOtpSmsTemplate, clientOtpEmailTemplate } from '../templates/client-email-templates';
import { Company } from '../../companies/entities/company.entity';
import { CompanyUser } from '../../companies/entities/company-user.entity';
import { ClientRefreshToken } from '../entities/client-refresh-token.entity';
// import { DataSource } from 'typeorm';
/**
 * CLIENT AUTH SERVICE
 * 
 * ✅ USES AUDIT-REQUESTS PATTERN FOR EMAIL SETTINGS
 * Sends emails from coordinator's configured account (database settings)
 */


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

  constructor(
    @InjectRepository(ClientAccessToken, 'scheme_dbs')
    private accessTokenRepository: Repository<ClientAccessToken>,
    @InjectRepository(ClientOtpLog, 'scheme_dbs')
    private otpLogRepository: Repository<ClientOtpLog>,
    @InjectRepository(ClientRefreshToken, 'scheme_dbs')
    private readonly refreshTokenRepository: Repository<ClientRefreshToken>,
    @InjectRepository(CompanyUser, 'scheme_dbs')
    private readonly companyUserRepository: Repository<CompanyUser>,
    @InjectRepository(Company, 'scheme_dbs')
    private companyRepository: Repository<Company>,
    @InjectRepository(User, 'scheme_dbs')
    private userRepository: Repository<User>,
    private readonly userService: UserService,
    private readonly jwtService: JwtService,
    private readonly mailsService: MailsService,
    private readonly notificationsService: NotificationsService,
    @InjectDataSource('scheme_dbs')
    private readonly dataSource: DataSource,

  ) { }

  /**
   * ═════════════════════════════════════════════════════════
   * HELPER METHODS
   * ═════════════════════════════════════════════════════════
   */

  /**
   * ✅ Helper: Send email using user's configured account (from database)
   * SAME PATTERN AS AUDIT-REQUESTS!
   */
  private async sendAsUserSafe(
    senderUserId: number | null | undefined,
    mail: {
      to: string;
      cc?: string;
      bcc?: string;
      subject: string;
      html: string;
      attachments?: any[];
      scheme?: 'QRS' | 'TQS';   // 👈 NEW — brand hint
    },
  ): Promise<void> {
    if (!senderUserId) return;

    await this.mailsService
      .sendAsUser(senderUserId, mail)  // ✅ Uses user's email settings from DB
      .catch((e) =>
        this.logger.warn(`[CLIENT-AUTH-MAIL] sendAsUser failed: ${e.message}`),
      );
  }
  /**
   * Maps scheme -> the explicit brand_key tag in email_settings.
   * This is the ONLY place these strings should live — never inline
   * elsewhere, so there's a single source of truth to update.
   */
  private static readonly BRAND_KEY_BY_SCHEME: Record<'QRS' | 'TQS', string> = {
    TQS: 'CLIENT_PORTAL_TQS',
    QRS: 'CLIENT_PORTAL_QRS',
  };

  /**
   * Returns the user_id that owns the mailbox for a given brand.
   *
   * ROBUSTNESS NOTE (post-incident fix):
   * Previously this picked the OLDEST email_settings row matching a
   * `scheme` column. That silently broke the moment a second mailbox
   * with that scheme existed with a lower id — QRS invites went out
   * from an unrelated staff mailbox instead of audit5@qrs.ae for
   * weeks with no error, no exception, nothing.
   *
   * Now it queries an explicit, unique `brand_key` tag instead of
   * inferring intent from row order. If zero or more-than-one row
   * carries that tag, we FAIL LOUDLY (return null + error log) rather
   * than guess — a missing/misconfigured mailbox should block the
   * send and page someone, not quietly mis-send.
   */
  private async resolveBrandSenderUserId(
    scheme: 'QRS' | 'TQS',
  ): Promise<number | null> {
    const brandKey = ClientAuthService.BRAND_KEY_BY_SCHEME[scheme];
    try {
      const rows = await this.dataSource.query(
        `SELECT user_id FROM email_settings
         WHERE brand_key = ? AND user_id IS NOT NULL`,
        [brandKey],
      );

      if (!rows || rows.length === 0) {
        this.logger.error(
          `[BRAND-SENDER] ❌ No email_settings row tagged brand_key=${brandKey} ` +
          `(scheme=${scheme}). Client-portal emails for this scheme are BLOCKED ` +
          `until a mailbox is tagged. Fix: UPDATE email_settings SET brand_key='${brandKey}' WHERE id=<mailbox_id>;`,
        );
        return null;
      }

      if (rows.length > 1) {
        // Should be structurally impossible thanks to the unique index
        // on brand_key, but if someone bypasses the migration / edits
        // prod directly, don't guess — refuse and alert.
        this.logger.error(
          `[BRAND-SENDER] ❌ MULTIPLE email_settings rows tagged brand_key=${brandKey}. ` +
          `This should be prevented by a unique index — check for a manual DB edit. ` +
          `Refusing to guess which mailbox to use.`,
        );
        return null;
      }

      return Number(rows[0].user_id);
    } catch (err: any) {
      this.logger.error(`[BRAND-SENDER] Lookup failed for brand_key=${brandKey}: ${err.message}`);
      return null;
    }
  }
  private generateAccessToken(): string {
    return crypto.randomBytes(32).toString('hex');
  }

  /**
 * Generate a secure refresh token.
 */
  private generateRefreshToken(): string {
    return crypto.randomBytes(64).toString('hex');
  }

  /**
   * Hash refresh token before storing it in database.
   */
  private hashRefreshToken(token: string): string {
    return crypto
      .createHash('sha256')
      .update(token)
      .digest('hex');
  }

  /**
   * Create JWT access token.
   */
  private generateJwtAccessToken(
    user: User,
    companyIds: number[],
    scheme: 'QRS' | 'TQS' = 'QRS',   // 👈 NEW param — defaults to QRS if not provided
  ): string {
    const jwtPayload = {
      email: user.email,
      sub: user.id,
      firstName: user.firstName,
      lastName: user.lastName,
      is_client: 1,
      company_ids: companyIds,
      scheme,                          // 👈 NEW — included in JWT so frontend picks the right logo
      roleNames: ['Client'],
      type: 'access',
    };

    return this.jwtService.sign(jwtPayload, {
      expiresIn: '15m',
    });
  }

  /**
   * Save refresh token.
   */
  private async createRefreshToken(
    userId: number,
    deviceName?: string,
    deviceType: string = 'unknown',
    ipAddress?: string | null,       // 👈 NEW
    userAgent?: string | null,       // 👈 NEW
  ): Promise<string> {
    const refreshToken = this.generateRefreshToken();

    const tokenHash = this.hashRefreshToken(refreshToken);

    const expiresAt = new Date();

    // Refresh token valid for 30 days
    expiresAt.setDate(expiresAt.getDate() + 30);

    const refreshTokenEntity =
      this.refreshTokenRepository.create({
        user_id: userId,
        token_hash: tokenHash,
        device_name: deviceName || null,
        device_type: deviceType,
        ip_address: ipAddress || null,   // 👈 NEW
        user_agent: userAgent || null,   // 👈 NEW
        expires_at: expiresAt,
        is_revoked: false,
      } as any);

    await this.refreshTokenRepository.save(
      refreshTokenEntity,
    );

    return refreshToken;
  }
  private generateOtp(): string {
    return Math.floor(10000 + Math.random() * 90000).toString();
  }

  private maskPhone(phone: string): string {
    if (!phone || phone.length < 6) return phone;
    const lastFour = phone.slice(-4);
    return `${phone.slice(0, 6)} *** ${lastFour}`;
  }

  /**
   * ═════════════════════════════════════════════════════════
   * 1️⃣ INVITE CLIENT TO PORTAL
   * ═════════════════════════════════════════════════════════
   */
  async inviteClient(
    companyId: number,
    clientEmail: string,
    clientPhone: string,
    createdByUserId: number,
  ): Promise<{
    token: string;
    link: string;
    message: string;
  }> {
    this.logger.log(
      `Inviting client: email=${clientEmail}, company_id=${companyId}, user=${createdByUserId}`,
    );

    try {
      // 1. Check if already invited
      const existing = await this.accessTokenRepository.findOne({
        where: {
          company_id: companyId,
          client_email: clientEmail,
        } as any,
      });

      if (existing && !this.isTokenExpired(existing.expires_at)) {
        this.logger.warn(
          `Client ${clientEmail} already has active invite for company ${companyId}`,
        );
        throw new BadRequestException(
          'Client already has an active invite. Check email/WhatsApp.',
        );
      }

      // 2. Create new access token
      const token = this.generateAccessToken();
      const expiresAt = new Date();
      expiresAt.setDate(expiresAt.getDate() + 7);

      const accessTokenData: any = {
        token,
        company_id: companyId,
        client_email: clientEmail,
        client_phone: clientPhone,
        created_by_user_id: createdByUserId,  // ✅ Store WHO created it
        is_used: 0,
        expires_at: expiresAt,
      };

      const accessToken = this.accessTokenRepository.create(accessTokenData);
      await this.accessTokenRepository.save(accessToken);
      this.logger.log(`✓ Access token created: ${token.slice(0, 8)}...`);

      // 3. Generate invite link ✅ Uses ENV variable (localhost vs production)
      const inviteLink = `${process.env.CLIENT_PORTAL_URL || 'http://localhost:3007'
        }/client/login?token=${token}`;

      this.logger.log(`✓ Invite link: ${inviteLink}`);

      // 4. Get company name from database for email template
      let companyName = 'Your Company';
      try {
        const company = await this.companyRepository.findOne({
          where: { id: companyId } as any,
        });
        if (company && company.name) {
          companyName = company.name;
        }
      } catch (err) {
        this.logger.warn(`Could not fetch company name: ${getErrorMessage(err)}`);
      }

      // 5. Send email using professional template ✅ CORRECTED!
      try {
        // Extract name from email (first part before @)
        const clientNameFromEmail = clientEmail.split('@')[0];

        // ✅ Use the professional email template from client-email-templates.ts
        const htmlContent = clientInviteEmailTemplate(
          clientNameFromEmail,
          companyName,
          inviteLink,
          7,  // Link expires in 7 days
        );

        // 👇 Resolve brand sender based on the CLIENT's company scheme.
        //    Fresh company lookup because the earlier `company` was scoped inside
        //    the previous try/catch block.
        const companyForScheme = await this.companyRepository.findOne({
          where: { id: companyId } as any,
        });
        const scheme: 'QRS' | 'TQS' =
          ((companyForScheme as any)?.scheme === 'TQS') ? 'TQS' : 'QRS';
        const brandSenderId = await this.resolveBrandSenderUserId(scheme);

        // 👇 STRICT — must have brand mailbox, no fallback allowed.
        //    TQS clients ONLY receive from audit3@iicc.ae
        //    QRS clients ONLY receive from audit5@qrs.ae
        if (!brandSenderId) {
          this.logger.error(
            `[INVITE] Missing brand mailbox for scheme=${scheme} — cannot send invite to ${clientEmail}`,
          );
          throw new BadRequestException(
            `Email cannot be sent right now. The ${scheme} brand mailbox is not configured. Please contact IT.`,
          );
        }

        // Send FROM the brand mailbox (audit3@iicc.ae for TQS, audit5@qrs.ae for QRS)
        await this.sendAsUserSafe(brandSenderId, {
          to: clientEmail,
          subject: 'You are invited to track your company audits',
          html: htmlContent,
          scheme,   // 👈 tells mails.service which mailbox to pick
        });

        this.logger.log(`✓ Professional email sent to ${clientEmail} from user ${createdByUserId}`);
      } catch (emailError) {
        // sendAsUserSafe() already has .catch() to handle failures gracefully
        this.logger.warn(`⚠️ Email send failed: ${getErrorMessage(emailError)}`);
        // Continue anyway - don't block the flow
      }

      // 6. Send WhatsApp (graceful fallback)
      try {
        if (
          this.notificationsService &&
          typeof (this.notificationsService as any).sendWhatsAppMessage === 'function'
        ) {
          await (this.notificationsService as any).sendWhatsAppMessage(
            clientPhone,
            `You're invited to track your company's audits!\n\nClick here: ${inviteLink}\n\nThis link expires in 7 days.`,
          );
          this.logger.log(`✓ WhatsApp sent to ${clientPhone}`);
        } else {
          this.logger.warn(`⚠️ sendWhatsAppMessage not implemented`);
        }
      } catch (whatsappError) {
        this.logger.warn(
          `⚠️ WhatsApp send failed: ${getErrorMessage(whatsappError)}`,
        );
      }

      return {
        token,
        link: inviteLink,
        message: 'Invite created. Email/WhatsApp may not be available.',
      };
    } catch (error) {
      this.logger.error(`❌ Invite failed: ${getErrorMessage(error)}`);
      throw error;
    }
  }

  /**
   * ═════════════════════════════════════════════════════════
   * 2️⃣ CLIENT LOGIN (Verify token, Send OTP)
   * ═════════════════════════════════════════════════════════
   */
  async clientLogin(email: string, token: string): Promise<{
    otp_sent: boolean;
    message: string;
    expires_in_minutes: number;
    email?: string;
  }> {
    this.logger.log(
      `Client login attempt: email=${email || '(from token)'}, token=${token?.slice(0, 8)}...`,
    );

    try {
      // 1. Find access token
      const accessToken = await this.accessTokenRepository.findOne({
        where: { token } as any,
      });

      if (!accessToken) {
        this.logger.warn(`Invalid token: ${token.slice(0, 8)}...`);
        throw new BadRequestException('Invalid or expired invite link');
      }

      // 2. Check if already used
      if (accessToken.is_used === 1) {
        this.logger.warn(`Token already used: ${token.slice(0, 8)}...`);
        throw new BadRequestException(
          'This invite link has already been used. Request a new one.',
        );
      }

      // 3. Check if expired
      if (this.isTokenExpired(accessToken.expires_at)) {
        this.logger.warn(`Token expired: ${token.slice(0, 8)}...`);
        throw new BadRequestException(
          'Invite link has expired (valid for 7 days)',
        );
      }

      // 4. Auto-resolve email from token if not provided, or verify match
      if (!email || !email.trim()) {
        email = accessToken.client_email;
        this.logger.log(`[TOKEN-LOGIN] Auto-resolved email from token: ${email}`);
      } else if (accessToken.client_email.toLowerCase() !== email.toLowerCase()) {
        this.logger.warn(
          `Email mismatch: token=${accessToken.client_email}, input=${email}`,
        );
        throw new BadRequestException(
          'Email does not match the invite. Check and try again.',
        );
      }

      // 5. Generate OTP
      const otpCode = this.generateOtp();
      const otpExpiresAt = new Date();
      otpExpiresAt.setMinutes(otpExpiresAt.getMinutes() + 10);

      // 🆕 SAFETY NET — prevent duplicate OTP within 30 seconds.
      //    Protects against frontend double-fire, user double-click,
      //    or accidental retry. If a recent OTP exists we silently
      //    return success without sending another email.
      try {
        const recentOtp = await this.otpLogRepository.findOne({
          where: { email: email.toLowerCase(), is_verified: 0 } as any,
          order: { id: 'DESC' } as any,
        });
        if (recentOtp) {
          const createdAt =
            (recentOtp as any).created_at ||
            new Date(new Date((recentOtp as any).expires_at).getTime() - 10 * 60 * 1000);
          const secondsSinceLast = (Date.now() - new Date(createdAt).getTime()) / 1000;
          if (secondsSinceLast < 30) {
            this.logger.warn(
              `[OTP-DEDUPE] Skipping duplicate OTP for ${email} (${Math.floor(secondsSinceLast)}s since last)`,
            );
            return {
              otp_sent: true,
              message: `OTP already sent to ${email}`,
              expires_in_minutes: 10,
              email,
            } as any;
          }
        }
      } catch (dedupeErr) {
        // If dedupe check itself fails, don't block the flow — just proceed to send
        this.logger.warn(`[OTP-DEDUPE] Check failed: ${getErrorMessage(dedupeErr)}`);
      }

      // 6. Clear old OTP logs
      try {
        await this.otpLogRepository.delete({
          email: email.toLowerCase(),
        } as any);
      } catch (err) {
        this.logger.warn(`Could not delete old OTP logs: ${getErrorMessage(err)}`);
      }

      // 7. Create new OTP log
      const otpData: any = {
        email: email.toLowerCase(),
        phone: accessToken.client_phone,
        otp_code: otpCode,
        attempts: 0,
        is_verified: 0,
        expires_at: otpExpiresAt,
      };

      const otpLog = this.otpLogRepository.create(otpData);
      await this.otpLogRepository.save(otpLog);
      this.logger.log(`✓ OTP generated: ${otpCode}`);

      // 8. Send OTP via EMAIL using BRAND-AWARE sender.
      //    TQS clients receive from audit3@iicc.ae
      //    QRS clients receive from audit5@qrs.ae
      try {
        // Look up the invited company's scheme
        const companyForScheme = await this.companyRepository.findOne({
          where: { id: accessToken.company_id } as any,
        });
        const scheme: 'QRS' | 'TQS' =
          ((companyForScheme as any)?.scheme === 'TQS') ? 'TQS' : 'QRS';
        const brandSenderId = await this.resolveBrandSenderUserId(scheme);

        if (!brandSenderId) {
          // Brand mailbox missing — dev fallback log so testing isn't blocked
          this.logger.error(
            `[OTP-LOGIN] Missing brand mailbox for scheme=${scheme} — OTP not sent to ${email}`,
          );
          if (process.env.NODE_ENV !== 'production') {
            this.logger.log(`📧 DEV MODE: OTP for testing: ${otpCode}`);
          }
        } else {
          // Build branded OTP email + send from the correct brand mailbox
          const otpHtml = clientOtpEmailTemplate(otpCode, 10);
          await this.sendAsUserSafe(brandSenderId, {
            to: email,
            subject: `Your Client Portal login code: ${otpCode}`,
            html: otpHtml,
            scheme,   // tells mails.service which mailbox (QRS vs TQS) to pick
          });
          this.logger.log(`✓ Email OTP sent to ${email} via ${scheme} brand mailbox`);
        }
      } catch (emailError) {
        this.logger.error(`❌ Email send failed: ${getErrorMessage(emailError)}`);
        if (process.env.NODE_ENV !== 'production') {
          this.logger.log(`📧 DEV MODE: OTP for testing: ${otpCode}`);
        }
      }

      return {
        otp_sent: true,
        message: `OTP sent to ${this.maskPhone(accessToken.client_phone)}`,
        expires_in_minutes: 10,
        email: email,
      };
    } catch (error) {
      this.logger.error(`❌ Login failed: ${getErrorMessage(error)}`);
      throw error;
    }
  }
  async loginWithEmail(email: string): Promise<{

    otp_sent: boolean;
    message: string;
    expires_in_minutes: number;
  }> {
    this.logger.log(`Client login with email: ${email}`);

    try {
      // 1. Find user by email
      const user = await this.userService.findByEmail(email);

      if (!user) {
        this.logger.warn(`User not found for email: ${email}`);
        // Check if there's a pending invite for this email
        const pendingInvite = await this.accessTokenRepository.findOne({
          where: { client_email: email, is_used: 0 } as any,
        });
        if (pendingInvite) {
          throw new BadRequestException(
            'Your invite is pending. Please click the "Access Client Portal" link in the invitation email sent to you. After your first login via the invite link, you can log in directly with your email.',
          );
        }
        throw new BadRequestException(
          'Email not found. Please ask your coordinator for an invite link.',
        );
      }

      // 2. CHECK IF USER IS CLIENT - Query company_users table!
      const companyUser = await this.companyUserRepository.findOne({
        where: {
          user_id: user.id,
          role: 'CLIENT',  // ← Checks if role is CLIENT
        } as any,
      });

      if (!companyUser) {
        this.logger.warn(`User ${email} is not registered as client`);
        throw new BadRequestException(
          'This user is not registered as a client.',
        );
      }

      // 🔧 PORTAL-FIX: Check if user has at least one ACTIVE company link.
      //    If ALL their links are DISABLED, block login immediately.
      const activeLink = await this.companyUserRepository.findOne({
        where: {
          user_id: user.id,
          role: 'CLIENT',
          status: 'ACTIVE',
        } as any,
      });
      if (!activeLink) {
        this.logger.warn(`User ${email} portal access is DISABLED — login blocked`);
        throw new BadRequestException(
          'Your portal access has been disabled. Please contact administrator.',
        );
      }

      // 3. Get phone number from user (with fallback to access token)
      let phone = user.phone_number;

      // ✅ Fallback: If user doesn't have phone, try to get from access token
      if (!phone) {
        this.logger.warn(`User ${email} has no phone_number, checking access token...`);
        const accessToken = await this.accessTokenRepository.findOne({
          where: {
            client_email: email.toLowerCase(),
          } as any,
        });
        if (accessToken && accessToken.client_phone) {
          phone = accessToken.client_phone;
          this.logger.log(`✓ Using phone from access token: ${this.maskPhone(phone)}`);
        }
      }

      if (!phone) {
        this.logger.warn(`No phone number found for user ${email}`);
        throw new BadRequestException(
          'No phone number on file. Please contact support.',
        );
      }

      // 4. Generate OTP
      const otpCode = this.generateOtp();
      const otpExpiresAt = new Date();
      otpExpiresAt.setMinutes(otpExpiresAt.getMinutes() + 10);

      // 🆕 SAFETY NET — prevent duplicate OTP within 30 seconds.
      try {
        const recentOtp = await this.otpLogRepository.findOne({
          where: { email: email.toLowerCase(), is_verified: 0 } as any,
          order: { id: 'DESC' } as any,
        });
        if (recentOtp) {
          const createdAt =
            (recentOtp as any).created_at ||
            new Date(new Date((recentOtp as any).expires_at).getTime() - 10 * 60 * 1000);
          const secondsSinceLast = (Date.now() - new Date(createdAt).getTime()) / 1000;
          if (secondsSinceLast < 30) {
            this.logger.warn(
              `[OTP-DEDUPE] Skipping duplicate OTP for ${email} (${Math.floor(secondsSinceLast)}s since last)`,
            );
            return {
              otp_sent: true,
              message: `OTP already sent to ${email}`,
              expires_in_minutes: 10,
            } as any;
          }
        }
      } catch (dedupeErr) {
        this.logger.warn(`[OTP-DEDUPE] Check failed: ${getErrorMessage(dedupeErr)}`);
      }

      // 5. Clear old OTP logs
      try {
        await this.otpLogRepository.delete({
          email: email.toLowerCase(),
        } as any);
      } catch (err) {
        this.logger.warn(`Could not delete old OTP logs: ${getErrorMessage(err)}`);
      }

      // 6. Create new OTP log
      const otpData: any = {
        email: email.toLowerCase(),
        phone,
        otp_code: otpCode,
        attempts: 0,
        is_verified: 0,
        expires_at: otpExpiresAt,
      };

      const otpLog = this.otpLogRepository.create(otpData);
      await this.otpLogRepository.save(otpLog);
      this.logger.log(`✓ OTP generated for ${email}: ${otpCode}`);

      // 7. Send OTP via EMAIL using BRAND-AWARE sender.
      //    `activeLink` is guaranteed to be ACTIVE (see check above),
      //    so we use its company_id to pick the right brand mailbox.
      try {
        const companyForScheme = await this.companyRepository.findOne({
          where: { id: (activeLink as any).company_id } as any,
        });
        const scheme: 'QRS' | 'TQS' =
          ((companyForScheme as any)?.scheme === 'TQS') ? 'TQS' : 'QRS';
        const brandSenderId = await this.resolveBrandSenderUserId(scheme);

        if (!brandSenderId) {
          this.logger.error(
            `[OTP-LOGIN] Missing brand mailbox for scheme=${scheme} — OTP not sent to ${email}`,
          );
          if (process.env.NODE_ENV !== 'production') {
            this.logger.log(`📧 DEV MODE: OTP for testing: ${otpCode}`);
          }
        } else {
          const otpHtml = clientOtpEmailTemplate(otpCode, 10);
          await this.sendAsUserSafe(brandSenderId, {
            to: email,
            subject: `Your Client Portal login code: ${otpCode}`,
            html: otpHtml,
            scheme,   // tells mails.service which mailbox (QRS vs TQS) to pick
          });
          this.logger.log(`✓ Email OTP sent to ${email} via ${scheme} brand mailbox`);
        }
      } catch (emailError) {
        this.logger.error(`❌ Email send failed: ${getErrorMessage(emailError)}`);
        if (process.env.NODE_ENV !== 'production') {
          this.logger.log(`📧 DEV MODE: OTP for testing: ${otpCode}`);
        }
      }

      return {
        otp_sent: true,
        message: `OTP sent to ${this.maskPhone(phone)}`,
        expires_in_minutes: 10,
      };
    } catch (error) {
      this.logger.error(`❌ Login with email failed: ${getErrorMessage(error)}`);
      throw error;
    }
  }
  /**
   * ═════════════════════════════════════════════════════════
   * 3️⃣ VERIFY OTP (Create User if new, Generate JWT)
   * ═════════════════════════════════════════════════════════
   */
  async verifyOtp(
    email: string,
    otpCode: string,
    deviceName?: string,
    deviceType: string = 'web',
    ipAddress?: string | null,       // 👈 NEW
    userAgent?: string | null,       // 👈 NEW
  ): Promise<{
    access_token: string;
    refresh_token: string;
    token_type: string;
    expires_in: number;
    user: {
      id: number;
      email: string;
      firstName: string;
      lastName: string;
    };
    company_ids: number[];
    message: string;
  }> {
    this.logger.log(`Verifying OTP: email=${email}, code=${otpCode}`);

    return this.dataSource.transaction(async (manager) => {
      // 1. Find OTP log WITH a row lock.
      const otpLog = await manager.findOne(ClientOtpLog, {
        where: { email: email.toLowerCase() } as any,
        lock: { mode: 'pessimistic_write' },
      });

      if (!otpLog) {
        throw new BadRequestException(
          'No OTP found for this email. Request new login.',
        );
      }

      if (otpLog.is_verified === 1) {
        throw new BadRequestException('OTP already used. Request a new one.');
      }

      if (this.isTokenExpired(otpLog.expires_at)) {
        throw new UnauthorizedException('OTP expired. Request a new one.');
      }

      if (otpLog.attempts >= 5) {
        throw new UnauthorizedException(
          'Too many incorrect attempts. Request a new OTP.',
        );
      }

      if (otpLog.otp_code !== otpCode) {
        await manager.update(ClientOtpLog, otpLog.id, {
          attempts: (otpLog.attempts || 0) + 1,
        } as any);
        throw new UnauthorizedException('Incorrect OTP code.');
      }

      this.logger.log(`✓ OTP code correct: ${email}`);

      // 2. Find or create the client user.
      let user = await this.userService.findByEmail(email);

      if (!user) {
        this.logger.log(`Creating new client user: ${email}`);
        const [namesPart] = email.split('@');
        const names = namesPart.split('.').map((n) =>
          n.charAt(0).toUpperCase() + n.slice(1),
        );
        const firstName = names[0] || 'Client';
        const lastName = names[1] || 'User';

        try {
          user = await this.userService.create({
            email,
            firstName,
            lastName,
            password: crypto.randomBytes(16).toString('hex'),
          } as any);
          this.logger.log(`✓ Client user created: ${user.id}`);
        } catch (createErr) {
          this.logger.error(`Failed to create user: ${getErrorMessage(createErr)}`);
          throw new BadRequestException(
            'Failed to create user account. Please try again.',
          );
        }
      }

      // 3. Bootstrap any pending, unused invite into company_users - this
      //    runs on EVERY login, not just the first one, so a client who
      //    gets invited to a second company later actually picks it up
      //    instead of being stuck on whichever company they logged into
      //    first, forever.
      const accessToken = await manager.findOne(ClientAccessToken, {
        where: { client_email: email.toLowerCase(), is_used: 0 } as any,
      });

      if (accessToken) {
        await manager.update(ClientAccessToken, accessToken.id, {
          is_used: 1,
          used_at: new Date(),
        } as any);

        const alreadyLinked = await manager.findOne(CompanyUser, {
          where: { company_id: accessToken.company_id, user_id: user.id } as any,
        });

        if (!alreadyLinked) {
          const link = manager.create(CompanyUser, {
            company_id: accessToken.company_id,
            user_id: user.id,
            role: 'CLIENT',
            status: 'ACTIVE',           // 🔧 PORTAL-FIX: explicitly set ACTIVE on new links
          } as any);
          await manager.save(link);
          this.logger.log(`✓ Bootstrapped new company link: ${accessToken.company_id}`);
        } else if ((alreadyLinked as any).status === 'DISABLED') {
          // 🔧 PORTAL-FIX: If admin disabled this link but a new invite was sent,
          //    DON'T silently re-enable — the admin made a deliberate choice.
          this.logger.warn(
            `⚠️ Company link ${accessToken.company_id} exists but is DISABLED — not re-enabling`,
          );
        }
      }

      // 4. Gather ALL company links this user has, most recently linked
      //    first. Every controller endpoint today uses companyIds[0] as
      //    "the" company - ordering by newest link first means a client
      //    who was just invited to a new company sees THAT one by
      //    default, without needing any controller changes today.
      const allLinks = await manager.find(CompanyUser, {
        where: { user_id: user.id, role: 'CLIENT' } as any,
        order: { created_at: 'DESC' } as any,
      });
      // 🔧 PORTAL-FIX: Only include ACTIVE company links in the JWT.
      //    DISABLED links are excluded — the client won't see those companies.
      //    If ALL links are DISABLED, block login entirely.
      const activeLinks = allLinks.filter((cu) => (cu as any).status !== 'DISABLED');
      const companyIds: number[] = activeLinks.map((cu) => cu.company_id);

      if (companyIds.length === 0) {
        this.logger.warn(`⚠️ User ${email} has no active company access (all DISABLED or none)`);
        throw new BadRequestException(
          'Your portal access has been disabled. Please contact your coordinator.',
        );
      }

      // 5. Only now mark the OTP as verified.
      await manager.update(ClientOtpLog, otpLog.id, {
        is_verified: 1,
        verified_at: new Date(),
      } as any);
      this.logger.log(`✓ OTP verified: ${email}`);

      // 6. 👇 Resolve brand scheme from the client's first company.
      //    Uses raw SQL to bypass any TypeORM entity mapping issues —
      //    always returns the scheme column even if it's not on the entity.
      const schemeRow = await manager.query(
        `SELECT scheme FROM companies WHERE id = ? LIMIT 1`,
        [companyIds[0]],
      );
      const scheme: 'QRS' | 'TQS' =
        (schemeRow?.[0]?.scheme === 'TQS') ? 'TQS' : 'QRS';
      this.logger.log(`✓ Scheme resolved: ${scheme} (company ${companyIds[0]})`);

      // 7. Generate short-lived JWT access token (now includes scheme).
      const jwtToken = this.generateJwtAccessToken(
        user,
        companyIds,
        scheme,
      );

      this.logger.log(
        `✓ Access token generated for user ${user.id}`,
      );

      // 8. Generate long-lived refresh token.
      const refreshToken = await this.createRefreshToken(
        user.id,
        deviceName,
        deviceType,
        ipAddress,
        userAgent,
      );

      this.logger.log(
        `✓ Refresh token created for user ${user.id}`,
      );
      return {
        access_token: jwtToken,

        refresh_token: refreshToken,

        token_type: 'Bearer',

        // Access token expiry in seconds
        expires_in: 15 * 60,

        user: {
          id: user.id,
          email: user.email,
          firstName: user.firstName,
          lastName: user.lastName,
        },

        company_ids: companyIds,

        message: `Welcome ${user.firstName}! You're now logged in.`,
      };
    });
  }
  /**
 * ═════════════════════════════════════════════════════════
 * REFRESH CLIENT ACCESS TOKEN
 * ═════════════════════════════════════════════════════════
 *
 * Mobile flow:
 *
 * Face ID / Fingerprint
 *        ↓
 * Mobile gets securely stored refresh token
 *        ↓
 * This API generates a new access token
 */
  async refreshAccessToken(
    refreshToken: string,
  ): Promise<{
    access_token: string;
    token_type: string;
    expires_in: number;
  }> {

    this.logger.log('Refreshing client access token');

    // 1. Hash the received refresh token
    const tokenHash =
      this.hashRefreshToken(refreshToken);

    // 2. Find token in database
    const storedToken =
      await this.refreshTokenRepository.findOne({
        where: {
          token_hash: tokenHash,
        } as any,
      });

    // 3. Validate token
    if (!storedToken) {
      throw new UnauthorizedException(
        'Invalid refresh token.',
      );
    }

    // 4. Check if revoked
    if (storedToken.is_revoked) {
      throw new UnauthorizedException(
        'Refresh token has been revoked.',
      );
    }

    // 5. Check expiry
    if (this.isTokenExpired(storedToken.expires_at)) {
      throw new UnauthorizedException(
        'Refresh token has expired. Please login again.',
      );
    }

    // 6. Get user
    const user =
      await this.userRepository.findOne({
        where: {
          id: storedToken.user_id,
        } as any,
      });

    if (!user) {
      throw new UnauthorizedException(
        'User no longer exists.',
      );
    }

    // 7. Get all company links
    const allLinks =
      await this.companyUserRepository.find({
        where: {
          user_id: user.id,
          role: 'CLIENT',
        } as any,

        order: {
          created_at: 'DESC',
        } as any,
      });

    // 8. Only allow ACTIVE companies
    const activeLinks =
      allLinks.filter(
        (companyUser) =>
          (companyUser as any).status !== 'DISABLED',
      );

    const companyIds =
      activeLinks.map(
        (companyUser) =>
          companyUser.company_id,
      );

    // 9. Check company access
    if (companyIds.length === 0) {
      throw new UnauthorizedException(
        'Your portal access has been disabled.',
      );
    }

    // 10. 👇 Resolve brand scheme from the client's first company (raw SQL).
    //         Same pattern as verifyOtp — bypasses entity mapping issues.
    const schemeRow = await this.dataSource.query(
      `SELECT scheme FROM companies WHERE id = ? LIMIT 1`,
      [companyIds[0]],
    );
    const scheme: 'QRS' | 'TQS' =
      (schemeRow?.[0]?.scheme === 'TQS') ? 'TQS' : 'QRS';

    // 11. Generate new access token (with scheme).
    const accessToken =
      this.generateJwtAccessToken(
        user,
        companyIds,
        scheme,
      );

    this.logger.log(
      `✓ Access token refreshed for user ${user.id} (scheme=${scheme})`,
    );

    // 11. Return new access token
    return {
      access_token: accessToken,
      token_type: 'Bearer',
      expires_in: 15 * 60,
    };
  }
  /**
   * ═════════════════════════════════════════════════════════
   * CLIENT LOGOUT
   * ═════════════════════════════════════════════════════════
   */
  async logout(
    refreshToken: string,
  ): Promise<{
    success: boolean;
    message: string;
  }> {

    this.logger.log('Client logout requested');

    // Hash the refresh token
    const tokenHash =
      this.hashRefreshToken(refreshToken);

    // Find token
    const storedToken =
      await this.refreshTokenRepository.findOne({
        where: {
          token_hash: tokenHash,
        } as any,
      });

    // For security, don't reveal whether token exists
    if (!storedToken) {
      return {
        success: true,
        message: 'Logged out successfully.',
      };
    }

    // Revoke refresh token
    await this.refreshTokenRepository.update(
      storedToken.id,
      {
        is_revoked: true,
        revoked_at: new Date(),
      } as any,
    );

    this.logger.log(
      `✓ Refresh token revoked for user ${storedToken.user_id}`,
    );

    return {
      success: true,
      message: 'Logged out successfully.',
    };
  }

  /**
   * ═════════════════════════════════════════════════════════
   * 4️⃣ RESEND OTP
   * ═════════════════════════════════════════════════════════
   */
  async resendOtp(email: string, token: string): Promise<{
    otp_sent: boolean;
    message: string;
  }> {
    this.logger.log(`Resending OTP: email=${email}`);

    try {
      const accessToken = await this.accessTokenRepository.findOne({
        where: { token } as any,
      });

      if (
        !accessToken ||
        accessToken.is_used === 1 ||
        this.isTokenExpired(accessToken.expires_at)
      ) {
        throw new BadRequestException('Invalid or expired invite link');
      }

      const otpCode = this.generateOtp();
      const otpExpiresAt = new Date();
      otpExpiresAt.setMinutes(otpExpiresAt.getMinutes() + 10);

      try {
        await this.otpLogRepository.delete({
          email: email.toLowerCase(),
        } as any);
      } catch (err) {
        this.logger.warn(`Could not delete old OTP`);
      }

      const otpData: any = {
        email: email.toLowerCase(),
        phone: accessToken.client_phone,
        otp_code: otpCode,
        attempts: 0,
        is_verified: 0,
        expires_at: otpExpiresAt,
      };

      const otpLog = this.otpLogRepository.create(otpData);
      await this.otpLogRepository.save(otpLog);

      try {
        if (
          this.notificationsService &&
          typeof (this.notificationsService as any).sendSmsOtp === 'function'
        ) {
          // ✅ Use professional SMS template for resend
          const smsContent = clientOtpSmsTemplate(otpCode, 10);

          await (this.notificationsService as any).sendSmsOtp(
            accessToken.client_phone,
            otpCode,
            10,
          );
          this.logger.log(`✓ OTP resent to ${accessToken.client_phone}`);
        } else {
          this.logger.warn(`⚠️ sendSmsOtp not available`);
          this.logger.log(`📱 Test OTP: ${otpCode}`);
        }
      } catch (smsError) {
        this.logger.warn(`SMS send failed: ${getErrorMessage(smsError)}`);
      }

      return {
        otp_sent: true,
        message: `New OTP sent to ${this.maskPhone(accessToken.client_phone)}`,
      };
    } catch (error) {
      this.logger.error(`❌ Resend OTP failed: ${getErrorMessage(error)}`);
      throw error;
    }
  }

  /**
   * ═════════════════════════════════════════════════════════
   * UTILITY: Check if token/OTP is expired
   * ═════════════════════════════════════════════════════════
   */
  private isTokenExpired(expiresAt: Date | null | undefined): boolean {
    if (!expiresAt) return true;
    const now = new Date();
    const expDate = new Date(expiresAt);
    return now > expDate;
  }
  /**
   * ═════════════════════════════════════════════════════════
   * 5️⃣ PORTAL STATUS CHECK (for internal Companies module)
   * ═════════════════════════════════════════════════════════
   */
  async getPortalStatus(companyId: number): Promise<{
    hasAccess: boolean;
    linkedEmails: string[];
  }> {
    const links = await this.companyUserRepository.find({
      where: { company_id: companyId, role: 'CLIENT' } as any,
    });

    if (!links.length) {
      return { hasAccess: false, linkedEmails: [] };
    }

    const emails: string[] = [];
    for (const link of links) {
      const linkedUser = await this.userRepository.findOne({
        where: { id: link.user_id } as any,
      });
      if (linkedUser?.email) emails.push(linkedUser.email);
    }

    return { hasAccess: emails.length > 0, linkedEmails: emails };
  }
  async inviteTeamMember(
    companyId: number,
    invitedEmail: string,
    invitedByUserId: number,
  ): Promise<{ token: string; link: string; message: string }> {
    this.logger.log(`Inviting team member: email=${invitedEmail}, company_id=${companyId}`);

    try {
      const normalizedEmail = invitedEmail.toLowerCase();

      const existingUser = await this.userService.findByEmail(normalizedEmail);
      if (existingUser) {
        const alreadyLinked = await this.companyUserRepository.findOne({
          where: { company_id: companyId, user_id: existingUser.id } as any,
        });
        if (alreadyLinked) {
          throw new BadRequestException('This person already has access to your company.');
        }
      }

      const existing = await this.accessTokenRepository.findOne({
        where: { company_id: companyId, client_email: normalizedEmail } as any,
      });
      if (existing && !this.isTokenExpired(existing.expires_at)) {
        throw new BadRequestException('An invite is already pending for this email.');
      }

      const token = this.generateAccessToken();
      const expiresAt = new Date();
      expiresAt.setDate(expiresAt.getDate() + 7);

      const accessTokenData: any = {
        token,
        company_id: companyId,
        client_email: normalizedEmail,
        client_phone: null,
        created_by_user_id: invitedByUserId,
        is_used: 0,
        expires_at: expiresAt,
      };
      const accessToken = this.accessTokenRepository.create(accessTokenData);
      await this.accessTokenRepository.save(accessToken);

      const inviteLink = `${process.env.CLIENT_PORTAL_URL || 'http://localhost:3007'}/client/login?token=${token}`;

      let companyName = 'your company';
      try {
        const company = await this.companyRepository.findOne({ where: { id: companyId } as any });
        if (company?.name) companyName = company.name;
      } catch (err) {
        this.logger.warn(`Could not fetch company name: ${getErrorMessage(err)}`);
      }

      // NOTE: sendGenericEmail is a guess at the real method name - I've only
      // confirmed sendEmailOtp/sendSmsOtp/sendWhatsAppMessage exist on this
      // service across this whole build. If this method doesn't actually
      // exist, this just logs a warning and the invite row still gets
      // created - the teammate just won't get an automated email until the
      // real method name is confirmed and swapped in here.
      const htmlContent = clientInviteEmailTemplate(normalizedEmail.split('@')[0], companyName, inviteLink, 7);

      // 👇 Resolve brand sender based on the company's scheme.
      //    Fresh company lookup because the earlier `company` was scoped inside
      //    the previous try/catch block.
      const companyForScheme = await this.companyRepository.findOne({
        where: { id: companyId } as any,
      });
      const scheme: 'QRS' | 'TQS' =
        ((companyForScheme as any)?.scheme === 'TQS') ? 'TQS' : 'QRS';
      const brandSenderId = await this.resolveBrandSenderUserId(scheme);

      // 👇 STRICT — must have brand mailbox, no fallback allowed.
      if (!brandSenderId) {
        this.logger.error(
          `[TEAM-INVITE] Missing brand mailbox for scheme=${scheme}`,
        );
        throw new BadRequestException(
          `Email cannot be sent right now. The ${scheme} brand mailbox is not configured. Please contact IT.`,
        );
      }

      await this.sendAsUserSafe(brandSenderId, {
        to: normalizedEmail,
        subject: "You've been invited to your company's client portal",
        html: htmlContent,
        scheme,   // 👈 tells mails.service which mailbox to pick
      });

      return { token, link: inviteLink, message: 'Invite sent.' };
    } catch (error) {
      this.logger.error(`❌ Team invite failed: ${getErrorMessage(error)}`);
      throw error;
    }
  }

  async getTeamMembers(companyId: number): Promise<{ members: any[]; pending: any[] }> {
    const links = await this.companyUserRepository.find({
      where: { company_id: companyId, role: 'CLIENT' } as any,
      order: { created_at: 'ASC' } as any,
    });

    const members: any[] = [];
    for (const link of links) {
      const user = await this.userRepository.findOne({ where: { id: link.user_id } as any });
      if (user) {
        members.push({
          id: user.id,
          firstName: user.firstName,
          lastName: user.lastName,
          email: user.email,
          joined_at: (link as any).created_at,
        });
      }
    }

    const invites = await this.accessTokenRepository.find({
      where: { company_id: companyId, is_used: 0 } as any,
    });
    const pending = invites
      .filter((inv) => !this.isTokenExpired(inv.expires_at))
      .map((inv) => ({ email: inv.client_email, expires_at: inv.expires_at }));

    return { members, pending };
  }

}