import {
  BadRequestException,
  ConflictException,
  ForbiddenException,
  Inject,
  Injectable,
  NotFoundException,
} from '@nestjs/common';
import { PaginatedResponse, YardCountry } from '@edr/types';
import { CreateRateDto } from '../dto/create-rate.dto';
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
import { UpdateRateDto } from '../dto/update-rate.dto';
import { Rate } from '../entities/rate.entity';
import { deriveRateType } from '../entities/rate-type.util';
import { CargoUom, allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util';
import {
  CARGO_TYPES_REPOSITORY,
  ICargoTypesRepository,
} from '../interfaces/cargo-types.repository.interface';
import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface';
import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface';

/** Categories priced per rail leg — they carry an origin → destination yard pair. */
const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = ['BULK', 'CONTAINER', 'INTERCITY'];

/** The yard pair a rate scopes to, already validated against its direction. */
interface YardScope {
  originYardId: string | null;
  destinationYardId: string | null;
}

@Injectable()
export class RatesService {
  constructor(
    @Inject(RATES_REPOSITORY)
    private readonly repository: IRatesRepository,
    @Inject(YARDS_REPOSITORY)
    private readonly yardsRepository: IYardsRepository,
    @Inject(CARGO_TYPES_REPOSITORY)
    private readonly cargoTypesRepository: ICargoTypesRepository,
  ) {}

  /** List rates — standard paginated envelope with server-side search. */
  async findAll(query: ListRatesQueryDto): Promise<PaginatedResponse<Rate>> {
    return this.repository.findPaged(query);
  }

  /** Return all currently LIVE rates. */
  async findLiveRates(): Promise<Rate[]> {
    return this.repository.findLiveRates();
  }

  /**
   * LIVE rates with yard / container / cargo relations joined — used to render
   * the origin → destination rate schedule inside generated contracts.
   */
  async findLiveRatesDetailed(): Promise<Rate[]> {
    return this.repository.findLiveRatesDetailed();
  }

  /** Get a rate by ID. */
  async findById(id: string): Promise<Rate> {
    const entity = await this.repository.findById(id);
    if (!entity) throw new NotFoundException(`Rate ${id} not found`);
    return entity;
  }

  /**
   * Normalise + validate the weighting unit for a rate shape. Overweight is
   * always billed per excess ton, so its unit is forced to PER_TON regardless
   * of what the client sent. Every other shape must pick a unit the pricing
   * engine can actually apply (see `allowedRateUnits`) — for a rate scoped to a
   * bulk commodity that means the commodity's own unit of measure: a PER_ITEM
   * commodity bills per item where a weighed one bills per ton.
   */
  private async resolveRateUnit(
    appliesTo: Rate['appliesTo'],
    trigger: Rate['trigger'],
    requestedUnit: Rate['rateUnit'] | undefined,
    cargoKind?: 'CONTAINER' | 'BULK' | null,
    cargoTypeId?: string | null,
  ): Promise<Rate['rateUnit']> {
    // Overweight is per-ton, full stop — the admin form hides the unit field
    // for it and omits rateUnit from the payload entirely.
    if (trigger === 'OVERWEIGHT') return 'PER_TON';

    const cargoUnitOfMeasure = await this.cargoUnitOfMeasure(cargoTypeId);
    const allowed = allowedRateUnits({ appliesTo, trigger, cargoKind, cargoUnitOfMeasure });
    if (!requestedUnit) {
      throw new BadRequestException(
        `Pick a rate unit for this rate. Allowed: ${allowed.join(', ')}.`,
      );
    }
    if (
      !isRateUnitAllowed({
        appliesTo,
        trigger,
        cargoKind,
        cargoUnitOfMeasure,
        unit: requestedUnit,
      })
    ) {
      throw new BadRequestException(
        `Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed.join(', ')}.`,
      );
    }
    return requestedUnit;
  }

  /** Unit of measure of the bulk commodity a rate is scoped to; null when unscoped. */
  private async cargoUnitOfMeasure(cargoTypeId?: string | null): Promise<CargoUom> {
    if (!cargoTypeId) return null;
    const cargo = await this.cargoTypesRepository.findById(cargoTypeId);
    return cargo?.unitOfMeasure ?? null;
  }

  /** Base rail freight is priced per leg; surcharges and truck legs are not. */
  private isBaseFreight(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean {
    return trigger === 'ALWAYS' && BASE_FREIGHT_CATEGORIES.includes(appliesTo);
  }

  /**
   * Rates sold per direction + route. Base freight always; customs clearance
   * and empty-container return are the surcharges that are too — their fee
   * depends on the lane (and, for returns, the container type).
   */
  private isRouteScoped(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean {
    return (
      this.isBaseFreight(appliesTo, trigger) ||
      trigger === 'CUSTOMS_CLEARANCE' ||
      trigger === 'WITH_RETURN'
    );
  }

  /**
   * True when pricing resolves exactly ONE rate for this shape (base freight,
   * customs clearance, lashing, empty-container return — all `find()`-based
   * lookups). For those the unit is not part of the rate's identity: two rows
   * for the same lane differing only by unit are a duplicate the engine cannot
   * choose between.
   *
   * The additive surcharges are the opposite — the engine bills EVERY matching
   * rate by its own unit, which is how hazard can be per-container for boxes
   * and per-ton for bulk at the same time — so their unit stays part of the key.
   */
  private resolvesSingleRate(
    appliesTo: Rate['appliesTo'],
    trigger: Rate['trigger'],
  ): boolean {
    return this.isRouteScoped(appliesTo, trigger) || trigger === 'LASHING';
  }

  /**
   * Which country each end of the leg must sit in, given what the rate is for.
   * The railway only sells three shapes: import lands at the Djibouti ports and
   * rails inland, export is the reverse, and intercity stays inside Ethiopia.
   */
  private expectedYardCountries(
    appliesTo: Rate['appliesTo'],
    tradeDirection: string | null,
  ): { origin: YardCountry; destination: YardCountry } {
    if (appliesTo === 'INTERCITY') {
      return { origin: YardCountry.ETHIOPIA, destination: YardCountry.ETHIOPIA };
    }
    return tradeDirection === 'EXPORT'
      ? { origin: YardCountry.ETHIOPIA, destination: YardCountry.DJIBOUTI }
      : { origin: YardCountry.DJIBOUTI, destination: YardCountry.ETHIOPIA };
  }

  /**
   * Validate and normalise the leg a rate prices.
   *
   * Base freight must name both yards and they must match the direction, so a
   * "container import" rate cannot be quoted Ethiopia → Ethiopia. Everything
   * else (surcharges, first/last mile) is route-agnostic and has its yards
   * cleared, mirroring how container/cargo scope is cleared for surcharges.
   */
  private async resolveYardScope(input: {
    appliesTo: Rate['appliesTo'];
    trigger: Rate['trigger'];
    tradeDirection: string | null;
    originYardId?: string | null;
    destinationYardId?: string | null;
  }): Promise<YardScope> {
    const { appliesTo, trigger, tradeDirection } = input;
    if (!this.isRouteScoped(appliesTo, trigger)) {
      return { originYardId: null, destinationYardId: null };
    }

    const originYardId = input.originYardId ?? null;
    const destinationYardId = input.destinationYardId ?? null;
    if (!originYardId || !destinationYardId) {
      throw new BadRequestException(
        'This rate is priced per leg — pick both an origin and a destination yard.',
      );
    }
    if (originYardId === destinationYardId) {
      throw new BadRequestException('Origin and destination yard must be different.');
    }

    const [origin, destination] = await Promise.all([
      this.yardsRepository.findById(originYardId),
      this.yardsRepository.findById(destinationYardId),
    ]);
    if (!origin) throw new BadRequestException(`Origin yard ${originYardId} not found`);
    if (!destination) {
      throw new BadRequestException(`Destination yard ${destinationYardId} not found`);
    }

    const expected = this.expectedYardCountries(appliesTo, tradeDirection);
    if (origin.country !== expected.origin || destination.country !== expected.destination) {
      const shape =
        appliesTo === 'INTERCITY' ? 'Intercity' : `${tradeDirection ?? 'Import'} freight`;
      throw new BadRequestException(
        `${shape} runs ${expected.origin} → ${expected.destination}, but ${origin.label} is in ` +
          `${origin.country} and ${destination.label} is in ${destination.country}.`,
      );
    }

    return { originYardId, destinationYardId };
  }

  /**
   * Guard the scope fields a base-freight category needs before we derive its
   * rateType: import/export must say which, and intercity must say whether it
   * carries containers or bulk (the two price differently and an unstated kind
   * would silently file the rate as one of them).
   */
  private assertScopeCoherent(input: {
    appliesTo: Rate['appliesTo'];
    trigger: Rate['trigger'];
    tradeDirection: string | null;
    intercityKind: string | null;
    cargoKind: string | null;
    containerTypeId: string | null;
    cargoTypeId: string | null;
  }): void {
    const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input;
    const { containerTypeId, cargoTypeId } = input;
    if (trigger === 'CUSTOMS_CLEARANCE') {
      if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
        throw new BadRequestException(
          'A customs clearance rate must say whether it covers IMPORT or EXPORT.',
        );
      }
      // Sold per cargo kind: a container fee names the container type it covers
      // (20ft and 40ft price differently); a bulk fee carries no type at all —
      // that absence is what marks it as the bulk fee.
      if (cargoKind !== 'CONTAINER' && cargoKind !== 'BULK') {
        throw new BadRequestException(
          'A customs clearance rate must say whether it covers containers or bulk.',
        );
      }
      if (cargoKind === 'CONTAINER' && !containerTypeId) {
        throw new BadRequestException(
          'A container customs clearance rate must name the container type it covers.',
        );
      }
      if (cargoKind === 'BULK' && containerTypeId) {
        throw new BadRequestException(
          'A bulk customs clearance rate cannot be scoped to a container type.',
        );
      }
      // The bulk customs fee names the commodity it covers (sugar and
      // fertilizer clear differently).
      if (cargoKind === 'BULK' && !cargoTypeId) {
        throw new BadRequestException(
          'A bulk customs clearance rate must name the bulk cargo type it covers.',
        );
      }
      if (cargoKind === 'CONTAINER' && cargoTypeId) {
        throw new BadRequestException(
          'A container customs clearance rate cannot be scoped to a bulk cargo type.',
        );
      }
      return;
    }
    if (trigger === 'LASHING') {
      // Bulk-only cargo securing, sold per direction. May narrow to one leaf
      // commodity (specific wins over the commodity-wide catch-all).
      if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
        throw new BadRequestException(
          'A lashing rate must say whether it covers IMPORT or EXPORT.',
        );
      }
      if (containerTypeId) {
        throw new BadRequestException(
          'Lashing is bulk-only — it cannot be scoped to a container type.',
        );
      }
      return;
    }
    if (trigger === 'WITH_RETURN') {
      // Returning the empty box only exists on imports (the box goes back to
      // the port) — export return rates are rejected until the business sells
      // that.
      if (tradeDirection !== 'IMPORT') {
        throw new BadRequestException(
          'An empty container return rate is import-only for now.',
        );
      }
      return;
    }
    if (!this.isBaseFreight(appliesTo, trigger)) return;

    if (appliesTo === 'INTERCITY') {
      if (intercityKind !== 'CONTAINER' && intercityKind !== 'BULK') {
        throw new BadRequestException(
          'An intercity rate must say whether it covers containers or bulk.',
        );
      }
      // The scope field has to agree with the kind, or the rate would advertise
      // one cargo kind and narrow by the other.
      if (intercityKind === 'CONTAINER' && cargoTypeId) {
        throw new BadRequestException(
          'An intercity container rate cannot be scoped to a bulk cargo type.',
        );
      }
      if (intercityKind === 'BULK' && containerTypeId) {
        throw new BadRequestException(
          'An intercity bulk rate cannot be scoped to a container type.',
        );
      }
      return;
    }

    if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
      throw new BadRequestException(
        `${appliesTo === 'BULK' ? 'Bulk' : 'Container'} freight must be either IMPORT or EXPORT.`,
      );
    }
  }

  /**
   * Whether a rate covers bulk cargo — the flag `deriveRateType` splits
   * INTERCITY_BULK from INTERCITY_CONTAINER on. Intercity states its kind
   * explicitly; for BULK/CONTAINER the category already says it.
   */
  private resolvesToBulk(appliesTo: Rate['appliesTo'], intercityKind: string | null): boolean {
    return appliesTo === 'INTERCITY' ? intercityKind === 'BULK' : appliesTo === 'BULK';
  }

  /**
   * Reject a second rate with the same identity pattern (rateType + scope). With
   * effective-date windows gone, two LIVE/DRAFT rates for the same pattern would
   * make pricing ambiguous — so we allow exactly one per pattern.
   *
   * The UNIT is not part of that identity. Pricing resolves one rate per lane +
   * scope and then applies whatever unit it carries; a per-container and a
   * per-wagon row for the same 20ft lane are two answers to one question, and
   * the engine silently picked one of them. Changing how a lane is billed means
   * editing its rate, not adding a second.
   */
  private async assertNoDuplicatePattern(pattern: {
    rateType: string;
    /** Passed only for additive surcharges — see {@link resolvesSingleRate}. */
    rateUnit?: string;
    containerTypeId: string | null;
    cargoTypeId: string | null;
    tradeDirection: string | null;
    originYardId: string | null;
    destinationYardId: string | null;
    ignoreId?: string;
  }): Promise<void> {
    const existing = await this.repository.findByPattern(pattern);
    if (existing && existing.id !== pattern.ignoreId) {
      throw new ConflictException(
        'A rate for this exact combination already exists on this route. Edit or delete the existing rate instead of creating a duplicate.',
      );
    }
  }

  /** Create a rate in DRAFT status. */
  async create(dto: CreateRateDto, proposedByStaffId: string): Promise<Rate> {
    const appliesTo = dto.appliesTo as Rate['appliesTo'];
    const trigger = dto.trigger as Rate['trigger'];
    // Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so
    // the engine never accidentally narrows a surcharge by container/direction.
    // Exceptions: customs clearance and empty-container return keep direction +
    // container type — both are sold per lane (and per container type).
    const isSurcharge = trigger !== 'ALWAYS';
    const cargoKind =
      trigger === 'CUSTOMS_CLEARANCE'
        ? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null)
        : null;
    const containerTypeId =
      trigger === 'WITH_RETURN' ||
      (trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER')
        ? (dto.containerTypeId ?? null)
        : isSurcharge
          ? null
          : (dto.containerTypeId ?? null);
    const cargoTypeId =
      (trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
      trigger === 'LASHING'
        ? (dto.cargoTypeId ?? null)
        : isSurcharge
          ? null
          : (dto.cargoTypeId ?? null);
    // Intercity never leaves Ethiopia, so it has no trade direction to store —
    // its yard pair already says where it runs.
    const tradeDirection =
      trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING'
        ? (dto.tradeDirection ?? null)
        : isSurcharge || appliesTo === 'INTERCITY'
          ? null
          : (dto.tradeDirection ?? null);

    const intercityKind = dto.intercityKind ?? null;
    this.assertScopeCoherent({
      appliesTo,
      trigger,
      tradeDirection,
      intercityKind,
      cargoKind,
      containerTypeId,
      cargoTypeId,
    });
    const { originYardId, destinationYardId } = await this.resolveYardScope({
      appliesTo,
      trigger,
      tradeDirection,
      originYardId: dto.originYardId,
      destinationYardId: dto.destinationYardId,
    });

    const rateType = deriveRateType({
      appliesTo,
      trigger,
      tradeDirection,
      isBulk: this.resolvesToBulk(appliesTo, intercityKind),
    });
    const rateUnit = await this.resolveRateUnit(
      appliesTo,
      trigger,
      dto.rateUnit as Rate['rateUnit'] | undefined,
      cargoKind,
      cargoTypeId,
    );

    await this.assertNoDuplicatePattern({
      rateType,
      ...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
      containerTypeId,
      cargoTypeId,
      tradeDirection,
      originYardId,
      destinationYardId,
    });

    return this.repository.create({
      appliesTo,
      trigger,
      rateType,
      containerTypeId,
      cargoTypeId,
      tradeDirection,
      originYardId,
      destinationYardId,
      currency: dto.currency ?? 'USD',
      rateValue: dto.rateValue,
      rateUnit,
      status: 'DRAFT',
      proposedByStaffId,
    });
  }

  /**
   * Update a DRAFT rate in place. Nothing prices off a draft, so a direct edit
   * is safe. A LIVE rate cannot take this path — see `applyApprovedUpdate`.
   */
  async update(id: string, dto: UpdateRateDto): Promise<Rate> {
    const existing = await this.findById(id);
    if (existing.status !== 'DRAFT') {
      throw new BadRequestException(
        existing.status === 'LIVE'
          ? 'A LIVE rate cannot be edited directly — file a rate change request so an approver can apply it.'
          : 'Only DRAFT rates can be updated',
      );
    }
    return this.applyUpdate(existing, dto);
  }

  /**
   * Apply an approved change request to a LIVE rate. Same validation as a
   * DRAFT edit — it just skips the DRAFT guard, because a LIVE rate reaching
   * here has already been through approval. Only ever called by
   * RateChangeRequestsService.approve.
   */
  async applyApprovedUpdate(id: string, dto: UpdateRateDto): Promise<Rate> {
    const existing = await this.findById(id);
    if (existing.status !== 'LIVE') {
      throw new BadRequestException(
        `Rate change requests apply to LIVE rates only — this rate is ${existing.status}.`,
      );
    }
    return this.applyUpdate(existing, dto);
  }

  /**
   * Validate a proposed patch against a rate without writing anything — lets a
   * change request be refused at submit time instead of surprising the
   * approver. Throws exactly what applying it would throw.
   */
  async assertUpdateValid(id: string, dto: UpdateRateDto): Promise<void> {
    await this.buildUpdate(await this.findById(id), dto);
  }

  private async applyUpdate(existing: Rate, dto: UpdateRateDto): Promise<Rate> {
    const updates = await this.buildUpdate(existing, dto);
    const updated = await this.repository.update(existing.id, updates);
    if (!updated) throw new NotFoundException(`Rate ${existing.id} not found`);
    return updated;
  }

  /**
   * The shared edit body: re-derives rateType, re-validates the unit against
   * the (possibly changed) shape, and guards pattern uniqueness. Status is
   * never touched — an approved edit to a LIVE rate stays LIVE. Pure apart
   * from the uniqueness read, so it doubles as the dry-run validator.
   */
  private async buildUpdate(existing: Rate, dto: UpdateRateDto): Promise<Partial<Rate>> {
    const id = existing.id;
    const updates: Partial<Rate> = {};

    const appliesTo = (dto.appliesTo as Rate['appliesTo']) ?? existing.appliesTo;
    const trigger = (dto.trigger as Rate['trigger']) ?? existing.trigger;
    const isSurcharge = trigger !== 'ALWAYS';

    if (dto.appliesTo) updates.appliesTo = appliesTo;
    if (dto.trigger) updates.trigger = trigger;

    // A patch that leaves the cargo kind unsaid keeps the one the rate already
    // has — read back off its container scope (container fees carry the type).
    const cargoKind =
      trigger !== 'CUSTOMS_CLEARANCE'
        ? null
        : ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ??
          (existing.containerTypeId ? 'CONTAINER' : 'BULK'));

    const keepsContainerType =
      !isSurcharge ||
      trigger === 'WITH_RETURN' ||
      (trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER');
    const containerTypeId = !keepsContainerType
      ? null
      : dto.containerTypeId !== undefined
        ? dto.containerTypeId
        : existing.containerTypeId;
    const keepsCargoType =
      !isSurcharge ||
      (trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
      trigger === 'LASHING';
    const cargoTypeId = !keepsCargoType
      ? null
      : dto.cargoTypeId !== undefined
        ? dto.cargoTypeId
        : existing.cargoTypeId;
    const tradeDirection =
      trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING'
        ? dto.tradeDirection !== undefined
          ? dto.tradeDirection
          : existing.tradeDirection
        : isSurcharge || appliesTo === 'INTERCITY'
          ? null
          : dto.tradeDirection !== undefined
            ? dto.tradeDirection
            : existing.tradeDirection;

    updates.containerTypeId = containerTypeId ?? null;
    updates.cargoTypeId = cargoTypeId ?? null;
    updates.tradeDirection = tradeDirection ?? null;

    // A patch that leaves the cargo kind unsaid keeps the one the rate already
    // has — read back off its rateType, the only place it is recorded.
    const intercityKind =
      dto.intercityKind ?? (existing.rateType === 'INTERCITY_BULK' ? 'BULK' : 'CONTAINER');

    this.assertScopeCoherent({
      appliesTo,
      trigger,
      tradeDirection: updates.tradeDirection,
      intercityKind,
      cargoKind,
      containerTypeId: updates.containerTypeId,
      cargoTypeId: updates.cargoTypeId,
    });
    // Re-validate the leg: changing direction can invalidate a yard pair that
    // was legal under the old one (an import route is not an export route).
    const yardScope = await this.resolveYardScope({
      appliesTo,
      trigger,
      tradeDirection: updates.tradeDirection,
      originYardId:
        dto.originYardId !== undefined ? dto.originYardId : existing.originYardId,
      destinationYardId:
        dto.destinationYardId !== undefined
          ? dto.destinationYardId
          : existing.destinationYardId,
    });
    updates.originYardId = yardScope.originYardId;
    updates.destinationYardId = yardScope.destinationYardId;

    // Keep the derived rateType in sync with whatever changed.
    const rateType = deriveRateType({
      appliesTo,
      trigger,
      tradeDirection,
      isBulk: this.resolvesToBulk(appliesTo, intercityKind),
    });
    updates.rateType = rateType;

    // Re-validate the unit against the (possibly changed) shape; overweight is
    // forced to PER_TON.
    const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit;
    const rateUnit = await this.resolveRateUnit(
      appliesTo,
      trigger,
      requestedUnit,
      cargoKind,
      updates.cargoTypeId,
    );
    updates.rateUnit = rateUnit;

    // Guard the pattern uniqueness for the new identity, ignoring this row.
    await this.assertNoDuplicatePattern({
      rateType,
      ...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
      containerTypeId: updates.containerTypeId,
      cargoTypeId: updates.cargoTypeId,
      tradeDirection: updates.tradeDirection,
      originYardId: updates.originYardId,
      destinationYardId: updates.destinationYardId,
      ignoreId: id,
    });

    updates.currency = dto.currency ?? existing.currency ?? 'USD';
    if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue;
    return updates;
  }

  /** Submit a DRAFT rate for CEO approval. */
  async submitForApproval(id: string): Promise<Rate> {
    const rate = await this.findById(id);
    if (rate.status !== 'DRAFT') {
      throw new BadRequestException('Only DRAFT rates can be submitted for approval');
    }
    const updated = await this.repository.update(id, { status: 'PENDING_APPROVAL' });
    return updated!;
  }

  /** CEO approves a rate — moves to LIVE. */
  async approve(id: string, approverUserId: string, canSelfApprove = false): Promise<Rate> {
    const rate = await this.findById(id);
    if (rate.status !== 'PENDING_APPROVAL') {
      throw new BadRequestException('Only PENDING_APPROVAL rates can be approved');
    }
    // Separation of duties: the proposer cannot approve their own rate — except
    // super admins, who have full backoffice authority (propose + approve).
    // TODO: split approval into a distinct CEO/approver permission — a normal
    // proposer who also holds the approve permission is still the wrong signer.
    if (!canSelfApprove && approverUserId === rate.proposedByStaffId) {
      throw new ForbiddenException('You cannot approve a rate you proposed');
    }
    const updated = await this.repository.update(id, {
      status: 'LIVE',
      approvedByCeoId: approverUserId,
      approvedAt: new Date(),
    });
    return updated!;
  }

  /** Soft-delete a rate. */
  async remove(id: string): Promise<void> {
    await this.findById(id);
    await this.repository.softDelete(id);
  }
}
