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

import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { GenerateFromScheduleDto } from './dto/generate-from-schedule.dto';
import { InterchangeDocumentQueryDto } from './dto/interchange-document-query.dto';
import {
  AcknowledgeInterchangeDocumentDto,
  DisputeInterchangeDocumentDto,
} from './dto/update-interchange-document-status.dto';
import { InterchangeDocumentItem } from './entities/interchange-document-item.entity';
import {
  InterchangeDirection,
  InterchangeDocument,
  InterchangeDocumentStatus,
} from './entities/interchange-document.entity';

interface ScheduleSnapshot {
  id: string;
  status: string;
  trainNo: string | null;
  routeId: string | null;
  originFacilityId: string | null;
  destinationFacilityId: string | null;
  originCountry: string | null;
  destinationCountry: string | null;
}

interface InterchangeItemSnapshot {
  bookingId: string;
  bookingReference: string | null;
  itemType: 'CONTAINER' | 'CARGO';
  bookingContainerId: string | null;
  bookingCargoId: string | null;
  containerNumber: string | null;
  sealNumber: string | null;
  cargoId: string | null;
  cargoType: string | null;
  cargoDescription: string | null;
  weight: string | number | null;
  quantity: string | number | null;
  packageCount: string | number | null;
  wagonNumber: string | null;
  hasDamage: boolean | null;
  damageDescription: string | null;
  hasWeightLoss: boolean | null;
  hasMissingItems: boolean | null;
  missingItemsDescription: string | null;
}

@Injectable()
export class InterchangeDocumentsService {
  constructor(private readonly dataSource: DataSource) {}

  async findAll(query: InterchangeDocumentQueryDto): Promise<InterchangeDocument[]> {
    const where: FindOptionsWhere<InterchangeDocument>[] = [];
    const base: FindOptionsWhere<InterchangeDocument> = {
      ...(query.direction ? { direction: query.direction } : {}),
      ...(query.status ? { status: query.status } : {}),
      ...(query.scheduleId ? { scheduleId: query.scheduleId } : {}),
      ...(query.documentNo ? { documentNo: ILike(`%${query.documentNo}%`) } : {}),
    };

    const search = query.search?.trim();
    if (search) {
      where.push(
        { ...base, documentNo: ILike(`%${search}%`) },
        { ...base, trainNo: ILike(`%${search}%`) },
        { ...base, handoverLocation: ILike(`%${search}%`) },
        { ...base, handoverFrom: ILike(`%${search}%`) },
        { ...base, handoverTo: ILike(`%${search}%`) },
      );
    }

    const qb = this.dataSource
      .getRepository(InterchangeDocument)
      .createQueryBuilder('doc')
      .leftJoinAndSelect('doc.items', 'items')
      .where(where.length ? where : base)
      .orderBy('doc.createdAt', 'DESC')
      .addOrderBy('items.createdAt', 'ASC');

    if (query.dateFrom) qb.andWhere('doc.created_at >= :dateFrom', { dateFrom: query.dateFrom });
    if (query.dateTo) qb.andWhere('doc.created_at <= :dateTo', { dateTo: query.dateTo });

    return qb.getMany();
  }

  async findOne(id: string): Promise<InterchangeDocument> {
    const document = await this.dataSource.getRepository(InterchangeDocument).findOne({
      where: { id },
      relations: { items: true },
      order: { items: { createdAt: 'ASC' } },
    });
    if (!document) throw new NotFoundException(`Interchange document ${id} not found`);
    return document;
  }

  async generateFromSchedule(dto: GenerateFromScheduleDto): Promise<InterchangeDocument> {
    const existing = await this.dataSource.getRepository(InterchangeDocument).findOne({
      where: {
        scheduleId: dto.scheduleId,
        direction: dto.direction,
        status: Not('CANCELLED') as unknown as InterchangeDocumentStatus,
      },
      relations: { items: true },
    });
    if (existing) return existing;

    const schedule = await this.getScheduleSnapshot(dto.scheduleId);
    const routeDirection = deriveTradeDirection(
      { country: schedule.originCountry },
      { country: schedule.destinationCountry },
    );
    if (routeDirection !== dto.direction) {
      throw new BadRequestException(`Train schedule route is ${routeDirection}, not ${dto.direction}`);
    }

    const itemSnapshots = await this.getScheduleItems(dto.scheduleId);
    if (itemSnapshots.length === 0) {
      throw new BadRequestException('No booking/container/cargo items found for this schedule');
    }

    return this.dataSource.transaction(async (manager) => {
      const now = new Date();
      const document = manager.getRepository(InterchangeDocument).create({
        documentNo: await this.nextDocumentNo(dto.direction),
        direction: dto.direction,
        scheduleId: schedule.id,
        trainNo: schedule.trainNo,
        routeId: schedule.routeId,
        originFacilityId: schedule.originFacilityId,
        destinationFacilityId: schedule.destinationFacilityId,
        handoverLocation: dto.handoverLocation.trim(),
        handoverFrom: dto.handoverFrom.trim(),
        handoverTo: dto.handoverTo.trim(),
        operatorName: dto.operatorName?.trim() || null,
        portOperatorName: dto.portOperatorName?.trim() || null,
        shippingLineName: dto.shippingLineName?.trim() || null,
        customsReference: dto.customsReference?.trim() || null,
        manifestReference: dto.manifestReference?.trim() || null,
        status: 'GENERATED',
        generatedAt: now,
        generatedBy: dto.generatedBy?.trim() || null,
        remarks: dto.remarks?.trim() || null,
      });
      const saved = await manager.getRepository(InterchangeDocument).save(document);

      const items = itemSnapshots.map((item) =>
        manager.getRepository(InterchangeDocumentItem).create({
          interchangeDocumentId: saved.id,
          bookingId: item.bookingId,
          bookingReference: item.bookingReference,
          itemType: item.itemType,
          bookingContainerId: item.bookingContainerId,
          bookingCargoId: item.bookingCargoId,
          containerNumber: item.containerNumber,
          sealNumber: item.sealNumber,
          cargoId: item.cargoId,
          cargoType: item.cargoType,
          cargoDescription: item.cargoDescription,
          weight: item.weight === null ? null : Number(item.weight) || null,
          quantity: item.quantity === null ? null : Number(item.quantity) || null,
          packageCount: item.packageCount === null ? null : Number(item.packageCount) || null,
          wagonNumber: item.wagonNumber,
          conditionStatus: this.conditionFromInspection(item),
          damageDescription:
            item.damageDescription ?? item.missingItemsDescription ?? null,
          remarks: null,
        }),
      );
      await manager.getRepository(InterchangeDocumentItem).save(items);

      return manager.getRepository(InterchangeDocument).findOneOrFail({
        where: { id: saved.id },
        relations: { items: true },
        order: { items: { createdAt: 'ASC' } },
      });
    });
  }

  async acknowledge(
    id: string,
    dto: AcknowledgeInterchangeDocumentDto,
  ): Promise<InterchangeDocument> {
    const document = await this.findOne(id);
    // Only a freshly GENERATED document can be acknowledged. Rejecting DISPUTED
    // (as well as CANCELLED / already-ACKNOWLEDGED) stops an acknowledge from
    // silently overriding a raised dispute.
    if (document.status !== 'GENERATED') {
      throw new BadRequestException(
        `Interchange document in ${document.status} status cannot be acknowledged (must be GENERATED)`,
      );
    }
    await this.dataSource.getRepository(InterchangeDocument).update(id, {
      status: 'ACKNOWLEDGED',
      acknowledgedAt: new Date(),
      acknowledgedBy: dto.acknowledgedBy,
      remarks: dto.remarks ?? document.remarks ?? null,
    });
    return this.findOne(id);
  }

  async dispute(id: string, dto: DisputeInterchangeDocumentDto): Promise<InterchangeDocument> {
    const document = await this.findOne(id);
    // A dispute can only be raised BEFORE the handover is acknowledged — an
    // acknowledged document is settled. DISPUTED itself is terminal and
    // read-only: the registered dispute cannot be re-raised or overwritten.
    if (document.status !== 'GENERATED') {
      throw new BadRequestException(
        `Interchange document in ${document.status} status cannot be disputed (must be GENERATED — an acknowledged handover is settled, a registered dispute is read-only)`,
      );
    }
    await this.dataSource.getRepository(InterchangeDocument).update(id, {
      status: 'DISPUTED',
      remarks: dto.remarks,
    });
    return this.findOne(id);
  }

  private async getScheduleSnapshot(scheduleId: string): Promise<ScheduleSnapshot> {
    const [schedule] = await this.dataSource.query(
      `SELECT ts.id,
              ts.status,
              ts.train_number AS "trainNo",
              ts.route_id AS "routeId",
              ts.origin_station_id AS "originFacilityId",
              ts.destination_station_id AS "destinationFacilityId",
              oy.country AS "originCountry",
              dy.country AS "destinationCountry"
         FROM freight.train_schedules ts
         LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
         LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
        WHERE ts.id = $1 AND ts.deleted_at IS NULL
        LIMIT 1`,
      [scheduleId],
    );
    if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
    return schedule;
  }

  private async getScheduleItems(scheduleId: string): Promise<InterchangeItemSnapshot[]> {
    return this.dataSource.query(
      `WITH assigned AS (
          SELECT b.id AS booking_id,
                 b.reference,
                 COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS booking_cargo_type,
                 b.cargo_free_text,
                 b.cargo_total_weight_vgm,
                 (
                   SELECT string_agg(DISTINCT w.wagon_number, ', ' ORDER BY w.wagon_number)
                     FROM freight.wagon_booking_allocations wba
                     JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
                     LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
                    WHERE wba.booking_id = b.id
                 ) AS wagon_number,
                 bool_or(COALESCE(wir.has_damage, false)) AS has_damage,
                 bool_or(COALESCE(wir.has_weight_loss, false)) AS has_weight_loss,
                 bool_or(COALESCE(wir.has_missing_items, false)) AS has_missing_items,
                 string_agg(DISTINCT NULLIF(wir.damage_description, ''), '; ') AS damage_description,
                 string_agg(DISTINCT NULLIF(wir.missing_items_description, ''), '; ') AS missing_items_description
            FROM freight.train_schedule_bookings tsb
            JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
            LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
            LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
            LEFT JOIN freight.warehouse_inspection_reports wir ON wir.inventory_id = inv.id AND wir.deleted_at IS NULL
           WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
           GROUP BY b.id, b.reference, cgt.cargo_type_name, b.cargo_free_text, b.cargo_total_weight_vgm
        )
        SELECT a.booking_id AS "bookingId",
               a.reference AS "bookingReference",
               'CONTAINER'::varchar AS "itemType",
               COALESCE(c.booking_container_id, bc.id) AS "bookingContainerId",
               NULL::uuid AS "bookingCargoId",
               COALESCE(c.container_number, bc.container_number) AS "containerNumber",
               c.seal_number AS "sealNumber",
               NULL::uuid AS "cargoId",
               a.booking_cargo_type AS "cargoType",
               a.cargo_free_text AS "cargoDescription",
               -- Item weight is normalized to TONS. total_vgm_tons and
               -- cargo_total_weight_vgm are already tons; containers.max_gross_weight
               -- is kilograms, so convert it (kg -> tons).
               COALESCE(bc.total_vgm_tons, c.max_gross_weight / 1000.0 /* kg->tons */, a.cargo_total_weight_vgm) AS "weight",
               COALESCE(bc.quantity, 1) AS "quantity",
               COALESCE(bc.quantity, 1) AS "packageCount",
               a.wagon_number AS "wagonNumber",
               a.has_damage AS "hasDamage",
               a.damage_description AS "damageDescription",
               a.has_weight_loss AS "hasWeightLoss",
               a.has_missing_items AS "hasMissingItems",
               a.missing_items_description AS "missingItemsDescription"
          FROM assigned a
          JOIN freight.containers c ON c.booking_id = a.booking_id AND c.deleted_at IS NULL
          LEFT JOIN freight.booking_container bc ON bc.id = c.booking_container_id AND bc.deleted_at IS NULL
        UNION ALL
        SELECT a.booking_id AS "bookingId",
               a.reference AS "bookingReference",
               'CONTAINER'::varchar AS "itemType",
               bc.id AS "bookingContainerId",
               NULL::uuid AS "bookingCargoId",
               bc.container_number AS "containerNumber",
               NULL::varchar AS "sealNumber",
               NULL::uuid AS "cargoId",
               a.booking_cargo_type AS "cargoType",
               a.cargo_free_text AS "cargoDescription",
               COALESCE(bc.total_vgm_tons, a.cargo_total_weight_vgm) AS "weight",
               bc.quantity AS "quantity",
               bc.quantity AS "packageCount",
               a.wagon_number AS "wagonNumber",
               a.has_damage AS "hasDamage",
               a.damage_description AS "damageDescription",
               a.has_weight_loss AS "hasWeightLoss",
               a.has_missing_items AS "hasMissingItems",
               a.missing_items_description AS "missingItemsDescription"
          FROM assigned a
          JOIN freight.booking_container bc ON bc.booking_id = a.booking_id AND bc.deleted_at IS NULL
         WHERE NOT EXISTS (
           SELECT 1 FROM freight.containers c
            WHERE c.booking_container_id = bc.id AND c.deleted_at IS NULL
         )
        UNION ALL
        SELECT a.booking_id AS "bookingId",
               a.reference AS "bookingReference",
               'CARGO'::varchar AS "itemType",
               NULL::uuid AS "bookingContainerId",
               cg.id AS "bookingCargoId",
               NULL::varchar AS "containerNumber",
               NULL::varchar AS "sealNumber",
               cg.id AS "cargoId",
               COALESCE(cgt.cargo_type_name, a.booking_cargo_type) AS "cargoType",
               COALESCE(cg.description, a.cargo_free_text) AS "cargoDescription",
               -- Normalized to TONS: cargoes.weight is kilograms (convert), while
               -- cargo_total_weight_vgm is already tons.
               COALESCE(cg.weight / 1000.0 /* kg->tons */, a.cargo_total_weight_vgm) AS "weight",
               cg.quantity AS "quantity",
               cg.quantity AS "packageCount",
               a.wagon_number AS "wagonNumber",
               a.has_damage AS "hasDamage",
               a.damage_description AS "damageDescription",
               a.has_weight_loss AS "hasWeightLoss",
               a.has_missing_items AS "hasMissingItems",
               a.missing_items_description AS "missingItemsDescription"
          FROM assigned a
          JOIN freight.cargoes cg ON cg.booking_id = a.booking_id AND cg.deleted_at IS NULL
          LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id
        UNION ALL
        SELECT a.booking_id AS "bookingId",
               a.reference AS "bookingReference",
               (CASE WHEN a.booking_cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END)::varchar AS "itemType",
               NULL::uuid AS "bookingContainerId",
               NULL::uuid AS "bookingCargoId",
               NULL::varchar AS "containerNumber",
               NULL::varchar AS "sealNumber",
               NULL::uuid AS "cargoId",
               a.booking_cargo_type AS "cargoType",
               a.cargo_free_text AS "cargoDescription",
               a.cargo_total_weight_vgm AS "weight",
               1 AS "quantity",
               1 AS "packageCount",
               a.wagon_number AS "wagonNumber",
               a.has_damage AS "hasDamage",
               a.damage_description AS "damageDescription",
               a.has_weight_loss AS "hasWeightLoss",
               a.has_missing_items AS "hasMissingItems",
               a.missing_items_description AS "missingItemsDescription"
          FROM assigned a
         WHERE NOT EXISTS (SELECT 1 FROM freight.containers c WHERE c.booking_id = a.booking_id AND c.deleted_at IS NULL)
           AND NOT EXISTS (SELECT 1 FROM freight.booking_container bc WHERE bc.booking_id = a.booking_id AND bc.deleted_at IS NULL)
           AND NOT EXISTS (SELECT 1 FROM freight.cargoes cg WHERE cg.booking_id = a.booking_id AND cg.deleted_at IS NULL)
         ORDER BY "bookingReference" ASC NULLS LAST, "itemType" ASC, "containerNumber" ASC NULLS LAST`,
      [scheduleId],
    );
  }

  private conditionFromInspection(item: InterchangeItemSnapshot) {
    if (item.hasDamage) return 'DAMAGED';
    if (item.hasWeightLoss || item.hasMissingItems) return 'SHORTAGE';
    return 'GOOD';
  }

  private async nextDocumentNo(direction: InterchangeDirection): Promise<string> {
    const prefix = `ICD-${direction === 'EXPORT' ? 'EXP' : 'IMP'}-${this.yyyymmdd(new Date())}`;
    const [row] = await this.dataSource.query(
      `SELECT document_no AS "documentNo"
         FROM freight.interchange_documents
        WHERE document_no LIKE $1
        ORDER BY document_no DESC
        LIMIT 1`,
      [`${prefix}-%`],
    );
    const last = row?.documentNo ? Number(String(row.documentNo).split('-').pop()) || 0 : 0;
    return `${prefix}-${String(last + 1).padStart(4, '0')}`;
  }

  private yyyymmdd(date: Date): string {
    const yyyy = date.getUTCFullYear();
    const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
    const dd = String(date.getUTCDate()).padStart(2, '0');
    return `${yyyy}${mm}${dd}`;
  }
}
