import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { ExchangeService } from '@edr/api-common';
import { NotificationAudience, NotificationType } from '@edr/types';
import { DataSource } from 'typeorm';

import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
import { FeeRuleBasis, FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity';
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';

interface ItemAttributes {
  arrivedAt: Date | null;
  gateClearedAt: Date | null;
  releaseDate: Date | null;
  freightType: string | null;
  tradeDirection: string | null;
  cargoTypeCode: string | null;
  containerTypeCode: string | null;
  /** Vehicle type of the truck (truck detention scoping); null otherwise. */
  vehicleType: string | null;
  inventoryQuantity: number;
  /** This inventory row's own net weight (tonnes) — bulk STORAGE/DEMURRAGE for PER_TON cargo bills against this, not the booking-wide total. */
  inventoryWeight: number;
  bookingContainerCount: number;
  /** This item's cargo type unit of measure (PER_TON | PER_ITEM); null defaults to PER_TON. Decides whether bulk day-based fees bill by weight or item count. */
  cargoUnitOfMeasure: string | null;
  /** Booking-level Yes/No recorded after unloading; only true bills double handling (null = undecided). */
  doubleHandling: boolean | null;
  facilityId: string | null;
  warehouseId: string | null;
  yardId: string | null;
  zoneId: string | null;
}

export type AccrualAlert = 'OK' | 'WARNING' | 'CHARGING';

export interface AccrualDashboardRow {
  inventoryId: string;
  status: string;
  bookingId: string | null;
  companyId: string | null;
  bookingReference: string | null;
  customerName: string | null;
  warehouseCode: string | null;
  zoneCode: string | null;
  receivedAt: string | null;
  currency: string;
  accruedAmount: number;
  freeDaysLeft: number | null;
  charging: boolean;
  alert: AccrualAlert;
  /** Reviewed by ops — suppressed from alerts (snoozed until snoozeUntil, or indefinitely). */
  acknowledged: boolean;
  snoozeUntil: string | null;
  breakdown: Array<{
    type: FeeRuleType;
    amount: number;
    freeDays: number;
    elapsedDays: number;
    chargeableDays: number;
  }>;
}

export interface FeePreview {
  ruleType: FeeRuleType;
  /** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_ITEM); null otherwise. */
  basis: FeeRuleBasis | null;
  ruleId: string | null;
  ruleName: string | null;
  freeDays: number;
  ratePerDay: number;
  currency: string;
  ruleCurrency: string | null;
  billingCurrency: string;
  startDate: string | null;
  endDate: string;
  endIsOpen: boolean; // true when still accruing (no release/gate-clear yet)
  elapsedDays: number;
  chargeableDays: number;
  containerCount: number;
  /** What `containerCount`/`billableUnits` are counted in — 'container' | 'truck' | 'ton' | 'item'. Bulk cargo bills by weight (ton) or item count depending on the cargo type's unit of measure. */
  unitLabel: string;
  billableUnits: number;
  amount: number;
  tiers: Array<{
    fromDay: number;
    toDay: number | null;
    appliedFromDay: number;
    appliedToDay: number;
    days: number;
    ratePerDay: number;
    amount: number;
  }>;
  /** Truck detention: per-vehicle-type breakdown — each truck-type group billed by its own matching rule. */
  groups?: Array<{
    vehicleType: string | null;
    truckCount: number;
    chargeableDays: number;
    ratePerDay: number;
    amount: number;
    ruleId: string | null;
    ruleName: string | null;
  }>;
}

const MS_PER_DAY = 24 * 60 * 60 * 1000;

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

  constructor(
    private readonly dataSource: DataSource,
    private readonly feeRuleRepository: WarehouseFeeRuleRepository,
    private readonly exchangeService: ExchangeService,
    private readonly inbox: NotificationInboxService,
  ) {}

  /**
   * Daily accrual alerts: for every in-warehouse item that is charging or within
   * its last free days, send the customer an in-app notification with the
   * outstanding accrued amount so they can collect before (more) charges hit.
   */
  @Cron(CronExpression.EVERY_DAY_AT_6AM, { name: 'warehouse-accrual-alert' })
  async sendAccrualAlerts(): Promise<void> {
    try {
      const alerts = (await this.accrualDashboard()).filter(
        (r) => r.alert !== 'OK' && !r.acknowledged,
      );
      if (!alerts.length) return;
      this.logger.log(`Accrual alerts: ${alerts.length} item(s) charging or nearing charges`);

      // Per-customer: notify each company about its own items.
      for (const row of alerts.filter((r) => r.companyId)) {
        const ref = row.bookingReference ?? row.inventoryId.slice(0, 8);
        const amount = `${row.accruedAmount.toFixed(2)} ${row.currency}`;
        const body = row.charging
          ? `Storage/demurrage is now charging on booking ${ref} — ${amount} accrued. Collect the cargo to stop further charges.`
          : `Booking ${ref} has ${row.freeDaysLeft ?? 0} free day(s) left before storage/demurrage charges start (${amount} accrued so far).`;
        try {
          await this.inbox.notify({
            recipients: { companyId: row.companyId! },
            audience: NotificationAudience.PORTAL,
            type: NotificationType.BOOKING_STATUS,
            title: row.charging ? 'Storage charges accruing' : 'Free days ending soon',
            body,
            link: row.bookingId ? `/bookings/${row.bookingId}` : undefined,
            data: {
              inventoryId: row.inventoryId,
              bookingId: row.bookingId,
              alert: row.alert,
              accruedAmount: row.accruedAmount,
              action: 'ACCRUAL_ALERT',
            },
          });
        } catch (err) {
          this.logger.warn(
            `Accrual alert failed for ${row.inventoryId}: ${(err as Error).message}`,
          );
        }
      }

      // Ops staff: one digest covering every alerting item.
      const charging = alerts.filter((r) => r.charging).length;
      const nearing = alerts.length - charging;
      const currency = alerts[0]?.currency ?? 'USD';
      const total = alerts.reduce((sum, r) => sum + r.accruedAmount, 0);
      try {
        await this.inbox.notify({
          recipients: { allBackoffice: true },
          audience: NotificationAudience.BACKOFFICE,
          type: NotificationType.BOOKING_STATUS,
          title: 'Warehouse fee accruals need attention',
          body: `${charging} item(s) charging, ${nearing} nearing the free-day limit — ${total.toFixed(2)} ${currency} accruing. Review the accrual dashboard.`,
          link: '/dashboard/warehouse-fee-invoices',
          data: { charging, nearing, totalAccrued: Math.round(total * 100) / 100, action: 'ACCRUAL_ALERT_DIGEST' },
        });
      } catch (err) {
        this.logger.warn(`Accrual staff digest failed: ${(err as Error).message}`);
      }
    } catch (err) {
      this.logger.warn(`Accrual alert tick failed: ${(err as Error).message}`);
    }
  }

  // ── Rule CRUD ──────────────────────────────────────────────────────────────
  listRules(): Promise<WarehouseFeeRule[]> {
    return this.feeRuleRepository.findAll({ order: { ruleType: 'ASC', priority: 'ASC' } });
  }

  createRule(dto: CreateFeeRuleDto): Promise<WarehouseFeeRule> {
    return this.feeRuleRepository.create({
      isActive: true,
      priority: 100,
      currency: 'USD',
      ...this.normalizeRuleInput(dto),
    });
  }

  async updateRule(id: string, dto: UpdateFeeRuleDto): Promise<WarehouseFeeRule> {
    const updated = await this.feeRuleRepository.update(id, this.normalizeRuleInput(dto));
    if (!updated) throw new NotFoundException(`Fee rule ${id} not found`);
    return updated;
  }

  deleteRule(id: string): Promise<void> {
    return this.feeRuleRepository.softDelete(id);
  }

  private normalizeRuleInput<T extends CreateFeeRuleDto | UpdateFeeRuleDto>(dto: T): T {
    if (dto.tiers === undefined) return dto;
    const tiers = (dto.tiers ?? [])
      .map((tier) => ({
        fromDay: Number(tier.fromDay),
        toDay: tier.toDay == null ? null : Number(tier.toDay),
        ratePerDay: Number(tier.ratePerDay),
      }))
      .filter((tier) => tier.fromDay > 0 || tier.toDay != null || tier.ratePerDay > 0);

    for (const tier of tiers) {
      if (!Number.isInteger(tier.fromDay) || tier.fromDay < 1) {
        throw new BadRequestException('Fee tier from day must be a positive whole number.');
      }
      if (tier.toDay != null && (!Number.isInteger(tier.toDay) || tier.toDay < tier.fromDay)) {
        throw new BadRequestException('Fee tier to day must be empty or greater than/equal to from day.');
      }
      if (!Number.isFinite(tier.ratePerDay) || tier.ratePerDay < 0) {
        throw new BadRequestException('Fee tier rate per day must be zero or greater.');
      }
    }

    const sorted = [...tiers].sort((a, b) => a.fromDay - b.fromDay || (a.toDay ?? Infinity) - (b.toDay ?? Infinity));
    for (let i = 1; i < sorted.length; i += 1) {
      const prev = sorted[i - 1];
      const current = sorted[i];
      if (prev.toDay == null || current.fromDay <= prev.toDay) {
        throw new BadRequestException('Fee tiers cannot overlap. Use separate from/to day ranges.');
      }
    }

    return { ...dto, tiers: sorted } as T;
  }

  private async loadItem(inventoryId: string): Promise<ItemAttributes> {
    const [row] = await this.dataSource.query(
      `SELECT inv.arrived_at        AS "arrivedAt",
              inv.gate_cleared_at   AS "gateClearedAt",
              inv.release_date      AS "releaseDate",
              inv.quantity          AS "inventoryQuantity",
              inv.weight            AS "inventoryWeight",
              inv.warehouse_id      AS "warehouseId",
              inv.yard_id           AS "yardId",
              inv.zone_id           AS "zoneId",
              w.facility_id         AS "facilityId",
              b.freight_type        AS "freightType",
              b.trade_direction     AS "tradeDirection",
              b.double_handling     AS "doubleHandling",
              COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode",
              COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode",
              COALESCE(container_lines.container_count, 0) AS "bookingContainerCount",
              COALESCE(cgt.unit_of_measure, booking_cgt.unit_of_measure) AS "cargoUnitOfMeasure"
         FROM freight.warehouse_inventory inv
         LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
         LEFT JOIN freight.bookings b ON b.id = inv.booking_id
         LEFT JOIN freight.cargoes cg ON cg.id = inv.cargo_id
         LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id
         LEFT JOIN freight.cargo_types booking_cgt ON booking_cgt.id = b.cargo_type_id
         LEFT JOIN freight.containers ct ON ct.id = inv.container_id
         LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id
         LEFT JOIN LATERAL (
           SELECT bc.container_type_id
             FROM freight.booking_container bc
            WHERE bc.booking_id = inv.booking_id
              AND bc.deleted_at IS NULL
              AND bc.container_type_id IS NOT NULL
            ORDER BY bc.created_at ASC
            LIMIT 1
         ) booking_container_type ON true
         LEFT JOIN freight.container_types booking_ctt ON booking_ctt.id = booking_container_type.container_type_id
         LEFT JOIN LATERAL (
           SELECT COALESCE(SUM(bc.quantity), 0)::int AS container_count
             FROM freight.booking_container bc
            WHERE bc.booking_id = inv.booking_id
              AND bc.deleted_at IS NULL
         ) container_lines ON true
        WHERE inv.id = $1 AND inv.deleted_at IS NULL`,
      [inventoryId],
    );
    if (!row) throw new NotFoundException(`Inventory item ${inventoryId} not found`);
    return row;
  }

  private matchScore(rule: WarehouseFeeRule, item: ItemAttributes): number | null {
    // Returns specificity score (#matched non-null scope fields), or null if any constraint fails.
    let score = 0;
    const normalized = (value: string | null | undefined) => value?.trim().toUpperCase() || null;
    const check = (
      ruleVal: string | null | undefined,
      itemVal: string | null,
      opts: { allowBoth?: boolean } = {},
    ) => {
      const ruleCode = normalized(ruleVal);
      if (ruleCode == null || ruleCode === 'ANY' || ruleCode === 'ALL') return true;
      if (opts.allowBoth && ruleCode === 'BOTH') {
        score += 1;
        return true;
      }
      if (ruleCode === normalized(itemVal)) {
        score += 1;
        return true;
      }
      return false;
    };
    if (!check(rule.freightType, item.freightType)) return null;
    if (!check(rule.tradeDirection, item.tradeDirection, { allowBoth: true })) return null;
    if (!check(rule.cargoTypeCode, item.cargoTypeCode)) return null;
    if (!check(rule.containerType, item.containerTypeCode)) return null;
    if (!check(rule.vehicleType, item.vehicleType)) return null;
    if (!check(rule.facilityId, item.facilityId)) return null;
    if (!check(rule.warehouseId, item.warehouseId)) return null;
    if (!check(rule.yardId, item.yardId)) return null;
    if (!check(rule.zoneId, item.zoneId)) return null;
    return score;
  }

  private bestRule(rules: WarehouseFeeRule[], item: ItemAttributes): WarehouseFeeRule | null {
    let best: WarehouseFeeRule | null = null;
    let bestScore = -1;
    for (const rule of rules) {
      const score = this.matchScore(rule, item);
      if (score == null) continue;
      if (score > bestScore || (score === bestScore && best && rule.priority < best.priority)) {
        best = rule;
        bestScore = score;
      }
    }
    return best;
  }

  /**
   * On-time dispatch rate: the share of items dispatched in the last N days that
   * LEFT before their storage free-days expired — CEIL((dispatched−arrived)/day)
   * <= freeDays, with freeDays resolved by the same rule matching the fee engine
   * uses (bestRule over active STORAGE_FEE rules). onTimePct is null when there
   * is nothing to measure (e.g. no dispatched items / no storage rules).
   */
  async onTimeDispatchStats(
    windowDays = 90,
  ): Promise<{ sampleSize: number; onTimeCount: number; onTimePct: number | null }> {
    const storageRules = (
      await this.feeRuleRepository.findAll({ where: { isActive: true } })
    ).filter((r) => r.ruleType === 'STORAGE_FEE');

    // Batched attribute pull mirroring loadItem's scope joins (multi-row) — only
    // the fields bestRule/matchScore reads, plus the two clock timestamps.
    const rows: Array<
      ItemAttributes & { arrivedAt: string; dispatchedAt: string }
    > = await this.dataSource.query(
      `SELECT inv.arrived_at        AS "arrivedAt",
              inv.dispatched_at     AS "dispatchedAt",
              inv.warehouse_id      AS "warehouseId",
              inv.yard_id           AS "yardId",
              inv.zone_id           AS "zoneId",
              w.facility_id         AS "facilityId",
              b.freight_type        AS "freightType",
              b.trade_direction     AS "tradeDirection",
              COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode",
              COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode",
              NULL                  AS "vehicleType"
         FROM freight.warehouse_inventory inv
         LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
         LEFT JOIN freight.bookings b ON b.id = inv.booking_id
         LEFT JOIN freight.cargoes cg ON cg.id = inv.cargo_id
         LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id
         LEFT JOIN freight.cargo_types booking_cgt ON booking_cgt.id = b.cargo_type_id
         LEFT JOIN freight.containers ct ON ct.id = inv.container_id
         LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id
         LEFT JOIN LATERAL (
           SELECT bc.container_type_id
             FROM freight.booking_container bc
            WHERE bc.booking_id = inv.booking_id
              AND bc.deleted_at IS NULL
              AND bc.container_type_id IS NOT NULL
            ORDER BY bc.created_at ASC
            LIMIT 1
         ) booking_container_type ON true
         LEFT JOIN freight.container_types booking_ctt ON booking_ctt.id = booking_container_type.container_type_id
        WHERE inv.deleted_at IS NULL
          AND inv.arrived_at IS NOT NULL
          AND inv.dispatched_at IS NOT NULL
          AND inv.dispatched_at > now() - ($1 || ' days')::interval`,
      [windowDays],
    );

    let onTimeCount = 0;
    for (const row of rows) {
      const freeDays = this.bestRule(storageRules, row)?.freeDays ?? 0;
      const elapsed = Math.max(
        0,
        Math.ceil(
          (new Date(row.dispatchedAt).getTime() - new Date(row.arrivedAt).getTime()) / MS_PER_DAY,
        ),
      );
      if (elapsed <= freeDays) onTimeCount += 1;
    }
    const sampleSize = rows.length;
    return {
      sampleSize,
      onTimeCount,
      onTimePct: sampleSize ? Math.round((onTimeCount / sampleSize) * 100) : null,
    };
  }

  private normalizeCurrency(currency?: string | null): 'ETB' | 'USD' {
    return currency === 'ETB' ? 'ETB' : 'USD';
  }

  private async convertAmount(amount: number, fromCurrency: string, toCurrency: string): Promise<number> {
    const from = this.normalizeCurrency(fromCurrency);
    const to = this.normalizeCurrency(toCurrency);
    if (from === to) return Math.round(amount * 100) / 100;
    const rate = await this.exchangeService.getRate(from, to);
    return Math.round(amount * rate * 100) / 100;
  }

  private calculateTieredAmount(
    tiers: WarehouseFeeTier[] | null | undefined,
    elapsedDays: number,
    containerCount: number,
  ): {
    sourceAmount: number;
    billableUnits: number;
    chargeableDays: number;
    weightedRatePerDay: number;
    tiers: FeePreview['tiers'];
  } {
    const sourceTiers = (tiers ?? [])
      .map((tier) => ({
        fromDay: Number(tier.fromDay),
        toDay: tier.toDay == null ? null : Number(tier.toDay),
        ratePerDay: Number(tier.ratePerDay),
      }))
      .filter((tier) => Number.isFinite(tier.fromDay) && tier.fromDay > 0 && Number.isFinite(tier.ratePerDay))
      .sort((a, b) => a.fromDay - b.fromDay);

    let sourceAmount = 0;
    let tierDays = 0;
    const appliedTiers: FeePreview['tiers'] = [];

    for (const tier of sourceTiers) {
      if (elapsedDays < tier.fromDay) continue;
      const appliedFromDay = tier.fromDay;
      const appliedToDay = Math.min(elapsedDays, tier.toDay ?? elapsedDays);
      const days = Math.max(0, appliedToDay - appliedFromDay + 1);
      if (days <= 0) continue;

      const amount = Math.round(days * containerCount * tier.ratePerDay * 100) / 100;
      sourceAmount += amount;
      tierDays += days;
      appliedTiers.push({
        fromDay: tier.fromDay,
        toDay: tier.toDay,
        appliedFromDay,
        appliedToDay,
        days,
        ratePerDay: tier.ratePerDay,
        amount,
      });
    }

    return {
      sourceAmount: Math.round(sourceAmount * 100) / 100,
      billableUnits: tierDays * containerCount,
      chargeableDays: tierDays,
      weightedRatePerDay: tierDays > 0 ? Math.round((sourceAmount / tierDays / containerCount) * 100) / 100 : 0,
      tiers: appliedTiers,
    };
  }

  /**
   * Bulk's own billing quantity for THIS inventory row — weight (tons) for
   * PER_TON cargo, unit count for PER_ITEM cargo (Machinery, Truck, Automobile,
   * Livestock…). Shared by every cargo-scoped fee type (storage, demurrage,
   * double handling) so a booking split across several rows is never billed
   * more than once against its full total. 0 is a legitimate charge (nothing
   * weighed/counted yet), so no forced floor.
   */
  private resolveBulkQuantity(item: ItemAttributes): { quantity: number; unitLabel: string } {
    const cargoUnit = (item.cargoUnitOfMeasure ?? 'PER_TON').toUpperCase();
    if (cargoUnit === 'PER_ITEM') {
      return { quantity: Math.max(0, Number(item.inventoryQuantity) || 0), unitLabel: 'item' };
    }
    return { quantity: Math.max(0, Number(item.inventoryWeight) || 0), unitLabel: 'ton' };
  }

  private async compute(
    ruleType: FeeRuleType,
    rule: WarehouseFeeRule | null,
    item: ItemAttributes,
    now: Date,
    billingCurrency: string,
  ): Promise<FeePreview> {
    // Double handling is a flat charge (rate × basis quantity), not day-based.
    if (ruleType === 'DOUBLE_HANDLING_FEE') {
      return this.computeDoubleHandling(rule, item, now, billingCurrency);
    }
    const start = item.arrivedAt ? new Date(item.arrivedAt) : null;
    const endDate = item.gateClearedAt ?? item.releaseDate ?? now;
    const endIsOpen = !item.gateClearedAt && !item.releaseDate;
    const freeDays = rule?.freeDays ?? 0;
    const ratePerDay = Number(rule?.ratePerDay ?? 0);
    const ruleCurrency = rule ? this.normalizeCurrency(rule.currency) : null;
    const targetCurrency = this.normalizeCurrency(billingCurrency);
    const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER';
    const inventoryQuantity = Math.max(1, Math.round(Number(item.inventoryQuantity) || 1));
    const bulk = this.resolveBulkQuantity(item);
    const containerCount = isContainer
      ? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity))
      : bulk.quantity;
    const unitLabel = isContainer ? 'container' : bulk.unitLabel;

    const elapsedDays = start
      ? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY))
      : 0;
    const tiered = this.calculateTieredAmount(rule?.tiers, elapsedDays, containerCount);
    const hasTiers = Boolean(rule?.tiers?.length);
    const chargeableDays = hasTiers ? tiered.chargeableDays : Math.max(0, elapsedDays - freeDays);
    const billableUnits = hasTiers ? tiered.billableUnits : chargeableDays * containerCount;
    const sourceAmount = hasTiers ? tiered.sourceAmount : Math.round(billableUnits * ratePerDay * 100) / 100;
    const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0;
    const sourceRatePerDay = hasTiers ? tiered.weightedRatePerDay : ratePerDay;
    const convertedRatePerDay = ruleCurrency
      ? await this.convertAmount(sourceRatePerDay, ruleCurrency, targetCurrency)
      : 0;
    const convertedTiers = ruleCurrency
      ? await Promise.all(
          tiered.tiers.map(async (tier) => ({
            ...tier,
            ratePerDay: await this.convertAmount(tier.ratePerDay, ruleCurrency, targetCurrency),
            amount: await this.convertAmount(tier.amount, ruleCurrency, targetCurrency),
          })),
        )
      : [];

    return {
      ruleType,
      basis: null,
      ruleId: rule?.id ?? null,
      ruleName: rule?.name ?? null,
      freeDays,
      ratePerDay: convertedRatePerDay,
      currency: targetCurrency,
      ruleCurrency,
      billingCurrency: targetCurrency,
      startDate: start ? start.toISOString() : null,
      endDate: new Date(endDate).toISOString(),
      endIsOpen,
      elapsedDays,
      chargeableDays,
      containerCount,
      unitLabel,
      billableUnits,
      amount,
      tiers: hasTiers ? convertedTiers : [],
    };
  }

  /**
   * Double handling — a flat one-time charge, not time-based. Amount = rate ×
   * the basis quantity: PER_CONTAINER (booking container count), or PER_TON /
   * PER_ITEM (the booking cargo total in the cargo's unit of measure — tonnes
   * for bulk, item count for break-bulk). No free days, no elapsed days, no tiers.
   */
  private async computeDoubleHandling(
    rule: WarehouseFeeRule | null,
    item: ItemAttributes,
    now: Date,
    billingCurrency: string,
  ): Promise<FeePreview> {
    const basis: FeeRuleBasis = rule?.basis ?? 'PER_CONTAINER';
    const rate = Number(rule?.ratePerDay ?? 0);
    const ruleCurrency = rule ? this.normalizeCurrency(rule.currency) : null;
    const targetCurrency = this.normalizeCurrency(billingCurrency);
    const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER';
    const inventoryQuantity = Math.max(1, Math.round(Number(item.inventoryQuantity) || 1));
    const containerCount = isContainer
      ? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity))
      : 1;
    // PER_TON (tonnes) and PER_ITEM (piece count) both read THIS row's own
    // weight/count — never the whole booking's total. previewForInventory()
    // computes double handling once per inventory row, so a booking-wide total
    // would double- (or triple-) bill a booking split across several rows.
    const bulk = this.resolveBulkQuantity(item);
    // Double handling applies to IMPORT only — no charge for export/domestic —
    // AND only when warehouse staff recorded that the goods were actually
    // re-handled (booking flag = Yes after unloading). Undecided (null) or No
    // means no charge, so the rule can exist without billing every import.
    const isImport = (item.tradeDirection ?? '').toUpperCase() === 'IMPORT';
    const applies = isImport && item.doubleHandling === true;
    const quantity = !applies ? 0 : basis === 'PER_CONTAINER' ? containerCount : bulk.quantity;
    const unitLabel = basis === 'PER_CONTAINER' ? 'container' : bulk.unitLabel;
    const sourceAmount = Math.round(rate * quantity * 100) / 100;
    const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0;
    const convertedRate = ruleCurrency ? await this.convertAmount(rate, ruleCurrency, targetCurrency) : 0;

    return {
      ruleType: 'DOUBLE_HANDLING_FEE',
      basis,
      ruleId: rule?.id ?? null,
      ruleName: rule?.name ?? null,
      freeDays: 0,
      ratePerDay: convertedRate,
      currency: targetCurrency,
      ruleCurrency,
      billingCurrency: targetCurrency,
      startDate: null,
      endDate: now.toISOString(),
      endIsOpen: false,
      elapsedDays: 0,
      chargeableDays: 0,
      containerCount,
      unitLabel,
      billableUnits: quantity,
      amount,
      tiers: [],
    };
  }

  /**
   * Live accrual dashboard: for every item still in the warehouse, the fees
   * accruing right now (storage + demurrage + double-handling), how many free
   * days remain, and an alert level so staff can act before charges land.
   */
  async accrualDashboard(billingCurrency = 'USD'): Promise<AccrualDashboardRow[]> {
    const items: Array<{
      id: string;
      status: string;
      bookingId: string | null;
      companyId: string | null;
      bookingReference: string | null;
      customerName: string | null;
      warehouseCode: string | null;
      zoneCode: string | null;
      receivedAt: string | null;
    }> = await this.dataSource.query(
      `SELECT inv.id,
              inv.status,
              b.id AS "bookingId",
              b.company_id AS "companyId",
              b.reference AS "bookingReference",
              c.name AS "customerName",
              w.code AS "warehouseCode",
              z.code AS "zoneCode",
              inv.created_at AS "receivedAt"
         FROM freight.warehouse_inventory inv
         LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
         LEFT JOIN freight.companies c ON c.id = b.company_id
         LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
         LEFT JOIN freight.warehouse_zones z ON z.id = inv.zone_id
        WHERE inv.deleted_at IS NULL
          AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP', 'RESERVED')
        ORDER BY inv.created_at ASC`,
    );

    const ackRows: Array<{ inventoryId: string; snoozeUntil: string | null }> =
      await this.dataSource.query(
        `SELECT inventory_id AS "inventoryId", snooze_until AS "snoozeUntil"
           FROM freight.warehouse_accrual_acks`,
      );
    const now = new Date();
    const acks = new Map(ackRows.map((a) => [a.inventoryId, a.snoozeUntil]));

    const rows = await Promise.all(
      items.map(async (it): Promise<AccrualDashboardRow> => {
        const previews = (await this.previewForInventory(it.id, billingCurrency)).filter(
          (p) => p.ruleId,
        );
        const accruedAmount =
          Math.round(previews.reduce((sum, p) => sum + (p.amount ?? 0), 0) * 100) / 100;
        const charging = previews.some((p) => p.chargeableDays > 0);
        const freeDaysLeftVals = previews
          .filter((p) => p.endIsOpen)
          .map((p) => Math.max(0, p.freeDays - p.elapsedDays));
        const freeDaysLeft = freeDaysLeftVals.length ? Math.min(...freeDaysLeftVals) : null;
        const alert: AccrualAlert = charging
          ? 'CHARGING'
          : freeDaysLeft != null && freeDaysLeft <= 2
            ? 'WARNING'
            : 'OK';
        return {
          inventoryId: it.id,
          status: it.status,
          bookingId: it.bookingId,
          companyId: it.companyId,
          bookingReference: it.bookingReference,
          customerName: it.customerName,
          warehouseCode: it.warehouseCode,
          zoneCode: it.zoneCode,
          receivedAt: it.receivedAt,
          currency: billingCurrency,
          accruedAmount,
          freeDaysLeft,
          charging,
          alert,
          acknowledged:
            acks.has(it.id) &&
            (acks.get(it.id) == null || new Date(acks.get(it.id) as string) > now),
          snoozeUntil: acks.get(it.id) ?? null,
          breakdown: previews.map((p) => ({
            type: p.ruleType,
            amount: p.amount,
            freeDays: p.freeDays,
            elapsedDays: p.elapsedDays,
            chargeableDays: p.chargeableDays,
          })),
        };
      }),
    );

    const rank = (a: AccrualAlert) => (a === 'CHARGING' ? 0 : a === 'WARNING' ? 1 : 2);
    // Acknowledged items sink to the bottom; among the rest, worst alert first.
    return rows.sort(
      (a, b) =>
        Number(a.acknowledged) - Number(b.acknowledged) ||
        rank(a.alert) - rank(b.alert) ||
        b.accruedAmount - a.accruedAmount,
    );
  }

  /** Mark an item's accrual reviewed. `snoozeDays` > 0 suppresses alerts until then; omitted = indefinitely. */
  async acknowledgeAccrual(
    inventoryId: string,
    opts: { snoozeDays?: number; note?: string; userId?: string } = {},
  ): Promise<void> {
    const snoozeUntil =
      opts.snoozeDays && opts.snoozeDays > 0
        ? new Date(Date.now() + opts.snoozeDays * 24 * 60 * 60 * 1000)
        : null;
    await this.dataSource.query(
      `INSERT INTO freight.warehouse_accrual_acks
         (inventory_id, acknowledged_by, acknowledged_at, snooze_until, note, updated_at)
       VALUES ($1, $2, now(), $3, $4, now())
       ON CONFLICT (inventory_id) DO UPDATE
          SET acknowledged_by = EXCLUDED.acknowledged_by,
              acknowledged_at = now(),
              snooze_until = EXCLUDED.snooze_until,
              note = EXCLUDED.note,
              updated_at = now()`,
      [inventoryId, opts.userId ?? null, snoozeUntil, opts.note?.trim() || null],
    );
  }

  /** Remove an acknowledgement so the item re-surfaces for alerts. */
  async unacknowledgeAccrual(inventoryId: string): Promise<void> {
    await this.dataSource.query(
      `DELETE FROM freight.warehouse_accrual_acks WHERE inventory_id = $1`,
      [inventoryId],
    );
  }

  /** Preview demurrage + storage fees for an inventory item using the most specific active rules. */
  async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise<FeePreview[]> {
    const item = await this.loadItem(inventoryId);
    const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
    const now = new Date();

    // Truck detention is a per-truck last-mile charge, not a per-inventory fee —
    // it is computed separately via previewTruckDetention(), not here.
    const byType: FeeRuleType[] = ['DEMURRAGE_FEE', 'STORAGE_FEE', 'DOUBLE_HANDLING_FEE'];
    return Promise.all(
      byType.map((type) =>
        this.compute(
          type,
          this.bestRule(rules.filter((r) => r.ruleType === type), item),
          item,
          now,
          billingCurrency,
        ),
      ),
    );
  }

  /**
   * Truck detention preview for an EDR last-mile leg. The vehicle should be
   * returned within the rule's grace window (default 3h) of arriving; beyond
   * that, detention accrues per truck per day (flat rate/day or progressive
   * tiers by detention day) until it is delivered/returned (or now, if open).
   */
  async previewTruckDetention(lastMileId: string, billingCurrency = 'USD'): Promise<FeePreview> {
    const [leg] = await this.dataSource.query(
      `SELECT lm.arrived_at   AS "arrivedAt",
              lm.delivered_at AS "deliveredAt",
              b.freight_type  AS "freightType",
              b.trade_direction AS "tradeDirection"
         FROM freight.last_mile lm
         LEFT JOIN freight.bookings b ON b.id = lm.booking_id
        WHERE lm.id = $1 AND lm.deleted_at IS NULL`,
      [lastMileId],
    );
    if (!leg) throw new NotFoundException(`Last-mile record ${lastMileId} not found`);

    // Truck detention applies to IMPORT only — no charge for export/domestic.
    if ((leg.tradeDirection ?? '').toUpperCase() !== 'IMPORT') {
      const cur = this.normalizeCurrency(billingCurrency);
      return {
        ruleType: 'TRUCK_DETENTION_FEE',
        basis: null,
        unitLabel: 'truck',
        ruleId: null,
        ruleName: null,
        freeDays: 0,
        ratePerDay: 0,
        currency: cur,
        ruleCurrency: null,
        billingCurrency: cur,
        startDate: leg.arrivedAt ? new Date(leg.arrivedAt).toISOString() : null,
        endDate: (leg.deliveredAt ? new Date(leg.deliveredAt) : new Date()).toISOString(),
        endIsOpen: !leg.deliveredAt,
        elapsedDays: 0,
        chargeableDays: 0,
        containerCount: 0,
        billableUnits: 0,
        amount: 0,
        tiers: [],
        groups: [],
      };
    }

    // Group the leg's vehicles by CANONICAL truck type so each type is billed
    // by its own matching rule (rates differ by truck type). The FK to
    // truck_types is the source of truth — renaming a type's label no longer
    // silently unmatches its rule; the normalized legacy vehicle_type code is
    // only a fallback for vehicles without the FK (LEFT JOIN keeps them billed
    // instead of dropping them). Falls back to one untyped group.
    const groupRows: Array<{ vehicleType: string | null; truckCount: number | string }> =
      await this.dataSource.query(
        `SELECT COALESCE(t.code, NULLIF(UPPER(TRIM(v.vehicle_type)), '')) AS "vehicleType",
                count(*)::int AS "truckCount"
           FROM freight.last_mile_vehicle_assignments va
           JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL
           LEFT JOIN freight.truck_types t
             ON t.id = v.truck_type_id AND t.deleted_at IS NULL
          WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL
          GROUP BY 1`,
        [lastMileId],
      );
    const groups = groupRows.length ? groupRows : [{ vehicleType: null, truckCount: 1 }];

    const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
    const detentionRules = rules.filter((r) => r.ruleType === 'TRUCK_DETENTION_FEE');
    const now = new Date();
    const targetCurrency = this.normalizeCurrency(billingCurrency);

    const computed = await Promise.all(
      groups.map(async (g) => {
        const item: ItemAttributes = {
          arrivedAt: null,
          gateClearedAt: null,
          releaseDate: null,
          freightType: leg.freightType ?? null,
          tradeDirection: leg.tradeDirection ?? null,
          cargoTypeCode: null,
          containerTypeCode: null,
          vehicleType: g.vehicleType ?? null,
          inventoryQuantity: 1,
          inventoryWeight: 0,
          bookingContainerCount: 1,
          cargoUnitOfMeasure: null,
          // Irrelevant to detention (truck-time based, never double handling).
          doubleHandling: null,
          facilityId: null,
          warehouseId: null,
          yardId: null,
          zoneId: null,
        };
        const rule = this.bestRule(detentionRules, item);
        const c = await this.computeTruckDetention(
          rule,
          { arrivedAt: leg.arrivedAt, deliveredAt: leg.deliveredAt, truckCount: g.truckCount },
          now,
          billingCurrency,
        );
        return { vehicleType: g.vehicleType ?? null, truckCount: Math.max(1, Math.round(Number(g.truckCount) || 1)), c };
      }),
    );

    const totalAmount = Math.round(computed.reduce((s, x) => s + x.c.amount, 0) * 100) / 100;
    const totalTrucks = computed.reduce((s, x) => s + x.truckCount, 0);
    const totalBillable = computed.reduce((s, x) => s + x.c.billableUnits, 0);
    const chargeableDays = computed[0]?.c.chargeableDays ?? 0;
    const single = computed.length === 1 ? computed[0].c : null;
    const anyRuleName = computed.find((x) => x.c.ruleId)?.c.ruleName ?? null;

    return {
      ruleType: 'TRUCK_DETENTION_FEE',
      basis: null,
      unitLabel: 'truck',
      ruleId: single?.ruleId ?? null,
      ruleName: single ? single.ruleName : computed.length > 1 && anyRuleName ? 'Per truck-type rules' : anyRuleName,
      freeDays: 0,
      ratePerDay: single?.ratePerDay ?? 0,
      currency: targetCurrency,
      ruleCurrency: single?.ruleCurrency ?? null,
      billingCurrency: targetCurrency,
      startDate: leg.arrivedAt ? new Date(leg.arrivedAt).toISOString() : null,
      endDate: (leg.deliveredAt ? new Date(leg.deliveredAt) : now).toISOString(),
      endIsOpen: !leg.deliveredAt,
      elapsedDays: chargeableDays,
      chargeableDays,
      containerCount: totalTrucks,
      billableUnits: totalBillable,
      amount: totalAmount,
      tiers: single ? single.tiers : [],
      groups: computed.map((x) => ({
        vehicleType: x.vehicleType,
        truckCount: x.truckCount,
        chargeableDays: x.c.chargeableDays,
        ratePerDay: x.c.ratePerDay,
        amount: x.c.amount,
        ruleId: x.c.ruleId,
        ruleName: x.c.ruleName,
      })),
    };
  }

  private async computeTruckDetention(
    rule: WarehouseFeeRule | null,
    row: { arrivedAt: Date | string | null; deliveredAt: Date | string | null; truckCount: number | string },
    now: Date,
    billingCurrency: string,
  ): Promise<FeePreview> {
    const graceHours = rule?.freeHours && Number(rule.freeHours) > 0 ? Number(rule.freeHours) : 3;
    const truckCount = Math.max(1, Math.round(Number(row.truckCount) || 1));
    const start = row.arrivedAt ? new Date(row.arrivedAt) : null;
    const end = row.deliveredAt ? new Date(row.deliveredAt) : now;
    const endIsOpen = !row.deliveredAt;

    let chargeableDays = 0;
    if (start) {
      const detentionMs = end.getTime() - start.getTime() - graceHours * 60 * 60 * 1000;
      chargeableDays = detentionMs > 0 ? Math.ceil(detentionMs / MS_PER_DAY) : 0;
    }

    const ratePerDay = Number(rule?.ratePerDay ?? 0);
    const ruleCurrency = rule ? this.normalizeCurrency(rule.currency) : null;
    const targetCurrency = this.normalizeCurrency(billingCurrency);
    const hasTiers = Boolean(rule?.tiers?.length);
    const tiered = this.calculateTieredAmount(rule?.tiers, chargeableDays, truckCount);
    const billableUnits = hasTiers ? tiered.billableUnits : chargeableDays * truckCount;
    const sourceAmount = hasTiers ? tiered.sourceAmount : Math.round(billableUnits * ratePerDay * 100) / 100;
    const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0;
    const sourceRatePerDay = hasTiers ? tiered.weightedRatePerDay : ratePerDay;
    const convertedRatePerDay = ruleCurrency
      ? await this.convertAmount(sourceRatePerDay, ruleCurrency, targetCurrency)
      : 0;
    const convertedTiers = ruleCurrency
      ? await Promise.all(
          tiered.tiers.map(async (tier) => ({
            ...tier,
            ratePerDay: await this.convertAmount(tier.ratePerDay, ruleCurrency, targetCurrency),
            amount: await this.convertAmount(tier.amount, ruleCurrency, targetCurrency),
          })),
        )
      : [];

    return {
      ruleType: 'TRUCK_DETENTION_FEE',
      basis: null,
      unitLabel: 'truck',
      ruleId: rule?.id ?? null,
      ruleName: rule?.name ?? null,
      freeDays: 0,
      ratePerDay: convertedRatePerDay,
      currency: targetCurrency,
      ruleCurrency,
      billingCurrency: targetCurrency,
      startDate: start ? start.toISOString() : null,
      endDate: end.toISOString(),
      endIsOpen,
      elapsedDays: chargeableDays,
      chargeableDays,
      containerCount: truckCount, // reused as the per-truck count
      billableUnits,
      amount,
      tiers: hasTiers ? convertedTiers : [],
    };
  }
}
