import {
  BadRequestException,
  ConflictException,
  Injectable,
  Logger,
} from "@nestjs/common";
import { InjectDataSource, InjectRepository } from "@nestjs/typeorm";
import { DataSource, EntityManager, Repository } from "typeorm";
import { isValidPhoneNumber } from "libphonenumber-js";

import { EUserVerifiedBy } from "@tria-plc/api-common/utils/enums/user.enum";
import type { TCurrentTokenUser } from "@tria-plc/iamapi-common/types/current-user.type";
import { Employee } from "@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity";
import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";

import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
import { OtpService, OtpTarget } from "../otp/otp.service";
import {
  ContactChannel,
  SendContactOtpDto,
  UpdateAccountNameDto,
  UpdateContactDto,
} from "./dto/account.dto";
import { maskOtpTarget } from "./mask-target.util";

/** How long a contact-change code stays valid before it must be re-requested. */
const CONTACT_OTP_TTL_MS = 10 * 60 * 1000;

/** Postgres unique-violation SQLSTATE. */
const PG_UNIQUE_VIOLATION = "23505";

/**
 * Self-serve management of the caller's own IAM user record.
 *
 * IAM ships `PATCH /api/auth/update-profile`, but it takes email + username +
 * phone + name all at once (every field `@IsNotEmpty`) and performs no
 * verification — it will move an account's phone to any number the caller
 * types. These routes exist so a contact change is *proven*: the code goes to
 * the NEW address and the write only lands once it comes back.
 */
@Injectable()
export class AccountService {
  private readonly logger = new Logger(AccountService.name);

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

  /**
   * Send a code to the address the caller wants to move TO. Sending to the new
   * value (rather than the one on file) is the whole point — it proves control
   * of the destination before anything is written.
   */
  async sendContactOtp(
    userId: string,
    dto: SendContactOtpDto,
  ): Promise<{ sentTo: string }> {
    const value = this.normalize(dto.channel, dto.value);
    await this.assertNotTaken(dto.channel, value, userId);

    const target = this.targetFor(dto.channel, value);
    await this.otpService.sendOtp(target);

    return { sentTo: maskOtpTarget(target) };
  }

  /**
   * Verify the code, then write the new contact value. The verify and the write
   * are one call: the API never has to trust that a client "already verified"
   * — unlike the signup flow, where the OTP is client-orchestrated and
   * `POST /api/otp/verify` is a separate public route the client may simply skip.
   */
  async updateContact(
    userId: string,
    dto: UpdateContactDto,
  ): Promise<{ success: true; value: string }> {
    const value = this.normalize(dto.channel, dto.value);
    await this.assertNotTaken(dto.channel, value, userId);

    await this.otpService.verifyOtpForAction(
      this.targetFor(dto.channel, value),
      dto.otp,
      CONTACT_OTP_TTL_MS,
    );

    const isEmail = dto.channel === ContactChannel.Email;
    const userPatch = isEmail
      ? { email: value }
      : {
          phoneNumber: value,
          // The number just passed an OTP, which is exactly what IAM's own
          // phone-verification flag means. Set it here so the freight app stops
          // needing its own parallel "verified phone" bookkeeping.
          isPhoneNumberVerified: true,
          verifiedBy: EUserVerifiedBy.PHONE_NUMBER,
        };
    const sessionPatch: Partial<TCurrentTokenUser> = isEmail
      ? { email: value }
      : { phoneNumber: value, isPhoneNumberVerified: true };

    try {
      await this.dataSource.transaction(async (manager) => {
        await manager.getRepository(User).update({ id: userId }, userPatch);
        await this.refreshSessions(manager, userId, sessionPatch);
      });
    } catch (error) {
      throw this.asConflict(error, dto.channel);
    }

    this.logger.log(`Account ${dto.channel} updated for user ${userId}`);
    return { success: true, value };
  }

  /** Rename the account. No OTP — a name change proves nothing and grants nothing. */
  async updateName(
    userId: string,
    dto: UpdateAccountNameDto,
  ): Promise<{ success: true }> {
    const en = dto.name.en?.trim();
    const name = { am: dto.name.am.trim(), ...(en ? { en } : {}) };

    await this.dataSource.transaction(async (manager) => {
      await manager.getRepository(User).update({ id: userId }, { name });
      // IAM mirrors the name onto the employee row. Portal customers are
      // `individual` users with no employee row at all, so this is a no-op for
      // them — hence an unconditional update() rather than a lookup-then-write.
      await manager.getRepository(Employee).update({ userId }, { name });
      await this.refreshSessions(manager, userId, { name });
    });

    return { success: true };
  }

  /**
   * `GET /api/auth/me` serves `session.userInfo` — a snapshot IAM writes only
   * when a session is created at login. Without patching it here, a saved change
   * stays invisible to /me (and to anything reading the token's claims) until the
   * user logs out and back in, which reads as "my edit didn't save".
   */
  private async refreshSessions(
    manager: EntityManager,
    userId: string,
    patch: Partial<TCurrentTokenUser>,
  ): Promise<void> {
    const repo = manager.getRepository(Session);
    const sessions = await repo.find({ where: { userId } });

    await Promise.all(
      sessions.map((session) =>
        repo.update(
          { id: session.id },
          { userInfo: { ...session.userInfo, ...patch } },
        ),
      ),
    );
  }

  /** Canonicalise for the channel and reject anything malformed up front. */
  private normalize(channel: ContactChannel, value: string): string {
    const raw = value.trim();

    if (channel === ContactChannel.Email) {
      const email = raw.toLowerCase();
      if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
        throw new BadRequestException("A valid email address is required");
      }
      return email;
    }

    if (!isValidPhoneNumber(raw)) {
      throw new BadRequestException(
        "A valid international phone number is required (E.164, e.g. +251911223344)",
      );
    }
    // Store the same canonical form the OTP is keyed by, so the code sent here
    // is findable on verify regardless of how the number was typed.
    return normalizeE164(raw) as string;
  }

  private targetFor(channel: ContactChannel, value: string): OtpTarget {
    return channel === ContactChannel.Email ? { email: value } : { phone: value };
  }

  /**
   * `iam.users.email` and `.phone_number` are each independently UNIQUE, so a
   * collision would otherwise surface as a raw 500 at write time. This is a
   * courtesy check, not the guard — it races, so {@link asConflict} still has to
   * catch the violation.
   */
  private async assertNotTaken(
    channel: ContactChannel,
    value: string,
    userId: string,
  ): Promise<void> {
    const existing = await this.userRepository.findOne({
      where:
        channel === ContactChannel.Email
          ? { email: value }
          : { phoneNumber: value },
      select: { id: true },
    });

    if (existing && existing.id !== userId) {
      throw this.takenError(channel);
    }
  }

  private asConflict(error: unknown, channel: ContactChannel): Error {
    const code = (error as { code?: string } | null)?.code;
    if (code === PG_UNIQUE_VIOLATION) return this.takenError(channel);
    return error as Error;
  }

  private takenError(channel: ContactChannel): ConflictException {
    return new ConflictException(
      channel === ContactChannel.Email
        ? "That email address is already registered to another account"
        : "That phone number is already registered to another account",
    );
  }
}
