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

import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';

import {
  Locomotive,
  type LocomotiveStatus,
  type LocomotiveType,
} from './entities/locomotive.entity';
import { TrainLocomotive } from '../trains/entities/train-locomotive.entity';
import { LocomotivesRepository } from './locomotives.repository';

@Injectable()
export class LocomotivesService {
  constructor(
    private readonly locomotivesRepository: LocomotivesRepository,
    private readonly dataSource: DataSource,
  ) {}

  /** The built-train link (if any) coupling this locomotive to a fleet train. */
  private findTrainLink(locomotiveId: string): Promise<TrainLocomotive | null> {
    return this.dataSource.getRepository(TrainLocomotive).findOne({
      where: { locomotiveId },
      relations: { train: true },
    });
  }

  findAll(filter: FilterLocomotivesDto): Promise<Locomotive[]> {
    // The coupling picker needs a NOT-EXISTS against the train link table, so it
    // takes the query-builder path; the plain list keeps the simple where.
    if (filter.excludeCoupled) {
      return this.locomotivesRepository.findForCoupling({
        status: filter.status as LocomotiveStatus | undefined,
        locomotiveType: filter.locomotiveType as LocomotiveType | undefined,
        currentYardId: filter.currentYardId,
        excludeCoupled: true,
        keepTrainId: filter.excludeTrainId,
      });
    }
    return this.locomotivesRepository.findAll({
      where: {
        ...(filter.status ? { status: filter.status as LocomotiveStatus } : {}),
        ...(filter.locomotiveType
          ? { locomotiveType: filter.locomotiveType as LocomotiveType }
          : {}),
        ...(filter.currentYardId ? { currentYardId: filter.currentYardId } : {}),
      },
      relations: { currentYard: true },
      order: { code: 'ASC' },
    });
  }

  /** Default max pull weight (tons) applied when the caller omits it. */
  private static readonly DEFAULT_MAX_PULL_WEIGHT_TONS = 2500;

  /**
   * Generate the next sequential locomotive code (LOCO-001, LOCO-002, …) by
   * scanning the highest existing LOCO-NNN number. Used when the caller does not
   * supply a code.
   */
  private async generateCode(): Promise<string> {
    const all = await this.locomotivesRepository.findAll({});
    let max = 0;
    for (const loco of all) {
      const match = /^LOCO-(\d+)$/.exec(loco.code ?? '');
      if (match) max = Math.max(max, Number(match[1]));
    }
    return `LOCO-${String(max + 1).padStart(3, '0')}`;
  }

  /**
   * Reject a name already worn by another live locomotive. Compared
   * case-insensitively on the trimmed value so this matches the DB index
   * `UQ_locomotives_name_active` — otherwise a clash the guard waved through
   * would surface as a raw 500 from the index instead of a 409. `excludeId`
   * lets an update keep its own name.
   */
  private async assertNameAvailable(name: string, excludeId?: string): Promise<void> {
    const clash = await this.locomotivesRepository.findByName(name, excludeId);

    if (clash) {
      throw new ConflictException(
        `Locomotive name "${name.trim()}" is already used by ${clash.code}`,
      );
    }
  }

  async create(dto: CreateLocomotiveDto): Promise<Locomotive> {
    const code = dto.code?.trim() || (await this.generateCode());

    const [existing] = await this.locomotivesRepository.findAll({ where: { code } });
    if (existing) {
      throw new ConflictException(`Locomotive code ${code} already exists`);
    }

    // Name stays optional; only a non-blank one has to be unique.
    const name = dto.name?.trim() || null;
    if (name) {
      await this.assertNameAvailable(name);
    }

    return this.locomotivesRepository.create({
      code,
      name,
      locomotiveType: dto.locomotiveType as LocomotiveType,
      status: dto.status as LocomotiveStatus,
      currentYardId: dto.currentYardId ?? null,
      maxPullWeightTons:
        dto.maxPullWeightTons ?? LocomotivesService.DEFAULT_MAX_PULL_WEIGHT_TONS,
      maxTrainLengthMeters: dto.maxTrainLengthMeters,
      overageToleranceTons: dto.overageToleranceTons ?? null,
      overageToleranceMeters: dto.overageToleranceMeters ?? null,
    });
  }

  async findById(id: string): Promise<Locomotive> {
    const locomotive = await this.locomotivesRepository.findById(id, {
      relations: { currentYard: true },
    });

    if (!locomotive) {
      throw new NotFoundException(`Locomotive ${id} not found`);
    }

    return locomotive;
  }

  async update(id: string, dto: UpdateLocomotiveDto): Promise<Locomotive> {
    const locomotive = await this.findById(id);

    if (dto.code && dto.code !== locomotive.code) {
      const [existing] = await this.locomotivesRepository.findAll({ where: { code: dto.code } });
      if (existing && existing.id !== id) {
        throw new ConflictException(`Locomotive code ${dto.code} already exists`);
      }
    }

    // Only when the caller actually sends a name — an omitted field keeps the
    // current one, and clearing it to blank is allowed.
    if (dto.name !== undefined) {
      const nextName = dto.name?.trim() || null;
      if (nextName) {
        await this.assertNameAvailable(nextName, id);
      }
    }

    // A locomotive coupled to a built train follows the train: its yard and
    // status are owned by the train-builder flow, not this generic PATCH.
    const link = await this.findTrainLink(id);
    if (link) {
      if (dto.currentYardId !== undefined && dto.currentYardId !== link.train?.currentYardId) {
        throw new ConflictException(
          `Locomotive ${locomotive.code} is coupled to train ${link.train?.code}; move the train (train-builder yard change) instead`,
        );
      }
      if (dto.status !== undefined && dto.status !== locomotive.status) {
        throw new ConflictException(
          `Locomotive ${locomotive.code} is coupled to train ${link.train?.code}; detach it before changing its status`,
        );
      }
    }

    const updated = await this.locomotivesRepository.update(id, {
      ...dto,
      locomotiveType:
        dto.locomotiveType === undefined ? locomotive.locomotiveType : dto.locomotiveType as LocomotiveType,
      status: dto.status === undefined ? locomotive.status : dto.status as LocomotiveStatus,
      currentYardId:
        dto.currentYardId === undefined
          ? locomotive.currentYardId
          : (dto.currentYardId ?? null),
      name: dto.name === undefined ? locomotive.name : dto.name?.trim() || null,
    });

    if (!updated) {
      throw new NotFoundException(`Locomotive ${id} not found`);
    }

    return updated;
  }

  async decommission(id: string): Promise<Locomotive> {
    const locomotive = await this.findById(id);

    // Can't retire a locomotive that is still coupled to a built train — detach
    // it in the train-builder first so the train never loses a live loco.
    const link = await this.findTrainLink(id);
    if (link) {
      throw new ConflictException(
        `Locomotive ${locomotive.code} is coupled to train ${link.train?.code}; detach it before taking it out of service`,
      );
    }

    const updated = await this.locomotivesRepository.update(id, {
      status: 'OUT_OF_SERVICE',
    });

    if (!updated) {
      throw new NotFoundException(`Locomotive ${id} not found`);
    }

    return updated;
  }
}
