import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";

import { ExternalProfile } from "../companies/entities/external-profile.entity";
import { EmailClientService } from "../notifications/email-client.service";
import { SmsClientService } from "../notifications/sms-client.service";
import { ResetChannel } from "./dto/forgot-password.dto";
import {
  ForgotPasswordService,
  RESET_LINK_TTL_MS,
} from "./forgot-password.service";
import { maskOtpTarget } from "./mask-target.util";
import { isDomesticPhone } from "../otp/otp.service";

/** The account a staff-triggered reset would land on. */
export interface CustomerResetTarget {
  userId: string;
  name: string;
  email: string | null;
  phone: string | null;
  /**
   * Whether the SMS gateway (domestic-only) can reach `phone`. `null` when
   * there is no phone. The backoffice uses this to disable the SMS channel for
   * foreign numbers instead of sending a link that will never arrive.
   */
  phoneIsDomestic: boolean | null;
}

export interface SentResetLink {
  channel: ResetChannel;
  maskedTarget: string;
  expiresAt: string;
}

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

  constructor(
    @InjectRepository(ExternalProfile)
    private readonly externalProfileRepository: Repository<ExternalProfile>,
    private readonly forgotPasswordService: ForgotPasswordService,
    private readonly emailClient: EmailClientService,
    private readonly smsClient: SmsClientService,
    private readonly config: ConfigService,
  ) {}

  /**
   * The IAM account a reset would actually reach. The backoffice shows these
   * values rather than `company.email` / `company.phone`: the company row holds
   * business contact detail, while the link is delivered to the primary
   * contact's own login credentials — the two drift apart routinely, and showing
   * the wrong one has staff telling customers to check an inbox nothing was sent
   * to.
   */
  async getResetTarget(companyId: string): Promise<CustomerResetTarget | null> {
    const resolved = await this.resolvePrimaryContactUser(companyId);
    if (!resolved) return null;

    const { profile, user, userId } = resolved;
    return {
      userId,
      name: `${profile.firstName} ${profile.lastName}`.trim(),
      email: user.email ?? null,
      phone: user.phoneNumber ?? null,
      phoneIsDomestic: user.phoneNumber
        ? isDomesticPhone(user.phoneNumber)
        : null,
    };
  }

  /**
   * Mint a password-reset link and send it to the company's primary contact.
   * Returns the masked destination, or null when there is no eligible account
   * for that channel.
   *
   * Unlike the public flow this reports failure honestly — the caller is an
   * authenticated staff member, so there is nothing to enumerate.
   */
  async sendResetLinkToCustomer(
    companyId: string,
    channel: ResetChannel,
  ): Promise<SentResetLink | null> {
    const resolved = await this.resolvePrimaryContactUser(companyId);
    if (!resolved) return null;

    const { user, userId } = resolved;
    const target = this.forgotPasswordService.targetFor(user, channel);
    if (!target) return null;

    // A foreign number is unreachable by the domestic-only SMS gateway — treat
    // it like a missing phone rather than reporting "link sent" for a message
    // that will never arrive. The backoffice disables the channel up front via
    // `phoneIsDomestic`; this guards direct API calls.
    if (channel === "phone" && target.phone && !isDomesticPhone(target.phone)) {
      this.logger.warn(
        `Staff reset via SMS refused for user ${userId} — non-domestic phone`,
      );
      return null;
    }

    // Mint first, send second: a failed send leaves an unused ticket that simply
    // expires, whereas sending a link before the ticket exists would hand the
    // customer a URL that is dead on arrival.
    const ticket = await this.forgotPasswordService.mintResetTicket(
      userId,
      RESET_LINK_TTL_MS,
    );
    const link = this.buildResetLink(ticket.userId, ticket.verificationCode);
    const expiresAt = new Date(Date.now() + RESET_LINK_TTL_MS);

    const { queued } = target.email
      ? await this.emailClient.sendEmail({
          to: target.email,
          subject: "Reset your EDR Freight password",
          text:
            "A password reset was started for your EDR Freight account.\n\n" +
            `Open this link to choose a new password:\n${link}\n\n` +
            "The link expires in 24 hours and can only be used once. If you did " +
            "not expect this, ignore this message — your password stays unchanged.",
        })
      : await this.smsClient.sendSms({
          to: target.phone as string,
          message: `Reset your EDR Freight password: ${link} (expires in 24 hours, single use)`,
        });

    this.logger.log(
      `Staff-triggered ${channel} reset link sent to user ${userId} (company ${companyId}) queued=${queued}`,
    );

    if (!queued) {
      // The ticket is committed and the backoffice is about to say "link sent",
      // but nothing left this process — with RABBITMQ_ENABLED=false both clients
      // are no-ops. Without this line the only symptom is a customer who never
      // receives anything, indistinguishable from carrier loss.
      this.logger.error(
        `reset-link.dispatch.dropped channel=${channel} user=${userId} rabbitmqEnabled=${
          process.env.RABBITMQ_ENABLED ?? "unset"
        } — transport reported no hand-off; no link will arrive`,
      );
      // SECURITY: logs a live password-reset credential in cleartext. Same
      // deliberate tradeoff the OTP service makes — this is the only way to
      // complete a reset on an environment with no broker. Only reached when
      // delivery already failed.
      this.logger.warn(`Undelivered reset link for user ${userId}: ${link}`);
    }

    return {
      channel,
      maskedTarget: maskOtpTarget(target),
      expiresAt: expiresAt.toISOString(),
    };
  }

  /**
   * The company's primary contact, gated on the same active-account rule the
   * public flow uses — so a suspended customer cannot be reactivated by a
   * staff-triggered reset (IAM's `set-password` flips `isActive` back on).
   */
  private async resolvePrimaryContactUser(companyId: string) {
    const profile = await this.externalProfileRepository.findOne({
      where: { companyId, isPrimaryContact: true },
    });

    if (!profile) {
      this.logger.warn(`Company ${companyId} has no primary contact profile`);
      return null;
    }

    const user = await this.forgotPasswordService.resolveActiveUserById(
      profile.userId,
    );
    if (!user?.id) {
      this.logger.warn(
        `Primary contact ${profile.userId} of company ${companyId} is not an active account`,
      );
      return null;
    }

    return { profile, user, userId: user.id };
  }

  /**
   * The portal route that trades the token for a set-password form. Params are
   * URL-encoded because the token is base64url — safe as-is, but the encoding
   * keeps this correct if the token format ever changes.
   */
  private buildResetLink(userId: string, token: string): string {
    const base = this.config.get<string>("app.portalBaseUrl");
    return `${base}/reset-password?uid=${encodeURIComponent(
      userId,
    )}&token=${encodeURIComponent(token)}`;
  }
}
