import { randomBytes } from "node:crypto";

import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { InjectDataSource, InjectRepository } from "@nestjs/typeorm";
import { DataSource, Repository } from "typeorm";

import { hashPassword, verifyPassword } from "@tria-plc/api-common/utils/argon";
import { EOtpType } from "@tria-plc/iamapi-common/enums/otp.enum";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { UserVerification } from "@tria-plc/iamapi-common/entities/iam/user/user-verification.entity";

import { OtpService, OtpTarget } from "../otp/otp.service";
import { ResetChannel } from "./dto/forgot-password.dto";
import { maskOtpTarget } from "./mask-target.util";

/**
 * How long the reset ticket minted for `PATCH /api/auth/set-password` stays
 * valid. The IAM `setPassword` handler enforces this via `expiresAt`.
 */
const RESET_TICKET_TTL_MS = 10 * 60 * 1000;

/** How long the emailed/SMS'd OTP stays valid before it must be re-requested. */
const RESET_OTP_TTL_MS = 10 * 60 * 1000;

/**
 * A staff-triggered reset link lives longer than a typed OTP: the customer may
 * only see the SMS/email hours after the call that prompted it.
 */
export const RESET_LINK_TTL_MS = 24 * 60 * 60 * 1000;

/** IAM refuses a ticket once its row hits this many failed attempts. */
const MAX_TICKET_ATTEMPTS = 5;

export interface ResetTicket {
  userId: string;
  verificationCode: string;
}

/**
 * What a valid reset link resolves to. `identifier` is the value IAM's
 * `set-password` matches the user on (it accepts email / username / phone), so
 * the portal can spend the ticket without the customer typing anything.
 */
export interface ResetLinkAccount {
  userId: string;
  identifier: string;
  maskedIdentifier: string;
  verificationCode: string;
}

@Injectable()
export class ForgotPasswordService {
  private readonly logger = new Logger(ForgotPasswordService.name);

  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
    @InjectDataSource()
    private readonly dataSource: DataSource,
    private readonly otpService: OtpService,
  ) {}

  /**
   * Resolve an account that is actually eligible for a password reset.
   *
   * IAM's `set-password` handler flips `isActive: true` on the user as a side
   * effect, so a reset on a deactivated account would silently resurrect it.
   * Gating here — rather than at the set-password call — is what keeps that
   * from being reachable. Mirrors IAM's own login lookup: match on any of
   * email / username / phone, and require an active credential row.
   */
  async resolveActiveUser(identifier: string): Promise<User | null> {
    const id = identifier.trim();
    if (!id) return null;

    return await this.activeUserQuery()
      .andWhere(
        "(LOWER(u.email) = LOWER(:id) OR u.username = :id OR u.phoneNumber = :id)",
        { id },
      )
      .getOne();
  }

  /** Same eligibility gate as {@link resolveActiveUser}, keyed by IAM user id. */
  async resolveActiveUserById(userId: string): Promise<User | null> {
    if (!userId) return null;
    return await this.activeUserQuery()
      .andWhere("u.id = :userId", { userId })
      .getOne();
  }

  /**
   * Base query for accounts eligible to reset. `.where()` is claimed here so
   * callers must use `.andWhere()` — TypeORM's `.where()` resets the clause,
   * which would silently drop the `isActive` gate.
   */
  private activeUserQuery() {
    return this.userRepository
      .createQueryBuilder("u")
      .innerJoin("u.userCredentials", "uc", "uc.isActive = true")
      .where("u.isActive = true")
      .orderBy("u.createdAt", "DESC");
  }

  /**
   * A single channel of the account, for flows that genuinely deliver over one
   * transport (the staff-triggered reset LINK picks email or SMS). Taken from
   * the account — never from input.
   */
  targetFor(user: User, channel: ResetChannel): OtpTarget | null {
    if (channel === ResetChannel.Email) {
      return user.email ? { email: user.email } : null;
    }
    return user.phoneNumber ? { phone: user.phoneNumber } : null;
  }

  /**
   * Every contact the account has. The reset OTP goes to all of them and any one
   * verifies it — a customer whose SMS never lands can finish from their inbox
   * without restarting the flow on a different channel. An account holding only
   * one of the two degrades to that channel; only a contactless account is null.
   */
  targetsFor(user: User): OtpTarget | null {
    const target: OtpTarget = {};
    if (user.email) target.email = user.email;
    if (user.phoneNumber) target.phone = user.phoneNumber;
    return target.email || target.phone ? target : null;
  }

  /**
   * The value IAM's `set-password` will match this account on. It looks the user
   * up by email OR username OR phoneNumber (and lowercases whatever it is
   * given), so prefer email, then phone, and fall back to username last —
   * a mixed-case username would not survive that lowercasing.
   */
  private identifierFor(user: User): string | null {
    return user.email ?? user.phoneNumber ?? user.username ?? null;
  }

  /**
   * Send one reset code to every contact on the account — email AND phone —
   * returning the target so authenticated (backoffice) callers can echo a masked
   * version; unauthenticated callers must discard it.
   *
   * Note: `otp_verifications` keys rows by a unique phone/email, and `sendOtp`
   * replaces every row the target overlaps. A reset request therefore overwrites
   * any pending signup code for the same addresses — last code sent wins. That
   * is the pre-existing behaviour between any two flows sharing this table.
   */
  async requestReset(user: User): Promise<OtpTarget | null> {
    const target = this.targetsFor(user);
    if (!target) return null;

    await this.otpService.sendOtp(target);
    return target;
  }

  /**
   * Prove possession of the OTP, then mint an IAM reset ticket the caller can
   * spend on the public `PATCH /api/auth/set-password`.
   *
   * Minting a `UserVerification` row rather than writing `UserCredential`
   * ourselves keeps IAM as the single owner of the password write path (old
   * credential deactivation, argon hashing, changed-at bookkeeping).
   */
  async verifyAndMintTicket(
    identifier: string,
    otp: string,
  ): Promise<ResetTicket> {
    const user = await this.resolveActiveUser(identifier);
    // Same set of contacts `requestReset` sent to, so the code resolves whichever
    // of the two the customer actually received it on.
    const target = user && this.targetsFor(user);

    if (!user?.id || !target) {
      // Same shape as a wrong code: a caller probing for accounts learns nothing
      // beyond what the request step already (deliberately) refuses to tell them.
      throw new BadRequestException("Invalid verification code");
    }

    await this.otpService.verifyOtpForAction(target, otp, RESET_OTP_TTL_MS);

    return await this.mintResetTicket(user.id, RESET_TICKET_TTL_MS);
  }

  /**
   * Mint a single-use IAM reset ticket. Shared by the OTP flow (where the code
   * is the proof of possession) and the staff-triggered link flow (where the
   * ticket travels in the link and delivery to the account's own inbox/handset
   * is the proof).
   */
  async mintResetTicket(userId: string, ttlMs: number): Promise<ResetTicket> {
    const code = randomBytes(24).toString("base64url");
    const verificationCode = await hashPassword(code);

    await this.dataSource.transaction(async (manager) => {
      const repo = manager.getRepository(UserVerification);
      // Retire any outstanding codes so only the ticket we just minted can be
      // spent — `findVerificationForPrimaryReset` reads the newest row.
      await repo.update({ userId }, { isUsed: true });
      await repo.insert({
        userId,
        otpType: EOtpType.RESET_PASSWORD,
        verificationCode,
        expiresAt: new Date(Date.now() + ttlMs),
        isUsed: false,
        attemptCount: 0,
      });
    });

    this.logger.log(`Reset ticket minted for user ${userId}`);
    return { userId, verificationCode: code };
  }

  /**
   * Validate a reset link and hand back everything the portal needs to spend it
   * on IAM's `PATCH /api/auth/set-password`.
   *
   * The checks mirror IAM's own — newest row, unused, unexpired, attempts left,
   * argon match — so a link that resolves here is one IAM will honour. Doing
   * them up front is what lets the page say "this link has expired" before the
   * customer types a password rather than after.
   *
   * Every rejection is the same message: a link is a bearer credential, and the
   * holder of a bad one learns nothing about why it failed or whether the user
   * id exists.
   */
  async resolveResetLink(
    userId: string,
    token: string,
  ): Promise<ResetLinkAccount> {
    const invalid = new BadRequestException(
      "This password-reset link is invalid or has expired. Request a new one.",
    );

    const user = await this.resolveActiveUserById(userId);
    const identifier = user && this.identifierFor(user);
    if (!user || !identifier) throw invalid;

    const verification = await this.dataSource
      .getRepository(UserVerification)
      .findOne({
        where: { userId, otpType: EOtpType.RESET_PASSWORD },
        order: { createdAt: "DESC" },
      });

    // `expiresAt` / `attemptCount` are optional on IAM's entity but always
    // written by `mintResetTicket`. A row missing either is malformed, so treat
    // it as expired rather than letting it through unchecked.
    if (
      !verification ||
      verification.isUsed ||
      !verification.expiresAt ||
      verification.expiresAt < new Date() ||
      (verification.attemptCount ?? 0) >= MAX_TICKET_ATTEMPTS ||
      !(await verifyPassword(token, verification.verificationCode))
    ) {
      this.logger.warn(`Reset link rejected for user ${userId}`);
      throw invalid;
    }

    return {
      userId,
      identifier,
      maskedIdentifier: maskOtpTarget(
        identifier.includes("@") ? { email: identifier } : { phone: identifier },
      ),
      verificationCode: token,
    };
  }

  /** `+251911234567` -> `+251•••••4567`; `ab@x.com` -> `a•@x.com`. */
  maskTarget(target: OtpTarget): string {
    return maskOtpTarget(target);
  }
}
