import {
  ConflictException,
  ForbiddenException,
  Injectable,
  Logger,
  NotFoundException,
  UnauthorizedException,
  BadRequestException,
} from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
import { JwtService } from '@nestjs/jwt';
import bcrypt from 'bcryptjs';
import { ConfigService } from '@nestjs/config';
import type { StringValue } from 'ms';
import { randomInt } from 'crypto';
import { Prisma, ShopStatus, UserRole } from '@prisma/client';
import { BootstrapSuperadminDto } from './dto/bootstrap-superadmin.dto';
import { PasswordLoginDto } from './dto/password-login.dto';
import { OtpRequestDto } from './dto/otp-request.dto';
import { OtpVerifyDto } from './dto/otp-verify.dto';
import { URLSearchParams } from 'url';
import { RegisterStoreDto } from './dto/register-store.dto';
import { RegisterStoreMeDto } from './dto/register-store-me.dto';
import { PasswordResetRequestDto } from './dto/password-reset-request.dto';
import { PasswordResetConfirmDto } from './dto/password-reset-confirm.dto';
import { PasswordResetVerifyDto } from './dto/password-reset-verify.dto';
import { UpdateMyProfileDto } from '../users/dto/update-my-profile.dto';
import { UpdateMyLocationDto } from '../users/dto/update-my-location.dto';
import { WalletService } from '../wallet/wallet.service';
import { MailService } from '../mail/mail.service';

type TokenResponse = {
  accessToken: string;
  tokenType: 'Bearer';
  expiresIn: string;
};

@Injectable()
export class AuthService {
  private readonly logger = new Logger(AuthService.name);
  constructor(
    private readonly prisma: PrismaService,
    private readonly jwt: JwtService,
    private readonly config: ConfigService,
    private readonly wallet: WalletService,
    private readonly mail: MailService,
  ) {}

  private queueStoreRegistrationEmail(args: {
    to?: string | null;
    shopName: string;
    merchantEmail: string;
    merchantPhone?: string | null;
    planCode?: string | null;
    portalUrl?: string | null;
  }) {
    if (!args.to) return;
    void this.mail
      .sendStoreRegistrationReceived({
        to: args.to,
        shopName: args.shopName,
        merchantEmail: args.merchantEmail,
        merchantPhone: args.merchantPhone,
        planCode: args.planCode,
        portalUrl:
          args.portalUrl ||
          this.config.get<string>('mail.merchantPortalUrl') ||
          this.config.get<string>('payments.publicUrl') ||
          null,
      })
      .catch((error: unknown) => {
        const message =
          error instanceof Error ? error.message : 'unknown mailer error';
        this.logger.warn(`Failed to send store registration email: ${message}`);
      });
  }

  private async sendMetareachOtp(args: { phone: string; code: string }) {
    const baseUrl = String(
      this.config.get<string>('sms.metareach.baseUrl') ?? '',
    ).trim();
    const apiKey = String(
      this.config.get<string>('sms.metareach.apiKey') ?? '',
    ).trim();
    const senderId = String(
      this.config.get<string>('sms.metareach.senderId') ?? '',
    ).trim();
    const templateId = String(
      this.config.get<string>('sms.metareach.templateId') ?? '',
    ).trim();

    if (!baseUrl || !apiKey || !senderId || !templateId) {
      this.logger.warn(
        'MetaReach OTP not configured (missing METAREACH_SMS_* env vars). Skipping SMS send.',
      );
      return;
    }

    const digits = args.phone.replace(/\D/g, '');
    const number =
      digits.length === 12 && digits.startsWith('91')
        ? digits.slice(2)
        : digits; // provider expects local 10-digit
    const qs = new URLSearchParams({
      apikey: apiKey,
      senderid: senderId,
      templateid: templateId,
      number,
      message: args.code,
    });
    const url = `${baseUrl}?${qs.toString()}`;
    const safeNumber =
      number.length >= 4
        ? `${number.slice(0, 2)}******${number.slice(-2)}`
        : '****';

    const started = Date.now();
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), 2000);
    try {
      this.logger.log(
        `MetaReach OTP send start: number=${safeNumber} template=${templateId}`,
      );
      const res = await fetch(url, {
        method: 'GET',
        signal: controller.signal,
      });
      const text = await res.text().catch(() => '');
      const ms = Date.now() - started;
      this.logger.log(
        `MetaReach OTP send done: status=${res.status} ok=${res.ok} ms=${ms} body=${text.slice(0, 220)}`,
      );
    } catch (e: any) {
      const ms = Date.now() - started;
      const isAbort = String(e?.name ?? '')
        .toLowerCase()
        .includes('abort');
      this.logger.warn(
        `MetaReach OTP send failed: ${isAbort ? 'timeout' : (e?.message ?? 'unknown error')} ms=${ms}`,
      );
    } finally {
      clearTimeout(timeout);
    }
  }

  private async allocateReferralCode(): Promise<string> {
    const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
    for (let attempt = 0; attempt < 32; attempt++) {
      let segment = '';
      for (let i = 0; i < 6; i++) {
        segment += chars[randomInt(0, chars.length)];
      }
      const code = `CASHI-${segment}`;
      const clash = await this.prisma.user.findUnique({
        where: { referralCode: code },
        select: { id: true },
      });
      if (!clash) return code;
    }
    throw new ConflictException('Could not allocate referral code');
  }

  /** DB unique on referralCode — retry allocation if two requests draw the same code. */
  private isReferralUniqueViolation(e: unknown): boolean {
    if (
      !(e instanceof Prisma.PrismaClientKnownRequestError) ||
      e.code !== 'P2002'
    ) {
      return false;
    }
    const target = (e.meta as { target?: unknown } | undefined)?.target;
    return Array.isArray(target) && target.includes('referralCode');
  }

  /** Ensures any user row has a shareable referralCode (idempotent). */
  private async ensureReferralCode(userId: string): Promise<string | null> {
    const u = await this.prisma.user.findUnique({
      where: { id: userId },
      select: { referralCode: true },
    });
    if (!u) return null;
    if (u.referralCode) return u.referralCode;
    for (let tries = 0; tries < 8; tries++) {
      const code = await this.allocateReferralCode();
      try {
        const updated = await this.prisma.user.update({
          where: { id: userId },
          data: { referralCode: code },
          select: { referralCode: true },
        });
        return updated.referralCode;
      } catch (e) {
        // P2002 = unique violation; meta.target shape varies by DB — any conflict here is a code clash.
        if (
          e instanceof Prisma.PrismaClientKnownRequestError &&
          e.code === 'P2002'
        )
          continue;
        throw e;
      }
    }
    throw new ConflictException('Could not assign a unique referral code');
  }

  /**
   * Ensures all app users always get `referralCode` on /auth/me.
   * Uses `sub` if `id` is missing (defensive); normalizes role string for enum/JSON edge cases.
   */
  async finalizeMeResponse(user: Record<string, unknown>) {
    if (!user || typeof user !== 'object') return user;
    const rawId =
      (user as { id?: unknown; sub?: unknown }).id ??
      (user as { sub?: unknown }).sub;
    const userId = typeof rawId === 'string' ? rawId : null;
    if (!userId) return user;

    const code = await this.ensureReferralCode(userId);
    return {
      ...user,
      id: userId,
      referralCode:
        code ?? (user as { referralCode?: string | null }).referralCode,
    };
  }

  private async reactivateUserIfNeeded(userId: string) {
    const existing = await this.prisma.user.findUnique({
      where: { id: userId },
      select: { id: true, isActive: true },
    });
    if (!existing) throw new UnauthorizedException('User not found');
    if (existing.isActive) return;
    await this.prisma.user.update({
      where: { id: userId },
      data: { isActive: true, deactivatedAt: null },
      select: { id: true },
    });
  }

  async bootstrapSuperadmin(
    dto: BootstrapSuperadminDto,
  ): Promise<TokenResponse> {
    const existing = await this.prisma.user.findFirst({
      where: { role: UserRole.SUPERADMIN },
      select: { id: true },
    });
    if (existing) throw new ConflictException('Superadmin already exists');

    const user = await this.prisma.user.create({
      data: {
        role: UserRole.SUPERADMIN,
        email: dto.email.toLowerCase(),
        phone: dto.phone,
        passwordHash: await bcrypt.hash(dto.password, 12),
      },
      select: {
        id: true,
        email: true,
        role: true,
        shopId: true,
        tokenVersion: true,
      },
    });

    return this.issueToken(user);
  }

  async loginWithPassword(dto: PasswordLoginDto): Promise<TokenResponse> {
    const user = await this.prisma.user.findUnique({
      where: { email: dto.email.toLowerCase() },
      select: {
        id: true,
        email: true,
        role: true,
        shopId: true,
        isActive: true,
        tokenVersion: true,
        passwordHash: true,
      },
    });
    if (!user) throw new UnauthorizedException('Invalid credentials');
    if (!user.passwordHash)
      throw new ForbiddenException(
        'Password login is not enabled for this user',
      );

    // API-level: allow staff accounts to password-login.
    // Client apps can still restrict roles per-login using dto.roles.
    if (
      user.role !== UserRole.SUPERADMIN &&
      user.role !== UserRole.ADMIN &&
      user.role !== UserRole.SUBADMIN
    ) {
      throw new ForbiddenException('Password login is only for staff accounts');
    }

    if (Array.isArray(dto.roles) && dto.roles.length > 0) {
      if (!dto.roles.includes(user.role)) {
        throw new ForbiddenException('Role not allowed for this login');
      }
    }

    const ok = await bcrypt.compare(dto.password, user.passwordHash);
    if (!ok) throw new UnauthorizedException('Invalid credentials');
    if (!user.isActive) {
      await this.reactivateUserIfNeeded(user.id);
    }

    if (
      (user.role === UserRole.ADMIN || user.role === UserRole.SUBADMIN) &&
      user.shopId
    ) {
      const shop = await this.prisma.shop.findUnique({
        where: { id: user.shopId },
        select: { status: true, isActive: true },
      });
      if (!shop) throw new ForbiddenException('Shop not found for user');
      if (!shop.isActive) throw new ForbiddenException('Shop is inactive');
    }

    return this.issueToken(user);
  }

  async registerStore(dto: RegisterStoreDto, portalPublicUrl?: string | null) {
    const desiredPlanCode = String(dto.shop.planCode || '')
      .trim()
      .toUpperCase();
    const desiredPlan = await this.prisma.plan.findUnique({
      where: { code: desiredPlanCode },
      select: { id: true, code: true, priceMonthly: true },
    });
    if (!desiredPlan) {
      throw new NotFoundException(
        `Plan ${desiredPlanCode} not found. Create it first.`,
      );
    }
    const basic = await this.prisma.plan.findUnique({
      where: { code: 'BASIC' },
      select: { id: true, code: true },
    });
    if (!basic)
      throw new NotFoundException(
        'Plan BASIC not found. Seed default plans first.',
      );

    const email = dto.email.toLowerCase();

    const normalizeUsername = (input: string) => {
      let u = (input ?? '').trim();
      if (u.startsWith('@')) u = u.slice(1);
      u = u.toLowerCase();
      if (!/^[a-z0-9_.-]{3,30}$/.test(u)) {
        throw new BadRequestException('Invalid shop username');
      }
      return u;
    };

    try {
      const { admin, shop } = await this.prisma.$transaction(async (tx) => {
        const admin = await tx.user.create({
          data: {
            role: UserRole.ADMIN,
            email,
            phone: dto.phone.trim(),
            passwordHash: await bcrypt.hash(dto.password, 12),
            adminSubscription: {
              create: {
                planId: basic.id,
              },
            },
          },
          select: {
            id: true,
            email: true,
            role: true,
            phone: true,
            tokenVersion: true,
          },
        });

        const bt = await tx.businessType.findUnique({
          where: { id: dto.shop.businessTypeId },
          select: { id: true, imageUrl: true },
        });
        if (!bt) throw new NotFoundException('Business type not found');

        const shop = await tx.shop.create({
          data: {
            name: dto.shop.name,
            username: normalizeUsername(dto.shop.username),
            address: dto.shop.address,
            city: dto.shop.city,
            state: dto.shop.state,
            pincode: dto.shop.pincode,
            gstNo: dto.shop.gstNo,
            upiId: dto.shop.upiId?.trim() || null,
            businessTypeId: bt.id,
            imageUrl: dto.shop.imageUrl?.trim() || bt.imageUrl || null,
            latitude: dto.shop.latitude ?? null,
            longitude: dto.shop.longitude ?? null,
            adminId: admin.id,
            status: ShopStatus.PENDING,
          },
          select: {
            id: true,
            name: true,
            city: true,
            state: true,
            pincode: true,
            status: true,
            approvedAt: true,
            isActive: true,
          },
        });

        await tx.user.update({
          where: { id: admin.id },
          data: { shopId: shop.id },
          select: { id: true },
        });

        return { admin, shop };
      });

      const token = this.issueToken({
        id: admin.id,
        email: admin.email,
        role: admin.role,
        shopId: shop.id,
        tokenVersion: admin.tokenVersion,
      });

      const desiredPrice = Math.max(
        0,
        Math.floor(Number(desiredPlan.priceMonthly ?? 0)),
      );
      const needsCheckout = desiredPlan.code !== 'BASIC' && desiredPrice > 0;
      const checkout = needsCheckout
        ? await this.wallet.createPlanPurchaseCheckout({
            adminUserId: admin.id,
            shopId: shop.id,
            planCode: desiredPlan.code,
            amountToTopupInr: desiredPrice,
            portalPublicUrl,
          })
        : null;

      this.queueStoreRegistrationEmail({
        to: admin.email,
        shopName: shop.name,
        merchantEmail: admin.email ?? email,
        merchantPhone: admin.phone,
        planCode: desiredPlan.code,
        portalUrl: checkout?.checkoutUrl ?? portalPublicUrl ?? null,
      });

      return {
        ...token,
        pendingApproval: true,
        admin,
        shop,
        requestedPlanCode: desiredPlan.code,
        checkoutUrl: checkout?.checkoutUrl ?? null,
      };
    } catch (e) {
      if (
        e instanceof Prisma.PrismaClientKnownRequestError &&
        e.code === 'P2002'
      ) {
        throw new ConflictException(
          'Email, phone, or shop username already exists',
        );
      }
      throw e;
    }
  }

  async registerStoreForMe(
    userId: string,
    dto: RegisterStoreMeDto,
    portalPublicUrl?: string | null,
  ) {
    const existing = await this.prisma.user.findUnique({
      where: { id: userId },
      select: {
        id: true,
        role: true,
        phone: true,
        email: true,
        tokenVersion: true,
        shopId: true,
      },
    });
    if (!existing) throw new NotFoundException('User not found');

    if (
      existing.role === UserRole.SUPERADMIN ||
      existing.role === UserRole.ADMIN ||
      existing.role === UserRole.SUBADMIN
    ) {
      // Already a merchant user; just return a token response.
      return {
        ...this.issueToken({
          id: existing.id,
          email: existing.email,
          role: existing.role,
          shopId: existing.shopId,
          tokenVersion: existing.tokenVersion,
        }),
        pendingApproval: false,
      };
    }

    if (!existing.phone) {
      throw new BadRequestException('Phone is missing on user');
    }

    const desiredPlanCode = String(dto.shop.planCode || '')
      .trim()
      .toUpperCase();
    const desiredPlan = await this.prisma.plan.findUnique({
      where: { code: desiredPlanCode },
      select: { id: true, code: true, priceMonthly: true },
    });
    if (!desiredPlan) {
      throw new NotFoundException(
        `Plan ${desiredPlanCode} not found. Create it first.`,
      );
    }
    const basic = await this.prisma.plan.findUnique({
      where: { code: 'BASIC' },
      select: { id: true, code: true },
    });
    if (!basic)
      throw new NotFoundException(
        'Plan BASIC not found. Seed default plans first.',
      );

    const nextEmailRaw = String(dto.email ?? existing.email ?? '').trim();
    const email = nextEmailRaw ? nextEmailRaw.toLowerCase() : '';
    if (!email) {
      throw new BadRequestException('Email is required to register a store');
    }
    const password = String(dto.password ?? '').trim();
    if (!password) {
      throw new BadRequestException('Password is required to register a store');
    }

    const normalizeUsername = (input: string) => {
      let u = (input ?? '').trim();
      if (u.startsWith('@')) u = u.slice(1);
      u = u.toLowerCase();
      if (!/^[a-z0-9_.-]{3,30}$/.test(u)) {
        throw new BadRequestException('Invalid shop username');
      }
      return u;
    };

    try {
      const { admin, shop } = await this.prisma.$transaction(async (tx) => {
        const bt = await tx.businessType.findUnique({
          where: { id: dto.shop.businessTypeId },
          select: { id: true, imageUrl: true },
        });
        if (!bt) throw new NotFoundException('Business type not found');

        const shop = await tx.shop.create({
          data: {
            name: dto.shop.name,
            username: normalizeUsername(dto.shop.username),
            address: dto.shop.address,
            city: dto.shop.city,
            state: dto.shop.state,
            pincode: dto.shop.pincode,
            gstNo: dto.shop.gstNo,
            upiId: dto.shop.upiId?.trim() || null,
            businessTypeId: bt.id,
            imageUrl: dto.shop.imageUrl?.trim() || bt.imageUrl || null,
            latitude: dto.shop.latitude ?? null,
            longitude: dto.shop.longitude ?? null,
            adminId: existing.id,
            status: ShopStatus.PENDING,
          },
          select: {
            id: true,
            name: true,
            city: true,
            state: true,
            pincode: true,
            status: true,
            approvedAt: true,
            isActive: true,
          },
        });

        const admin = await tx.user.update({
          where: { id: existing.id },
          data: {
            role: UserRole.ADMIN,
            email,
            phone: existing.phone,
            passwordHash: await bcrypt.hash(password, 12),
            shopId: shop.id,
            adminSubscription: {
              upsert: {
                create: {
                  planId: basic.id,
                },
                update: {},
              },
            },
          },
          select: {
            id: true,
            email: true,
            role: true,
            phone: true,
            tokenVersion: true,
            shopId: true,
          },
        });

        return { admin, shop };
      });

      const token = this.issueToken({
        id: admin.id,
        email: admin.email,
        role: admin.role,
        shopId: admin.shopId,
        tokenVersion: admin.tokenVersion,
      });

      const desiredPrice = Math.max(
        0,
        Math.floor(Number(desiredPlan.priceMonthly ?? 0)),
      );
      const needsCheckout = desiredPlan.code !== 'BASIC' && desiredPrice > 0;
      const checkout = needsCheckout
        ? await this.wallet.createPlanPurchaseCheckout({
            adminUserId: admin.id,
            shopId: shop.id,
            planCode: desiredPlan.code,
            amountToTopupInr: desiredPrice,
            portalPublicUrl,
          })
        : null;

      this.queueStoreRegistrationEmail({
        to: admin.email,
        shopName: shop.name,
        merchantEmail: admin.email ?? email,
        merchantPhone: admin.phone,
        planCode: desiredPlan.code,
        portalUrl: checkout?.checkoutUrl ?? portalPublicUrl ?? null,
      });

      return {
        ...token,
        pendingApproval: true,
        admin,
        shop,
        requestedPlanCode: desiredPlan.code,
        checkoutUrl: checkout?.checkoutUrl ?? null,
      };
    } catch (e) {
      if (
        e instanceof Prisma.PrismaClientKnownRequestError &&
        e.code === 'P2002'
      ) {
        throw new ConflictException(
          'Email, phone, or shop username already exists',
        );
      }
      throw e;
    }
  }

  async registerStoreAvailable(args: { email?: string; phone?: string }) {
    const email = (args.email ?? '').toString().trim().toLowerCase();
    const phone = (args.phone ?? '').toString().trim();
    if (!email && !phone) {
      throw new BadRequestException('email or phone is required');
    }

    const [emailCount, phoneCount] = await Promise.all([
      email
        ? this.prisma.user.count({
            where: { email },
          })
        : Promise.resolve(0),
      phone
        ? this.prisma.user.count({
            where: { phone },
          })
        : Promise.resolve(0),
    ]);

    return {
      emailAvailable: email ? emailCount === 0 : true,
      phoneAvailable: phone ? phoneCount === 0 : true,
    };
  }

  async requestOtp(dto: OtpRequestDto) {
    const env =
      this.config.get<'development' | 'test' | 'production'>('app.env') ??
      'development';
    const phone = dto.phone.trim();

    // Mobile login flow: if the phone doesn't exist yet, create a USER record.
    // (Email/name can be filled later from the app registration details screen.)
    const existing = await this.prisma.user.findUnique({
      where: { phone },
      select: { id: true, role: true, isActive: true },
    });
    if (!existing) {
      for (let tries = 0; tries < 8; tries++) {
        try {
          const referralCode = await this.allocateReferralCode();
          await this.prisma.user.create({
            data: { role: UserRole.USER, phone, referralCode },
            select: { id: true },
          });
          break;
        } catch (e) {
          if (this.isReferralUniqueViolation(e)) continue;
          throw e;
        }
      }
    }

    const code = String(randomInt(0, 10_000)).padStart(4, '0');
    // Short-lived OTP: lower bcrypt cost keeps /auth/otp/request fast (compare still works).
    const codeHash = await bcrypt.hash(code, 8);
    const expiresAt = new Date(Date.now() + 5 * 60 * 1000);

    await this.prisma.otpCode.deleteMany({
      where: { phone, consumedAt: null },
    });
    await this.prisma.otpCode.create({
      data: { phone, codeHash, expiresAt },
      select: { id: true },
    });

    /**
     * Behavior requirement:
     * - production: send OTP via SMS provider, never return OTP to frontend
     * - development/test: do NOT call SMS provider, return OTP in response for easy testing
     */
    if (env === 'production') {
      // Fire-and-forget so OTP request is fast; log result async.
      void this.sendMetareachOtp({ phone, code });
      return { otpSent: true };
    }

    // Non-production: never call SMS provider; return devOtp for frontend display/autofill.
    return { otpSent: true, devOtp: code };
  }

  async verifyOtp(dto: OtpVerifyDto): Promise<TokenResponse> {
    const phone = dto.phone.trim();

    const record = await this.prisma.otpCode.findFirst({
      where: { phone, consumedAt: null, expiresAt: { gt: new Date() } },
      orderBy: { createdAt: 'desc' },
      select: { id: true, codeHash: true },
    });
    if (!record) throw new UnauthorizedException('Invalid or expired OTP');

    const ok = await bcrypt.compare(dto.code, record.codeHash);
    if (!ok) throw new UnauthorizedException('Invalid or expired OTP');

    await this.prisma.otpCode.update({
      where: { id: record.id },
      data: { consumedAt: new Date() },
      select: { id: true },
    });

    const user = await this.prisma.user.findUnique({
      where: { phone },
      select: {
        id: true,
        email: true,
        role: true,
        shopId: true,
        tokenVersion: true,
        isActive: true,
      },
    });
    if (!user) throw new UnauthorizedException('User not found');
    if (!user.isActive) {
      await this.reactivateUserIfNeeded(user.id);
    }

    return this.issueToken(user);
  }

  async logout(userId: string | undefined) {
    if (!userId) throw new UnauthorizedException('Invalid token');
    await this.prisma.user.update({
      where: { id: userId },
      data: { tokenVersion: { increment: 1 } },
      select: { id: true },
    });
    return { loggedOut: true };
  }

  /** Mobile onboarding / profile — any logged-in user (customer or staff on the consumer app). */
  async updateMyCustomerProfile(userId: string, dto: UpdateMyProfileDto) {
    await this.ensureReferralCode(userId);

    const row = await this.prisma.user.findUnique({
      where: { id: userId },
      select: {
        id: true,
        role: true,
        referredByUserId: true,
        referralCode: true,
      },
    });
    if (!row) throw new NotFoundException('User not found');

    const data: Prisma.UserUpdateInput = {};
    if (dto.name !== undefined) {
      const n = dto.name.trim();
      if (n.length < 2)
        throw new BadRequestException('Name must be at least 2 characters');
      data.name = n;
    }
    if (dto.email !== undefined) {
      const e = dto.email.trim();
      const taken = await this.prisma.user.findFirst({
        where: { email: e, NOT: { id: userId } },
        select: { id: true },
      });
      if (taken) throw new ConflictException('Email is already in use');
      data.email = e;
    }

    if (dto.referrerCode !== undefined && row.role === UserRole.USER) {
      const normalized = dto.referrerCode
        .trim()
        .toUpperCase()
        .replace(/\s+/g, '');
      if (normalized.length > 0) {
        if (row.referredByUserId) {
          throw new BadRequestException(
            'A referral code is already linked to your account',
          );
        }
        const myCode = row.referralCode?.toUpperCase() ?? '';
        if (!myCode)
          throw new BadRequestException('Referral setup incomplete; try again');
        if (normalized === myCode) {
          throw new BadRequestException(
            'You cannot use your own referral code',
          );
        }
        const refUser = await this.prisma.user.findUnique({
          where: { referralCode: normalized },
          select: { id: true },
        });
        if (!refUser) throw new BadRequestException('Invalid referral code');
        data.referredBy = { connect: { id: refUser.id } };
      }
    }

    const selectOut = {
      id: true,
      name: true,
      email: true,
      phone: true,
      role: true,
      referralCode: true,
      referredByUserId: true,
    } as const;

    if (Object.keys(data).length === 0) {
      return this.prisma.user.findUniqueOrThrow({
        where: { id: userId },
        select: selectOut,
      });
    }

    return this.prisma.user.update({
      where: { id: userId },
      data,
      select: selectOut,
    });
  }

  async updateMyCustomerLocation(userId: string, dto: UpdateMyLocationDto) {
    const user = await this.prisma.user.findUnique({
      where: { id: userId },
      select: { id: true },
    });
    if (!user) throw new NotFoundException('User not found');
    return this.prisma.user.update({
      where: { id: userId },
      data: {
        latitude: dto.latitude,
        longitude: dto.longitude,
        ...(dto.locationAddress !== undefined
          ? {
              locationAddress: dto.locationAddress.trim()
                ? dto.locationAddress.trim()
                : null,
            }
          : {}),
      },
      select: {
        id: true,
        latitude: true,
        longitude: true,
        locationAddress: true,
      },
    });
  }

  async requestPasswordReset(dto: PasswordResetRequestDto) {
    const email = dto.email.toLowerCase().trim();
    const user = await this.prisma.user.findUnique({
      where: { email },
      select: { id: true, email: true },
    });
    if (!user) throw new NotFoundException('User not found');

    // Invalidate previous tokens for this user (best-effort).
    await this.prisma.passwordResetToken.updateMany({
      where: { userId: user.id, usedAt: null, expiresAt: { gt: new Date() } },
      data: { usedAt: new Date() },
    });

    // 5 minutes validity.
    const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
    const raw = `pr_${randomInt(0, 1_000_000_000)}_${Date.now()}_${randomInt(0, 1_000_000_000)}`;
    const tokenHash = await bcrypt.hash(raw, 10);

    await this.prisma.passwordResetToken.create({
      data: {
        userId: user.id,
        tokenHash,
        expiresAt,
      },
      select: { id: true },
    });

    // No email for now: return reset url so UI can show it.
    const appBase =
      this.config.get<string>('dashboard.baseUrl') ?? 'http://localhost:8080';
    const resetUrl = `${appBase.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(raw)}`;
    return { requested: true, expiresAt, resetUrl };
  }

  async verifyPasswordReset(dto: PasswordResetVerifyDto) {
    const raw = dto.token.trim();
    if (!raw) throw new BadRequestException('token is required');

    const candidates = await this.prisma.passwordResetToken.findMany({
      where: { usedAt: null, expiresAt: { gt: new Date() } },
      orderBy: { createdAt: 'desc' },
      take: 25,
      select: { id: true, tokenHash: true, expiresAt: true },
    });

    for (const c of candidates) {
      const ok = await bcrypt.compare(raw, c.tokenHash);
      if (ok) return { valid: true, expiresAt: c.expiresAt };
    }
    return { valid: false };
  }

  async confirmPasswordReset(dto: PasswordResetConfirmDto) {
    const raw = dto.token.trim();
    if (!raw) throw new BadRequestException('token is required');

    const candidates = await this.prisma.passwordResetToken.findMany({
      where: { usedAt: null, expiresAt: { gt: new Date() } },
      orderBy: { createdAt: 'desc' },
      take: 25,
      select: { id: true, userId: true, tokenHash: true, expiresAt: true },
    });

    let matched: { id: string; userId: string; expiresAt: Date } | null = null;
    for (const c of candidates) {
      const ok = await bcrypt.compare(raw, c.tokenHash);
      if (ok) {
        matched = { id: c.id, userId: c.userId, expiresAt: c.expiresAt };
        break;
      }
    }

    if (!matched) throw new ForbiddenException('Link expired');

    await this.prisma.$transaction(async (tx) => {
      await tx.user.update({
        where: { id: matched.userId },
        data: {
          passwordHash: await bcrypt.hash(dto.newPassword, 12),
          tokenVersion: { increment: 1 },
        },
        select: { id: true },
      });
      await tx.passwordResetToken.update({
        where: { id: matched.id },
        data: { usedAt: new Date() },
        select: { id: true },
      });
    });

    return { updated: true };
  }

  private issueToken(user: {
    id: string;
    email: string | null;
    role: UserRole;
    shopId: string | null;
    tokenVersion: number;
  }): TokenResponse {
    const expiresIn = (this.config.get<string>('jwt.expiresIn') ??
      process.env.JWT_EXPIRES_IN ??
      '365d') as unknown as StringValue;

    const accessToken = this.jwt.sign(
      {
        email: user.email,
        role: user.role,
        shopId: user.shopId,
        ver: user.tokenVersion,
      },
      { subject: user.id, expiresIn },
    );

    return { accessToken, tokenType: 'Bearer', expiresIn };
  }
}
