// otp.service.ts

import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { randomInt } from "node:crypto";

import { OtpRepository } from "./otp.repository";

import { SmsClientService } from "../notifications/sms-client.service";
import { EmailClientService } from "../notifications/email-client.service";

/**
 * Where a code goes. At least one of phone/email must be set — enforced by the
 * controller and re-checked here. When BOTH are set the same code is sent to
 * both and either one can be used to verify it: a user who never receives the
 * SMS can still finish from their inbox, and vice versa. Callers that resolve
 * contacts from IAM pass whatever the account actually has, so an account with
 * only one of the two silently degrades to a single channel.
 */
export type OtpTarget = { phone?: string; email?: string };

/** Which transports a target resolves to, in a stable order for logging. */
function channelsOf(target: OtpTarget): Array<"email" | "sms"> {
  const channels: Array<"email" | "sms"> = [];
  if (target.email) channels.push("email");
  if (target.phone) channels.push("sms");
  return channels;
}

/**
 * Canonicalise a phone to E.164 so the code stored on send and the one looked
 * up on verify collide regardless of how the number was typed. Without this,
 * `+251986680099`, `251986680099` and `0986680099` are three different keys and
 * a code sent to one is invisible to the others — the send/verify halves must
 * agree on the exact string. Ethiopian local `09…`/`07…` (10 digits) maps to
 * `+2519…`/`+2517…`; a bare `251…` gains its `+`; anything already `+…` is kept.
 */
function normalizePhone(rawPhone: string): string {
  const raw = rawPhone.trim();
  const digits = raw.replace(/[^\d+]/g, "");
  if (digits.startsWith("+")) return digits;
  const bare = digits.replace(/^0+/, "");
  if (/^251\d{9}$/.test(digits)) return `+${digits}`;
  if (/^9\d{8}$|^7\d{8}$/.test(bare)) return `+251${bare}`;
  // Unknown shape (foreign number, already-clean intl without +) — prefix + if
  // it looks like a full international number, else leave as typed.
  return digits.length >= 11 ? `+${digits}` : raw;
}

/**
 * Whether a phone is an Ethiopian mobile the SMS gateway can actually reach —
 * the carrier integration is domestic-only, so a send to anything else is
 * queued and silently lost. Callers use this to fall back to email instead of
 * pretending an SMS is on its way.
 */
export function isDomesticPhone(rawPhone: string): boolean {
  return /^\+2519\d{8}$/.test(normalizePhone(rawPhone));
}

/**
 * Canonicalise every channel present on the target. Each field is normalised
 * independently — a dual-channel target must end up with both halves in their
 * canonical form, since verify may arrive naming either one.
 */
export function normalizeOtpTarget(target: OtpTarget): OtpTarget {
  const normalized: OtpTarget = {};

  if (target.email?.trim()) {
    // Same contract as the phone branch: the string stored on send and the one
    // looked up on verify must be byte-identical, or the code is invisible to
    // the verifier. Addresses reach us from a raw `@Body("email")` with no DTO
    // or ValidationPipe, so `User@X.com`, `user@x.com` and a copy-paste with a
    // trailing space are three different keys for one mailbox. Domains are
    // case-insensitive (RFC 1035); local-parts are formally case-sensitive
    // (RFC 5321 §2.4) but no mail provider in practice treats them so, and
    // matching what users expect beats matching the letter of the spec here.
    normalized.email = target.email.trim().toLowerCase();
  }

  if (target.phone?.trim()) {
    normalized.phone = normalizePhone(target.phone);
  }

  return normalized;
}

/** One transport's hand-off outcome. Never thrown — collected and reported. */
interface DispatchOutcome {
  channel: "email" | "sms";
  queued: boolean;
  error?: string;
}

@Injectable()
export class OtpService {
  logger = new Logger(OtpService.name);
  constructor(
    private readonly otpRepository: OtpRepository,
    private readonly smsClient: SmsClientService,
    private readonly emailClient: EmailClientService,
  ) { }

  // ---------------------------------------------------------------------------
  // Generate OTP
  // ---------------------------------------------------------------------------

  generateOtp(): string {
    // Cryptographically secure 6-digit code (100000–999999). Math.random() is a
    // non-CSPRNG and must never be used to mint a security token.
    return randomInt(100000, 1000000).toString();
  }

  // ---------------------------------------------------------------------------
  // Send OTP
  // ---------------------------------------------------------------------------

  async sendOtp(rawTarget: OtpTarget) {
    // Store under the canonical keys so verify (which normalises the same way)
    // always finds this row regardless of how either side typed the number.
    const target = normalizeOtpTarget(rawTarget);
    const channels = channelsOf(target);
    const label = this.targetLabel(target);
    const startedAt = Date.now();

    if (channels.length === 0) {
      throw new BadRequestException("phone or email is required");
    }

    try {
      // The verification code is generated server-side — never supplied by the
      // caller — so the OTP stays a secret known only to the server and the
      // recipient of the SMS/email. ONE code covers every channel: the user
      // types whichever message reaches them first.
      const otp = this.generateOtp();

      // Replaces every row this target overlaps with, so a dual-channel send
      // leaves exactly one row holding both halves — verify then resolves the
      // same row whichever channel it is given.
      const { rotated } = await this.otpRepository.replaceOtp(target, otp);

      // `rotate` means a code already existed for this target and was replaced —
      // the previous one is now dead. A user holding a slow-to-arrive SMS and
      // typing its code will fail against the row; this line is how that shows up
      // in the log rather than as an unexplained "invalid OTP" report.
      this.logger.log(
        `otp.issue channels=${channels.join("+")} target=${label} action=${rotated ? "rotate" : "create"}`,
      );

      // NOTE: do NOT reset the brute-force attempt counter on send. Clearing it
      // here let an attacker wipe the per-target guess budget just by calling
      // /otp/send between guesses. The counter is cleared only when the code is
      // consumed/expired during verification.
      // TODO: add per-target + per-IP rate limiting on the public /otp/send and
      // /otp/verify routes (a NestJS ThrottlerGuard / @Throttle) — none exists
      // in the codebase yet.

      // A foreign number is unreachable by the domestic-only SMS gateway; when
      // email is also on the target, go email-only rather than queueing an SMS
      // that will never arrive. With no email the SMS attempt stays — it is the
      // only route there is.
      const smsPhone =
        target.phone && (!target.email || isDomesticPhone(target.phone))
          ? target.phone
          : null;
      if (target.phone && !smsPhone) {
        this.logger.warn(
          `otp.dispatch.sms-skipped target=${label} — non-domestic phone, delivering via email only`,
        );
      }

      // Fan out to every channel the target has, independently: one transport
      // being down must not suppress the other, which is the whole point of
      // sending to both. Each helper swallows its own failure so a rejected
      // email publish still leaves the SMS delivered (and the code valid).
      const outcomes = (
        await Promise.all([
          target.email ? this.dispatchEmail(target.email, otp) : null,
          smsPhone ? this.dispatchSms(smsPhone, otp) : null,
        ])
      ).filter((outcome): outcome is DispatchOutcome => outcome !== null);

      for (const outcome of outcomes) {
        this.logger.log(
          `otp.dispatch channel=${outcome.channel} target=${label} queued=${outcome.queued
          } latencyMs=${Date.now() - startedAt}${outcome.error ? ` error=${outcome.error}` : ""
          }`,
        );
      }

      // Every channel threw. Nothing can arrive and there is no partial success
      // to preserve — fail the request the way a single-channel send always did.
      if (outcomes.every((outcome) => outcome.error)) {
        throw new Error(
          outcomes.map((o) => `${o.channel}: ${o.error}`).join("; "),
        );
      }

      // Both clients report hand-off, not delivery — capture it rather than
      // discarding it, so "delivered=false" is distinguishable from a code that
      // was published fine and lost downstream at the carrier.
      const delivered = outcomes.some((outcome) => outcome.queued);

      if (!delivered) {
        // The row is committed and we are about to answer "OTP sent successfully",
        // but nothing left this process. Without this line the only symptom is a
        // user who never receives a code — indistinguishable from carrier loss,
        // and the misleading success response makes it look like our side worked.
        this.logger.error(
          `otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${process.env.RABBITMQ_ENABLED ?? "unset"
          } — no transport reported hand-off; no code will arrive for this send`,
        );
      }

      // SECURITY: this logs a live credential in cleartext. Anyone with read
      // access to the log stream can complete a password reset or a contract
      // signature for the address on the same line. Kept deliberately (log
      // aggregation is the debugging path for flaky SMS here) — if that tradeoff
      // is ever revisited, gate on an env flag rather than deleting the line, so
      // dev keeps its workflow.
      this.logger.log(`OTP send for ${label}: ${otp}`);
      return {
        success: true,

        // Distinguishes "we published it" from "the transport is a no-op". The
        // HTTP response shape is unchanged; the controller drops this field.
        delivered,

        message: "OTP sent successfully",
      };
    } catch (error) {
      // Log the real cause (DB/SMS/email failure) with its stack so a deployed
      // "Failed to send OTP" 400 is diagnosable from the API logs, not opaque.
      this.logger.error(
        `otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${Date.now() - startedAt
        }: ${error instanceof Error ? error.message : String(error)}`,
        error instanceof Error ? error.stack : undefined,
      );
      throw new BadRequestException("Failed to send OTP");
    }
  }

  /**
   * Publish to one transport, converting a throw into a reported outcome. A
   * broker error on one channel must not abort the other — with dual-channel
   * sends the user still has a working route to the code.
   */
  private async dispatchEmail(
    email: string,
    otp: string,
  ): Promise<DispatchOutcome> {
    try {
      const { queued } = await this.emailClient.sendEmail({
        to: email,
        subject: "Your EDR Freight verification code",
        text: `Your verification code is ${otp}`,
      });
      return { channel: "email", queued };
    } catch (error) {
      return {
        channel: "email",
        queued: false,
        error: error instanceof Error ? error.message : String(error),
      };
    }
  }

  /** SMS half of {@link dispatchEmail}; same swallow-and-report contract. */
  private async dispatchSms(
    phone: string,
    otp: string,
  ): Promise<DispatchOutcome> {
    try {
      const { queued } = await this.smsClient.sendSms({
        to: phone,
        message: `Your verification code is ${otp}`,
      });
      return { channel: "sms", queued };
    } catch (error) {
      return {
        channel: "sms",
        queued: false,
        error: error instanceof Error ? error.message : String(error),
      };
    }
  }

  /**
   * Correlation key shared by every `otp.*` line for one target, so a send and
   * its later verify can be joined with a single grep. The raw values are used
   * because the code itself is already logged in cleartext above — hashing the
   * address while printing the credential next to it would buy nothing.
   */
  private targetLabel(target: OtpTarget): string {
    return [target.email, target.phone].filter(Boolean).join("+") || "unknown";
  }

  /**
   * One line per verify exit path. `result` is a closed set — ok | invalid |
   * expired | exhausted | not_found — so failures can be counted by reason
   * instead of inferred from error strings that the frontend also depends on.
   */
  private logVerify(
    target: OtpTarget,
    mode: "simple" | "action",
    result: "ok" | "invalid" | "expired" | "exhausted" | "not_found",
    detail?: string,
  ) {
    const line = `otp.verify channels=${channelsOf(target).join(
      "+",
    )} target=${this.targetLabel(target)} mode=${mode} result=${result}${detail ? ` ${detail}` : ""
      }`;
    if (result === "ok") this.logger.log(line);
    else this.logger.warn(line);
  }

  /**
   * "No code for this target" phrased for whichever channels were named. A
   * dual-channel caller gets a neutral message — naming one channel would be
   * misleading when the code went to both.
   */
  private notFoundMessage(target: OtpTarget, requested: boolean): string {
    const channels = channelsOf(target);
    if (channels.length !== 1) {
      return requested
        ? "No verification code was requested for this account"
        : "No verification code found for this account";
    }
    if (target.email) {
      return requested
        ? "No verification code was requested for this email"
        : "Email address not found";
    }
    return requested
      ? "No verification code was requested for this phone"
      : "Phone number not found";
  }

  // ---------------------------------------------------------------------------
  // Verify OTP
  // ---------------------------------------------------------------------------

  async verifyOtp(rawTarget: OtpTarget, otp: string) {
    // Same canonicalisation as sendOtp so a code stored under +2519… is found
    // when verify is called with 09… (or any equivalent form).
    const target = normalizeOtpTarget(rawTarget);
    // Matches on ANY channel the caller named, so a code sent to both phone and
    // email verifies whichever one the user quotes back.
    const otpData = await this.otpRepository.findByTarget(target);

    // not found
    if (!otpData) {
      // No row for this target. Most often a normalisation mismatch or a code
      // that was already consumed/burned — not necessarily a caller who never
      // asked.
      this.logVerify(target, "simple", "not_found");
      throw new BadRequestException(this.notFoundMessage(target, false));
    }

    // Key the attempt budget on the ROW, not on the channels the caller happened
    // to name — otherwise guessing alternately by phone and by email would hand
    // an attacker two independent budgets against the same code.
    const key = otpData.id;

    // TTL: reuse the same age window as the hardened action verifier — an old
    // code can't be verified.
    const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();
    if (ageMs > this.ACTION_OTP_TTL_MS) {
      await this.otpRepository.deleteOtp(otpData);
      this.actionAttempts.delete(key);
      this.logVerify(
        target,
        "simple",
        "expired",
        `ageMs=${ageMs} ttlMs=${this.ACTION_OTP_TTL_MS}`,
      );
      throw new BadRequestException(
        "Verification code has expired. Request a new one.",
      );
    }

    // invalid otp — per-target attempt cap so a 6-digit code can't be
    // brute-forced within its TTL; the code is burned once the budget is spent.
    if (otpData.otp !== otp) {
      const attempts = (this.actionAttempts.get(key) ?? 0) + 1;
      if (attempts >= this.MAX_ACTION_ATTEMPTS) {
        await this.otpRepository.deleteOtp(otpData);
        this.actionAttempts.delete(key);
        this.logVerify(
          target,
          "simple",
          "exhausted",
          `attempts=${attempts}/${this.MAX_ACTION_ATTEMPTS} ageMs=${ageMs}`,
        );
        throw new BadRequestException(
          "Too many incorrect attempts. Request a new code.",
        );
      }
      this.actionAttempts.set(key, attempts);
      this.logVerify(
        target,
        "simple",
        "invalid",
        `attempts=${attempts}/${this.MAX_ACTION_ATTEMPTS} ageMs=${ageMs}`,
      );
      throw new BadRequestException("Invalid OTP");
    }

    // single-use: consume the code on success so it can't be replayed. One row
    // covers every channel it was sent to, so this kills all of them at once.
    await this.otpRepository.deleteOtp(otpData);
    this.actionAttempts.delete(key);
    this.logVerify(target, "simple", "ok", `ageMs=${ageMs}`);

    return {
      success: true,

      message: "Verification successful",
    };
  }

  // ---------------------------------------------------------------------------
  // Verify OTP for a sensitive action (sudo mode)
  // ---------------------------------------------------------------------------

  // Fresh, single-use challenge gating a sensitive action (e.g. applying a
  // contract signature, resetting a forgotten password). Unlike verifyOtp above
  // — which marks a target verified and leaves the code in place — this enforces
  // a TTL and consumes the code on success so it can never be replayed.
  private readonly ACTION_OTP_TTL_MS = 5 * 60 * 1000;

  // Without a cap, a 6-digit code guarding a password reset is brute-forceable
  // within its own TTL. `otp_verifications` has no attempt column, so the
  // counter lives here and the code is burned once the budget is spent.
  // Per-process: it resets on restart and is not shared across replicas — a
  // persisted counter needs a migration on OtpVerification.
  private readonly MAX_ACTION_ATTEMPTS = 5;
  private readonly actionAttempts = new Map<string, number>();

  async verifyOtpForAction(
    rawTarget: OtpTarget,
    otp: string,
    ttlMs: number = this.ACTION_OTP_TTL_MS,
  ) {
    const target = normalizeOtpTarget(rawTarget);
    const otpData = await this.otpRepository.findByTarget(target);

    if (!otpData) {
      this.logVerify(target, "action", "not_found");
      throw new BadRequestException(this.notFoundMessage(target, true));
    }

    // Row-keyed for the same reason as verifyOtp: one code, one budget.
    const key = otpData.id;
    const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();

    if (ageMs > ttlMs) {
      await this.otpRepository.deleteOtp(otpData);
      this.actionAttempts.delete(key);

      this.logVerify(
        target,
        "action",
        "expired",
        `ageMs=${ageMs} ttlMs=${ttlMs}`,
      );
      throw new BadRequestException(
        "Verification code has expired. Request a new one.",
      );
    }

    if (otpData.otp !== otp) {
      const attempts = (this.actionAttempts.get(key) ?? 0) + 1;

      if (attempts >= this.MAX_ACTION_ATTEMPTS) {
        await this.otpRepository.deleteOtp(otpData);
        this.actionAttempts.delete(key);

        this.logVerify(
          target,
          "action",
          "exhausted",
          `attempts=${attempts}/${this.MAX_ACTION_ATTEMPTS} ageMs=${ageMs}`,
        );
        throw new BadRequestException(
          "Too many incorrect attempts. Request a new code.",
        );
      }

      this.actionAttempts.set(key, attempts);
      this.logVerify(
        target,
        "action",
        "invalid",
        `attempts=${attempts}/${this.MAX_ACTION_ATTEMPTS} ageMs=${ageMs}`,
      );
      throw new BadRequestException("Invalid verification code");
    }

    // single-use: consume on success
    await this.otpRepository.deleteOtp(otpData);
    this.actionAttempts.delete(key);
    this.logVerify(target, "action", "ok", `ageMs=${ageMs}`);

    return { success: true };
  }
}
