import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';

import {
  ClearanceMilestone,
  CustomsRiskLevel,
  MilestoneMetadata,
} from './entities/clearance-milestone.entity';
import { Booking } from '../bookings/entities/booking.entity';
import { Contract } from './entities/contract.entity';
import {
  HANDOFF_MILESTONES,
  MilestoneDef,
  milestonesForDirection,
  splitMilestones,
} from './clearance-milestone.catalog';

/**
 * Seeds and advances the GL clearance milestones (18–23 per direction). Pre-booking
 * milestones attach to the contract clearance cycle; post-booking milestones attach
 * to the booking. See docs/new-doc.md §5.12, §5.16, §11.3, §12.2.
 */
@Injectable()
export class ClearanceMilestoneService {
  constructor(private readonly dataSource: DataSource) {}

  private get repo() {
    return this.dataSource.getRepository(ClearanceMilestone);
  }

  /** Seed the pre-booking milestones onto a contract's current clearance cycle. */
  async seedPreBookingMilestones(
    contract: Contract,
    clearanceCycleId: string,
  ): Promise<void> {
    const { preBooking } = splitMilestones(contract.tradeDirection);
    await this.seed(preBooking, {
      contractId: contract.id,
      clearanceCycleId,
    });
  }

  /** Seed pre-booking milestones on a booking (GENERAL + customs per-shipment clearance). */
  async seedPreBookingMilestonesOnBooking(
    bookingId: string,
    tradeDirection: string,
  ): Promise<void> {
    const { preBooking } = splitMilestones(tradeDirection);
    await this.seed(preBooking, { bookingId });
  }

  /** Seed the post-booking milestones onto a freshly created booking. */
  async seedPostBookingMilestones(
    bookingId: string,
    tradeDirection: string,
  ): Promise<void> {
    const { postBooking } = splitMilestones(tradeDirection);
    await this.seed(postBooking, { bookingId });
  }

  /**
   * Seed whichever pre/post-booking milestones the booking is still missing,
   * keyed by milestoneCode. Plain seeding is a blind insert, so paths that can
   * run more than once (completing an initiated instance whose pre-booking
   * milestones were seeded at initiation, or a consolidation pairing replay)
   * must go through this instead — a duplicate timeline breaks the phase
   * derivation.
   */
  async ensureBookingMilestones(
    bookingId: string,
    tradeDirection: string,
  ): Promise<void> {
    const existing = await this.repo.find({ where: { bookingId } });
    const have = new Set(existing.map((m) => m.milestoneCode));
    const { preBooking, postBooking } = splitMilestones(tradeDirection);
    await this.seed(
      preBooking.filter((d) => !have.has(d.code)),
      { bookingId },
    );
    await this.seed(
      postBooking.filter((d) => !have.has(d.code)),
      { bookingId },
    );
  }

  private async seed(
    defs: MilestoneDef[],
    scope: { contractId?: string; clearanceCycleId?: string; bookingId?: string },
  ): Promise<void> {
    if (!defs.length) return;
    const rows = defs.map((def, i) =>
      this.repo.create({
        ...scope,
        milestoneCode: def.code,
        milestoneLabel: def.label,
        ownerRegion: def.ownerRegion,
        triggeredByDoc: def.triggeredByDoc,
        status: 'PENDING',
        sortOrder: i,
      }),
    );
    await this.repo.save(rows);
  }

  /** List milestones for a contract cycle or a booking. */
  async listForContract(contractId: string): Promise<ClearanceMilestone[]> {
    return this.repo.find({
      where: { contractId },
      order: { sortOrder: 'ASC' },
    });
  }

  async listForBooking(bookingId: string): Promise<ClearanceMilestone[]> {
    const rows = await this.repo.find({
      where: { bookingId },
      order: { sortOrder: 'ASC' },
    });

    // Self-heal: a booking that has settled its freight payment must have
    // FREIGHT_PAYMENT_SETTLED completed. The batch settle path writes it, but an
    // export FCFS booking (linked to its train at booking time) paid via the
    // prepaid invoice can leave the milestone PENDING — the clearance "Payment &
    // wagon allocation" step then never ticks. getClearanceView backfills it, but
    // the stepper reads its gating milestones straight from here, so heal here too.
    // Idempotent (no-op once COMPLETED); recovers already-stuck rows with no migration.
    const paymentSettled = rows.find(
      (m) => m.milestoneCode === 'FREIGHT_PAYMENT_SETTLED',
    );
    if (paymentSettled && paymentSettled.status === 'PENDING') {
      const booking = await this.dataSource.getRepository(Booking).findOne({
        where: { id: bookingId },
        select: { id: true, status: true, paymentStatus: true },
      });
      if (booking?.paymentStatus === 'PAID' || booking?.status === 'PAID') {
        await this.completeForBooking(bookingId, 'FREIGHT_PAYMENT_SETTLED');
        return this.repo.find({
          where: { bookingId },
          order: { sortOrder: 'ASC' },
        });
      }
    }

    return rows;
  }

  /**
   * Find-or-create a post-booking milestone row from the catalog. Needed for codes
   * added to the catalog after a booking's rows were seeded (e.g. export T1_CLOSED).
   */
  async ensureForBooking(
    bookingId: string,
    code: string,
    tradeDirection: string,
  ): Promise<ClearanceMilestone> {
    const existing = await this.repo.findOne({ where: { bookingId, milestoneCode: code } });
    if (existing) return existing;

    const { postBooking } = splitMilestones(tradeDirection);
    const idx = postBooking.findIndex((d) => d.code === code);
    if (idx < 0) {
      throw new NotFoundException(
        `Milestone ${code} is not a ${tradeDirection} post-booking milestone`,
      );
    }
    const def = postBooking[idx]!;
    return this.repo.save(
      this.repo.create({
        bookingId,
        milestoneCode: def.code,
        milestoneLabel: def.label,
        ownerRegion: def.ownerRegion,
        triggeredByDoc: def.triggeredByDoc,
        status: 'PENDING',
        sortOrder: idx,
      }),
    );
  }

  /** Mark a milestone complete (by code) on a booking. */
  async completeForBooking(
    bookingId: string,
    code: string,
    userId?: string,
    note?: string,
  ): Promise<ClearanceMilestone> {
    const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } });
    if (!milestone) {
      throw new NotFoundException(`Milestone ${code} not found for booking ${bookingId}`);
    }
    if (milestone.status === 'COMPLETED') {
      return milestone;
    }
    milestone.status = 'COMPLETED';
    milestone.triggeredAt = new Date();
    milestone.triggeredByUserId = userId ?? null;
    if (note) milestone.note = note;
    const saved = await this.repo.save(milestone);

    if (HANDOFF_MILESTONES.includes(code)) {
      await this.onHandoff(bookingId, code);
    }
    return saved;
  }

  /**
   * Assign a customs risk level (GREEN/YELLOW/RED) and complete the RISK_ASSIGNED
   * milestone on a booking (GL Import US-04 / §11.3 #19). Stores the level in the
   * milestone metadata so the timeline shows it.
   *
   * Customs cannot risk-rate cargo still moving under transit: the T1 must be
   * closed (accepted by GL Ethiopia after the train arrives) first, which is the
   * catalog order T1_CLOSED → RISK_ASSIGNED.
   *
   * The level stays correctable until duty is advised off it, so each assignment
   * is appended to `riskHistory` instead of silently replacing the last one — a
   * customer-visible level that changes needs a trail of who changed it and when.
   */
  async assignRisk(
    bookingId: string,
    riskLevel: CustomsRiskLevel,
    userId?: string,
    note?: string,
    actor?: string,
  ): Promise<ClearanceMilestone> {
    await this.assertT1Closed(bookingId);

    const existing = await this.repo.findOne({
      where: { bookingId, milestoneCode: 'RISK_ASSIGNED' },
    });
    const previousLevel = existing?.metadata?.riskLevel;
    const history = existing?.metadata?.riskHistory ?? [];

    // A repeat of the level already assigned is not a decision — recording it
    // would pad the trail with entries that changed nothing.
    const entries =
      previousLevel === riskLevel
        ? history
        : [
            ...history,
            {
              level: riskLevel,
              ...(previousLevel ? { previousLevel } : {}),
              assignedAt: new Date().toISOString(),
              assignedByUserId: userId ?? null,
              assignedBy: actor ?? null,
              note: note ?? null,
            },
          ];

    return this.completeWithMetadata(
      bookingId,
      'RISK_ASSIGNED',
      { riskLevel, riskHistory: entries },
      userId,
      note,
    );
  }

  /** Guard: the booking's T1 must be closed before customs risk can be assigned. */
  private async assertT1Closed(bookingId: string): Promise<void> {
    const t1 = await this.repo.findOne({
      where: { bookingId, milestoneCode: 'T1_CLOSED' },
    });
    if (t1?.status !== 'COMPLETED' && t1?.status !== 'SKIPPED') {
      throw new BadRequestException(
        'The T1 must be closed before a customs risk level can be assigned.',
      );
    }
  }

  /**
   * Advise duty & tax (amount + declaration serial) and complete the
   * DUTY_TAXES_ADVISED milestone (§11.3 #6). The customer then uploads the
   * payment slip, which doc-triggers DUTY_TAX_PAID.
   */
  async adviseDuty(
    bookingId: string,
    input: { amount: number; currency: string; declarationSerial?: string },
    userId?: string,
    note?: string,
  ): Promise<ClearanceMilestone> {
    return this.completeWithMetadata(
      bookingId,
      'DUTY_TAXES_ADVISED',
      {
        dutyAmount: input.amount,
        dutyCurrency: input.currency,
        declarationSerial: input.declarationSerial,
      },
      userId,
      note,
    );
  }

  /** Complete a milestone and merge structured metadata onto it. */
  private async completeWithMetadata(
    bookingId: string,
    code: string,
    metadata: MilestoneMetadata,
    userId?: string,
    note?: string,
  ): Promise<ClearanceMilestone> {
    const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } });
    if (!milestone) {
      throw new NotFoundException(`Milestone ${code} not found for booking ${bookingId}`);
    }
    milestone.status = 'COMPLETED';
    milestone.triggeredAt = new Date();
    milestone.triggeredByUserId = userId ?? null;
    milestone.metadata = { ...(milestone.metadata ?? {}), ...metadata };
    if (note) milestone.note = note;
    return this.repo.save(milestone);
  }

  /** Mark a pre-booking milestone complete (by code) on a contract cycle. */
  async completeForContract(
    contractId: string,
    code: string,
    userId?: string,
    note?: string,
  ): Promise<ClearanceMilestone> {
    const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } });
    if (!milestone) {
      throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`);
    }
    if (milestone.status === 'COMPLETED') {
      return milestone;
    }
    milestone.status = 'COMPLETED';
    milestone.triggeredAt = new Date();
    milestone.triggeredByUserId = userId ?? null;
    if (note) milestone.note = note;
    return this.repo.save(milestone);
  }

  /** Skip optional milestones (e.g. duty when not required). */
  /** Reopen a completed contract milestone so review can continue after a query. */
  async reopenForContract(contractId: string, code: string): Promise<void> {
    const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } });
    if (!milestone || milestone.status !== 'COMPLETED') return;
    milestone.status = 'PENDING';
    milestone.triggeredAt = null;
    milestone.triggeredByUserId = null;
    await this.repo.save(milestone);
  }

  /** Reopen a completed booking milestone so review can continue after a query. */
  async reopenForBooking(bookingId: string, code: string): Promise<void> {
    const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } });
    if (!milestone || milestone.status !== 'COMPLETED') return;
    milestone.status = 'PENDING';
    milestone.triggeredAt = null;
    milestone.triggeredByUserId = null;
    await this.repo.save(milestone);
  }

  async skipForContract(contractId: string, code: string): Promise<ClearanceMilestone> {
    const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } });
    if (!milestone) {
      throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`);
    }
    if (milestone.status === 'COMPLETED') return milestone;
    milestone.status = 'SKIPPED';
    milestone.triggeredAt = new Date();
    return this.repo.save(milestone);
  }

  async skipForBooking(bookingId: string, code: string): Promise<ClearanceMilestone> {
    const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } });
    if (!milestone) {
      throw new NotFoundException(`Milestone ${code} not found for booking ${bookingId}`);
    }
    if (milestone.status === 'COMPLETED') return milestone;
    milestone.status = 'SKIPPED';
    milestone.triggeredAt = new Date();
    return this.repo.save(milestone);
  }

  async completeWithMetadataForBooking(
    bookingId: string,
    code: string,
    metadata: MilestoneMetadata,
    userId?: string,
    note?: string,
  ): Promise<ClearanceMilestone> {
    return this.completeWithMetadata(bookingId, code, metadata, userId, note);
  }

  /** Complete a contract milestone with structured metadata (duty advice, etc.). */
  async completeWithMetadataForContract(
    contractId: string,
    code: string,
    metadata: MilestoneMetadata,
    userId?: string,
    note?: string,
  ): Promise<ClearanceMilestone> {
    const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } });
    if (!milestone) {
      throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`);
    }
    if (milestone.status === 'COMPLETED') {
      return milestone;
    }
    milestone.status = 'COMPLETED';
    milestone.triggeredAt = new Date();
    milestone.triggeredByUserId = userId ?? null;
    milestone.metadata = { ...(milestone.metadata ?? {}), ...metadata };
    if (note) milestone.note = note;
    return this.repo.save(milestone);
  }

  async adviseDutyForContract(
    contractId: string,
    input: { amount: number; currency: string; declarationSerial?: string },
    userId?: string,
  ): Promise<ClearanceMilestone> {
    return this.completeWithMetadataForContract(
      contractId,
      'DUTY_TAXES_ADVISED',
      {
        dutyAmount: input.amount,
        dutyCurrency: input.currency,
        declarationSerial: input.declarationSerial,
      },
      userId,
    );
  }

  /** Complete a doc-triggered milestone when its document is uploaded/approved. */
  async completeByDocTrigger(
    scope: { bookingId?: string; contractId?: string },
    code: string,
  ): Promise<void> {
    const where = scope.bookingId
      ? { bookingId: scope.bookingId, milestoneCode: code }
      : { contractId: scope.contractId, milestoneCode: code };
    const milestone = await this.repo.findOne({ where });
    if (!milestone || milestone.status === 'COMPLETED') return;
    milestone.status = 'COMPLETED';
    milestone.triggeredAt = new Date();
    await this.repo.save(milestone);
  }

  /**
   * ET ↔ DJ ownership handoff (doc §11.5/§12.3). On DEPARTED_FROM_DJIBOUTI the
   * lead transfers to GL Ethiopia + Operations; on DEPARTED_TO_DJIBOUTI to GL
   * Djibouti. Notifications are handled by the notification layer (out of scope);
   * here we only record the ownership flip on subsequent pending milestones.
   */
  private async onHandoff(bookingId: string, code: string): Promise<void> {
    void bookingId;
    void code;
    // Ownership region is already encoded per-milestone in the catalog; no
    // mutation is required. This hook exists for the notification dispatch that
    // the GL US-09 handoff requires once the notification module lands.
  }

  /** Catalog passthrough for the frontend timeline (labels + owners). */
  catalogForDirection(tradeDirection: string): MilestoneDef[] {
    return milestonesForDirection(tradeDirection);
  }
}
