import { Freight, WagonMovementKind, WagonStatus } from '@edr/types';
import {
  BadRequestException,
  Injectable,
  NotFoundException,
  ConflictException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, In } from 'typeorm';
import { CreateWagonDto } from './dto/create-wagon.dto';
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
import { UpdateWagonDto } from './dto/update-wagon.dto';
import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
import { BulkTransferWagonsDto } from './dto/bulk-transfer-wagons.dto';
import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto';
import { Wagon } from './entities/wagon.entity';
import { WagonMovement } from './entities/wagon-movement.entity';
import { Train } from '../trains/entities/train.entity';
import { Yard } from '../rule-engine/entities/yard.entity';

@Injectable()
export class WagonsService {
  constructor(
    @InjectRepository(Wagon)
    private readonly wagonRepo: Repository<Wagon>,
    @InjectRepository(Train)
    private readonly trainRepo: Repository<Train>,
    private readonly dataSource: DataSource,
  ) {}

  async create(dto: CreateWagonDto): Promise<Wagon> {
    const wagon = this.wagonRepo.create({
      ...dto,
      status: dto.status ?? WagonStatus.Available,
    });
    // Convert undefined to null for nullable fields
    if (dto.trainId === undefined) wagon.trainId = null;
    if (dto.sequenceNumber === undefined) wagon.sequenceNumber = null;
    if (dto.currentYardId === undefined) wagon.currentYardId = null;
    if (dto.exportTrainNumber === undefined) wagon.exportTrainNumber = null;
    if (dto.importTrainNumber === undefined) wagon.importTrainNumber = null;
    return this.wagonRepo.save(wagon);
  }

  async findAll(query: ListWagonsQueryDto = {}): Promise<Wagon[]> {
    const search = query.search?.trim();
    const trainId = query.trainId?.trim();
    const wagonTypeId = query.wagonTypeId?.trim();
    const trainNumber = query.trainNumber?.trim();

    // QueryBuilder (not find) because both search and the trainNumber filter span
    // two columns each (export/import run) — an OR that FindOptions cannot express
    // without cross-producting into conflicting branches. Soft-deleted rows are
    // still excluded automatically (BaseEntity's @DeleteDateColumn).
    const qb = this.wagonRepo
      .createQueryBuilder('w')
      .leftJoinAndSelect('w.currentYard', 'currentYard')
      .leftJoinAndSelect('w.wagonType', 'wagonType');

    if (query.status) qb.andWhere('w.status = :status', { status: query.status });
    if (query.currentYardId)
      qb.andWhere('w.currentYardId = :currentYardId', { currentYardId: query.currentYardId });
    if (trainId) qb.andWhere('w.trainId = :trainId', { trainId });
    if (wagonTypeId) qb.andWhere('w.wagonTypeId = :wagonTypeId', { wagonTypeId });

    // Filter by run: the odd export run identifies the pair, so match either
    // column — a wagon carries export on one, import on the other.
    if (trainNumber) {
      qb.andWhere(
        '(w.exportTrainNumber = :trainNumber OR w.importTrainNumber = :trainNumber)',
        { trainNumber },
      );
    }

    // Search matches the wagon number or either run number.
    if (search) {
      qb.andWhere(
        '(w.wagonNumber ILIKE :search OR w.exportTrainNumber ILIKE :search OR w.importTrainNumber ILIKE :search)',
        { search: `%${search}%` },
      );
    }

    // Spec columns (tare, payload) are no longer sortable here — they live on the
    // wagon type, so sorting by them is sorting by wagonTypeId.
    const sortable: Array<keyof Wagon> = [
      'wagonNumber',
      'status',
      'currentYardId',
      'sequenceNumber',
      'wagonTypeId',
    ];
    const sortBy = sortable.includes((query.sortBy ?? '') as keyof Wagon)
      ? (query.sortBy as keyof Wagon)
      : 'wagonNumber';
    const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
    qb.orderBy(`w.${sortBy}`, sortOrder);

    if (query.page && query.limit) {
      qb.skip((Number(query.page) - 1) * Number(query.limit));
    }
    if (query.limit) qb.take(Number(query.limit));

    return qb.getMany();
  }

  async findById(id: string): Promise<Wagon> {
    const wagon = await this.wagonRepo.findOne({
      where: { id },
      relations: { currentYard: true, wagonType: true },
    });
    if (!wagon) throw new NotFoundException(`Wagon ${id} not found`);
    return wagon;
  }

  async update(id: string, dto: UpdateWagonDto, userId?: string | null): Promise<Wagon> {
    const wagon = await this.findById(id);
    // A wagon coupled to a built train follows the train: its yard and status
    // are managed through the train-builder flow, not this generic PATCH.
    if (wagon.trainId != null) {
      if (dto.currentYardId !== undefined && dto.currentYardId !== wagon.currentYardId) {
        throw new ConflictException(
          `Wagon ${wagon.wagonNumber} is coupled to a built train; relocate the train (train-builder) instead of moving the wagon`,
        );
      }
      if (dto.status !== undefined && dto.status !== wagon.status) {
        throw new ConflictException(
          `Wagon ${wagon.wagonNumber} is coupled to a built train; detach it via train-builder before changing its status`,
        );
      }
    }
    const previousYardId = wagon.currentYardId ?? null;
    Object.assign(wagon, dto);
    // `findById` eager-loads `currentYard`; when the DTO changes the scalar FK
    // TypeORM otherwise re-derives `current_yard_id` from the STALE relation
    // object (the old yard) on save and silently reverts the change. Drop the
    // relation so the scalar `currentYardId` wins.
    if (dto.currentYardId !== undefined) {
      wagon.currentYard = null;
    }
    await this.wagonRepo.save(wagon);
    // Staff manually relocated the wagon — write the movement ledger row so the
    // wagon's yard history stays auditable (who moved it, from where, when).
    if (
      dto.currentYardId !== undefined &&
      dto.currentYardId !== null &&
      dto.currentYardId !== previousYardId
    ) {
      const movementRepo = this.dataSource.getRepository(WagonMovement);
      await movementRepo.save(
        movementRepo.create({
          wagonId: id,
          fromYardId: previousYardId,
          toYardId: dto.currentYardId,
          kind: WagonMovementKind.Manual,
          movedByUserId: userId ?? null,
          occurredAt: new Date(),
        }),
      );
    }
    // Re-read with the relation so the response reflects the new yard label
    // instead of the stale relation object loaded before the assign.
    return this.findById(id);
  }

  /** Movement ledger for one wagon, newest first (loaded legs, repositions, manual moves). */
  async listMovements(wagonId: string): Promise<WagonMovement[]> {
    await this.findById(wagonId); // 404 on unknown wagon
    return this.dataSource.getRepository(WagonMovement).find({
      where: { wagonId },
      relations: { fromYard: true, toYard: true },
      order: { occurredAt: 'DESC', createdAt: 'DESC' },
    });
  }

  async remove(id: string): Promise<void> {
    const wagon = await this.findById(id);
    // A coupled wagon must be detached via train-builder before it can be
    // removed, so a built train never silently loses a wagon.
    if (wagon.trainId != null) {
      throw new ConflictException(
        `Wagon ${wagon.wagonNumber} is coupled to a built train; detach it via train-builder before deleting it`,
      );
    }
    if (await this.isWagonPinnedToLiveSchedule(id)) {
      throw new ConflictException(
        `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be deleted`,
      );
    }
    // Soft delete (deleted_at) — hard-deleting would strand ledger/schedule
    // history that references this wagon.
    await this.wagonRepo.softRemove(wagon);
  }

  /**
   * A wagon is busy when any live (DRAFT/SCHEDULED/DISPATCHED) schedule pins it
   * to one of its slots — schedule occupancy lives on TrainSetWagon rows, not
   * on the Wagon entity. Mirrors TrainBuilderService.isWagonPinnedToLiveSchedule.
   */
  private async isWagonPinnedToLiveSchedule(wagonId: string): Promise<boolean> {
    const rows: { exists: boolean }[] = await this.dataSource.query(
      `SELECT TRUE AS exists
         FROM freight.train_set_wagons tsw
         JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
        WHERE tsw.physical_wagon_id = $1
          AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
          AND ts.deleted_at IS NULL
          AND tsw.deleted_at IS NULL
        LIMIT 1`,
      [wagonId],
    );
    return rows.length > 0;
  }

  async assignToTrain(wagonId: string, dto: AssignWagonToTrainDto): Promise<Wagon> {
    const wagon = await this.findById(wagonId);
    // Mirror train-builder attachWagons: only a truly free, available wagon in
    // the train's own yard can be coupled, and never onto a dispatched train.
    if (wagon.trainId != null) {
      throw new ConflictException(`Wagon ${wagon.wagonNumber} is already on another train`);
    }
    if (wagon.status !== WagonStatus.Available) {
      throw new ConflictException(`Wagon ${wagon.wagonNumber} is not available (${wagon.status})`);
    }

    const train = await this.trainRepo.findOne({ where: { id: dto.trainId } });
    if (!train) throw new NotFoundException('Train not found');
    if (train.status === Freight.TrainStatus.InService) {
      throw new ConflictException(
        `Train ${train.code} is out on a dispatched run; its composition is frozen until arrival`,
      );
    }
    if (wagon.currentYardId !== train.currentYardId) {
      throw new BadRequestException(
        `Wagon ${wagon.wagonNumber} is not in the train's yard; only wagons in the same yard can be attached`,
      );
    }

    const maxSeq = await this.wagonRepo
      .createQueryBuilder('w')
      .select('MAX(w.sequenceNumber)', 'max')
      .where('w.trainId = :trainId', { trainId: train.id })
      .getRawOne();
    const nextSequence = Number(maxSeq?.max ?? 0) + 1;
    // An explicit sequence is only honoured when it is the next free slot;
    // anything else would duplicate a slot or leave a gap.
    if (dto.sequenceNumber != null && dto.sequenceNumber !== nextSequence) {
      throw new BadRequestException(
        `Sequence ${dto.sequenceNumber} is not the next free slot (${nextSequence}) for train ${train.code}`,
      );
    }

    wagon.trainId = train.id;
    wagon.sequenceNumber = nextSequence;
    wagon.status = WagonStatus.Assigned;
    return this.wagonRepo.save(wagon);
  }

  async unassignFromTrain(wagonId: string): Promise<Wagon> {
    const wagon = await this.findById(wagonId);
    // A wagon pinned to a live schedule is still operationally committed even
    // if the fleet train is being edited — don't free it out from under it.
    if (await this.isWagonPinnedToLiveSchedule(wagonId)) {
      throw new ConflictException(
        `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be detached`,
      );
    }
    wagon.trainId = null;
    wagon.sequenceNumber = null;
    wagon.status = WagonStatus.Available;
    return this.wagonRepo.save(wagon);
  }

  /**
   * Relocate many wagons to one destination yard in a single transaction. Each
   * wagon whose yard actually changes gets a `wagon_movements` ledger row (kind
   * `Manual`) so the yard history stays auditable — mirrors the single-wagon
   * `update` path. Wagons already in the destination yard are skipped.
   */
  async bulkTransfer(
    dto: BulkTransferWagonsDto,
    userId?: string | null,
    opts?: { transferRequestId?: string | null },
  ): Promise<{ moved: number }> {
    const { wagonIds, toYardId } = dto;
    if (!wagonIds.length) return { moved: 0 };

    const yard = await this.dataSource
      .getRepository(Yard)
      .findOne({ where: { id: toYardId } });
    if (!yard) throw new NotFoundException('Destination yard not found');

    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();
    try {
      const wagons = await queryRunner.manager.find(Wagon, {
        where: { id: In(wagonIds) },
      });
      if (wagons.length !== wagonIds.length) {
        throw new NotFoundException('One or more wagons not found');
      }

      // Only free, available wagons can be bulk-relocated; a coupled wagon
      // moves with its train (train-builder), never on its own here.
      const blocked = wagons.filter(
        (w) => w.trainId != null || w.status !== WagonStatus.Available,
      );
      if (blocked.length) {
        throw new ConflictException(
          `Cannot transfer wagons coupled to a train or not available: ${blocked
            .map((w) => w.wagonNumber)
            .join(', ')}`,
        );
      }

      let moved = 0;
      for (const wagon of wagons) {
        const previousYardId = wagon.currentYardId ?? null;
        if (previousYardId === toYardId) continue;
        wagon.currentYardId = toYardId;
        // Drop the eager relation so the scalar FK wins on save (see `update`).
        wagon.currentYard = null;
        await queryRunner.manager.save(Wagon, wagon);
        await queryRunner.manager.save(
          queryRunner.manager.create(WagonMovement, {
            wagonId: wagon.id,
            fromYardId: previousYardId,
            toYardId,
            kind: WagonMovementKind.Manual,
            movedByUserId: userId ?? null,
            transferRequestId: opts?.transferRequestId ?? null,
            occurredAt: new Date(),
          }),
        );
        moved++;
      }

      await queryRunner.commitTransaction();
      return { moved };
    } catch (err) {
      await queryRunner.rollbackTransaction();
      throw err;
    } finally {
      await queryRunner.release();
    }
  }

  /**
   * Set the same status on many wagons in one transaction (e.g. flip a batch
   * from Available to Assigned in the yard workspace). Only the `status` column
   * is touched — train assignment is managed through the assign/unassign flow.
   */
  async bulkSetStatus(dto: BulkSetWagonStatusDto): Promise<{ updated: number }> {
    const { wagonIds, status } = dto;
    if (!wagonIds.length) return { updated: 0 };

    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();
    try {
      const wagons = await queryRunner.manager.find(Wagon, {
        where: { id: In(wagonIds) },
      });
      if (wagons.length !== wagonIds.length) {
        throw new NotFoundException('One or more wagons not found');
      }

      // A coupled wagon's status is owned by the train-builder flow — refuse to
      // flip status on any wagon that is currently on a built train.
      const coupled = wagons.filter((w) => w.trainId != null);
      if (coupled.length) {
        throw new ConflictException(
          `Cannot change status of wagons coupled to a built train: ${coupled
            .map((w) => w.wagonNumber)
            .join(', ')}. Detach them via train-builder first.`,
        );
      }

      for (const wagon of wagons) {
        wagon.status = status;
      }
      await queryRunner.manager.save(Wagon, wagons);

      await queryRunner.commitTransaction();
      return { updated: wagons.length };
    } catch (err) {
      await queryRunner.rollbackTransaction();
      throw err;
    } finally {
      await queryRunner.release();
    }
  }

}
