import { NotificationAudience, NotificationType } from '@edr/types';
import {
  BadRequestException,
  ConflictException,
  ForbiddenException,
  Injectable,
  Logger,
  NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';

import { NotificationInboxService } from '../../notification-inbox/notification-inbox.service';
import { SubmitRateChangeDto } from '../dto/rate-change-request.dto';
import { UpdateRateDto } from '../dto/update-rate.dto';
import {
  RateChangeRequest,
  RateChangeStatus,
} from '../entities/rate-change-request.entity';
import { Rate } from '../entities/rate.entity';
import { RatesService } from './rates.service';

/** Backoffice page where both the queue and the rates live. */
const RATES_LINK = '/dashboard/rules/rates';

/** Fields a change request may carry — anything else in the patch is ignored. */
const DIFFABLE_FIELDS = [
  'rateValue',
  'currency',
  'rateUnit',
  'appliesTo',
  'trigger',
  'tradeDirection',
  'containerTypeId',
  'cargoTypeId',
  // The leg a route-scoped rate prices. Missing here, a re-routed LIVE rate
  // diffed to nothing and the submit was refused as "nothing changed".
  'originYardId',
  'destinationYardId',
] as const;

/**
 * Approval workflow for edits to LIVE rates.
 *
 * A LIVE rate is what pricing charges right now, so it is never edited in
 * place. The edit is filed here as a PENDING request and the live row keeps
 * its old value — a rate at 100 USD keeps quoting 100 while a change to 200
 * waits. Approval replays the edit through RatesService, so every rule
 * (unit validity, pattern uniqueness) is re-checked against whatever is true
 * at approval time, not at submit time.
 */
@Injectable()
export class RateChangeRequestsService {
  private readonly logger = new Logger(RateChangeRequestsService.name);

  constructor(
    @InjectRepository(RateChangeRequest)
    private readonly repo: Repository<RateChangeRequest>,
    private readonly rates: RatesService,
    private readonly inbox: NotificationInboxService,
  ) {}

  /**
   * File an edit against a LIVE rate. Validated up front so the requester
   * hears about a bad unit or a pattern clash immediately rather than the
   * approver hitting it days later.
   */
  async submit(dto: SubmitRateChangeDto, userId?: string | null): Promise<RateChangeRequest> {
    const rate = await this.rates.findById(dto.rateId);
    if (rate.status !== 'LIVE') {
      throw new BadRequestException(
        `Only LIVE rates go through approval — this rate is ${rate.status} and can be edited directly.`,
      );
    }

    const payload = this.changedFieldsOnly(rate, dto.update);
    if (Object.keys(payload).length === 0) {
      throw new BadRequestException('Nothing changed — the proposed values match the live rate.');
    }

    // One pending edit per rate: two racing requests would both validate, then
    // the second would silently overwrite the first on approval.
    const inFlight = await this.repo.findOne({
      where: { rateId: dto.rateId, status: 'PENDING' },
    });
    if (inFlight) {
      throw new ConflictException(
        'This rate already has a change awaiting approval. Have it approved or rejected first.',
      );
    }

    await this.rates.assertUpdateValid(dto.rateId, payload as UpdateRateDto);

    const request = await this.repo.save(
      this.repo.create({
        rateId: dto.rateId,
        payload,
        previousValues: this.snapshot(rate, payload),
        status: 'PENDING',
        requestedByUserId: userId ?? null,
      }),
    );

    this.notifyTeam(
      'Rate change submitted',
      `A change to a LIVE rate was submitted and awaits approval. The current rate stays in effect until it is approved.`,
      request,
    );
    return request;
  }

  async list(status?: RateChangeStatus): Promise<RateChangeRequest[]> {
    return this.repo.find({
      where: status ? { status } : {},
      relations: { rate: true },
      order: { createdAt: 'DESC' },
    });
  }

  /**
   * Approve and apply. The live mutation runs FIRST — if it now fails (someone
   * created a clashing rate since submit), the request stays PENDING and the
   * approver sees the real error instead of a request marked approved that
   * never landed.
   */
  async approve(
    id: string,
    userId?: string | null,
    decisionNote?: string,
    canSelfApprove = false,
  ): Promise<RateChangeRequest> {
    const request = await this.findPending(id);

    // Separation of duties: the requester cannot approve their own repricing —
    // except super admins, who have full backoffice authority.
    if (!canSelfApprove && userId && userId === request.requestedByUserId) {
      throw new ForbiddenException('You cannot approve a rate change you submitted');
    }

    await this.rates.applyApprovedUpdate(request.rateId, request.payload as UpdateRateDto);

    request.status = 'APPROVED';
    request.decidedByUserId = userId ?? null;
    request.decidedAt = new Date();
    request.decisionNote = decisionNote ?? null;
    const saved = await this.repo.save(request);

    this.notifyTeam(
      'Rate change approved',
      `The rate change was approved and is now live.` +
        (decisionNote ? ` Note: ${decisionNote}` : ''),
      saved,
    );
    return saved;
  }

  /** Reject — the live rate is never touched, so it simply keeps its value. */
  async reject(
    id: string,
    userId?: string | null,
    decisionNote?: string,
  ): Promise<RateChangeRequest> {
    const request = await this.findPending(id);
    request.status = 'REJECTED';
    request.decidedByUserId = userId ?? null;
    request.decidedAt = new Date();
    request.decisionNote = decisionNote ?? null;
    const saved = await this.repo.save(request);

    this.notifyTeam(
      'Rate change rejected',
      `The rate change was rejected — the rate keeps its current value.` +
        (decisionNote ? ` Note: ${decisionNote}` : ''),
      saved,
    );
    return saved;
  }

  /**
   * Keep only fields the requester actually changed. A form posts every field
   * back, so without this the diff would list untouched values as changes.
   */
  private changedFieldsOnly(rate: Rate, update: UpdateRateDto): Record<string, unknown> {
    const patch: Record<string, unknown> = {};
    for (const field of DIFFABLE_FIELDS) {
      const proposed = (update as Record<string, unknown>)[field];
      if (proposed === undefined) continue;
      if (this.sameValue(proposed, (rate as unknown as Record<string, unknown>)[field])) continue;
      patch[field] = proposed;
    }
    return patch;
  }

  /** The live values the patch would overwrite — the "before" side of the diff. */
  private snapshot(rate: Rate, payload: Record<string, unknown>): Record<string, unknown> {
    const before: Record<string, unknown> = {};
    for (const field of Object.keys(payload)) {
      before[field] = (rate as unknown as Record<string, unknown>)[field] ?? null;
    }
    return before;
  }

  /**
   * rateValue arrives as a string from Postgres `numeric` but as a number from
   * the form, so 100 and "100.0000" must compare equal or every submit would
   * look like a change.
   */
  private sameValue(a: unknown, b: unknown): boolean {
    if (a === b) return true;
    if (a == null && b == null) return true;
    if (a == null || b == null) return false;
    const numA = Number(a);
    const numB = Number(b);
    if (!Number.isNaN(numA) && !Number.isNaN(numB) && a !== '' && b !== '') {
      return numA === numB;
    }
    return String(a) === String(b);
  }

  private async findPending(id: string): Promise<RateChangeRequest> {
    const request = await this.repo.findOne({ where: { id }, relations: { rate: true } });
    if (!request) throw new NotFoundException(`Rate change request ${id} not found`);
    if (request.status !== 'PENDING') {
      throw new ConflictException(`Rate change request is already ${request.status.toLowerCase()}`);
    }
    return request;
  }

  /** Fire-and-forget — a notification failure never blocks the workflow. */
  private notifyTeam(title: string, body: string, request: RateChangeRequest): void {
    void this.inbox
      .notify({
        recipients: { allBackoffice: true },
        audience: NotificationAudience.BACKOFFICE,
        type: NotificationType.REQUEST_SUBMITTED,
        title,
        body,
        link: RATES_LINK,
        data: { rateChangeRequestId: request.id, rateId: request.rateId },
      })
      .catch((err) =>
        this.logger.warn(`Rate-change notification failed: ${(err as Error).message}`),
      );
  }
}
