import {
  BadRequestException,
  ConflictException,
  Injectable,
  NotFoundException,
} from '@nestjs/common';
import { TrainScheduleStatus } from '@edr/types';
import { DataSource, In, Not } from 'typeorm';

import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { YardDistance } from '../rule-engine/entities/yard-distance.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { CreateRouteDto } from './dto/create-route.dto';
import { FilterRoutesDto } from './dto/filter-routes.dto';
import { UpdateRouteDto } from './dto/update-route.dto';
import { RouteMilestone } from './entities/route-milestone.entity';
import { formatRouteLabel, Route, type RouteStatus } from './entities/route.entity';
import { RoutesRepository } from './routes.repository';

/** Order-insensitive key: distances are symmetric. */
const pairKey = (a: string, b: string): string => (a < b ? `${a}|${b}` : `${b}|${a}`);

@Injectable()
export class RoutesService {
  constructor(
    private readonly dataSource: DataSource,
    private readonly routesRepository: RoutesRepository,
  ) {}

  async findAll(filter: FilterRoutesDto): Promise<Route[]> {
    const routes = await this.routesRepository.findAll({
      where: {
        ...(filter.status ? { status: filter.status } : {}),
      },
      relations: {
        originYard: true,
        destinationYard: true,
        milestones: { yard: true },
      },
      order: {
        milestones: { sequenceNo: 'ASC' },
      },
    });

    const sorted = [...routes].sort((a, b) =>
      formatRouteLabel(a).localeCompare(formatRouteLabel(b)),
    );

    const query = filter.search?.trim().toLowerCase();
    if (!query) return sorted;

    return sorted.filter((route) => {
      const haystack = [
        formatRouteLabel(route),
        route.originYard?.label,
        route.originYard?.code,
        route.destinationYard?.label,
        route.destinationYard?.code,
        ...(route.milestones ?? []).map(
          (m) => m.yard?.label ?? m.yard?.code ?? m.yardId,
        ),
      ]
        .filter(Boolean)
        .join(' ')
        .toLowerCase();
      return haystack.includes(query);
    });
  }

  async findById(id: string): Promise<Route> {
    const route = await this.dataSource.getRepository(Route).findOne({
      where: { id },
      relations: {
        originYard: true,
        destinationYard: true,
        milestones: { yard: true },
      },
      order: { milestones: { sequenceNo: 'ASC' } },
    });

    if (!route) {
      throw new NotFoundException(`Route ${id} not found`);
    }

    return route;
  }

  async create(dto: CreateRouteDto): Promise<Route> {
    const validated = await this.validateMilestones(dto.milestones);
    await this.assertNotDuplicate(validated.milestones);

    const route = await this.dataSource.transaction(async (manager) => {
      const savedRoute = await manager.getRepository(Route).save(
        manager.getRepository(Route).create({
          originYardId: validated.originYardId,
          destinationYardId: validated.destinationYardId,
          status: dto.status ?? 'AVAILABLE',
          direction: validated.direction,
        }),
      );

      await manager.getRepository(RouteMilestone).save(
        validated.milestones.map((milestone) =>
          manager.getRepository(RouteMilestone).create({
            routeId: savedRoute.id,
            yardId: milestone.yardId,
            sequenceNo: milestone.sequenceNo,
            distanceKm: milestone.distanceKm,
          }),
        ),
      );

      return savedRoute;
    });

    return this.findById(route.id);
  }

  async update(id: string, dto: UpdateRouteDto): Promise<Route> {
    const existing = await this.findById(id);

    const milestoneInput = dto.milestones
      ? await this.validateMilestones(dto.milestones)
      : null;

    // An edit can collide with another route just as easily as a create can.
    if (milestoneInput) {
      await this.assertNotDuplicate(milestoneInput.milestones, id);
    }

    // Milestones or endpoints are about to be rewritten — reject if any
    // non-terminal schedule still references this route, otherwise its stop list
    // and distances would silently shift under a live plan. Status-only /
    // label-only edits (no milestones supplied) are always allowed.
    if (milestoneInput) {
      const activeSchedules = await this.dataSource
        .getRepository(TrainSchedule)
        .count({
          where: {
            routeId: id,
            status: In([
              TrainScheduleStatus.Draft,
              TrainScheduleStatus.Scheduled,
              TrainScheduleStatus.Dispatched,
            ]),
          },
        });
      if (activeSchedules > 0) {
        throw new ConflictException(
          'This route is used by active train schedules and its stops cannot be changed. Create a new route instead.',
        );
      }
    }

    await this.dataSource.transaction(async (manager) => {
      await manager.getRepository(Route).update(id, {
        originYardId: milestoneInput?.originYardId ?? existing.originYardId,
        destinationYardId:
          milestoneInput?.destinationYardId ?? existing.destinationYardId,
        ...(milestoneInput ? { direction: milestoneInput.direction } : {}),
        ...(dto.status !== undefined ? { status: dto.status } : {}),
      });

      if (milestoneInput) {
        await manager.getRepository(RouteMilestone).delete({ routeId: id });
        await manager.getRepository(RouteMilestone).save(
          milestoneInput.milestones.map((milestone) =>
            manager.getRepository(RouteMilestone).create({
              routeId: id,
              yardId: milestone.yardId,
              sequenceNo: milestone.sequenceNo,
              distanceKm: milestone.distanceKm,
            }),
          ),
        );
      }
    });

    return this.findById(id);
  }

  async deactivate(id: string): Promise<Route> {
    await this.findById(id);
    const updated = await this.routesRepository.update(id, {
      status: 'STOP_WORKING',
    } as never);

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

    return this.findById(id);
  }

  /**
   * A route IS its ordered stop list — "Addis → Adama → Dire Dawa" and
   * "Addis → Dire Dawa" share endpoints but are different corridors. So the
   * duplicate test compares the full yard sequence, not just origin/destination.
   *
   * Decommissioned routes (STOP_WORKING) are ignored: replacing a retired
   * corridor with a fresh one is exactly what an admin does after deactivating,
   * and there is no reactivate action to fall back on.
   */
  private async assertNotDuplicate(
    milestones: Array<{ yardId: string }>,
    excludeRouteId?: string,
  ): Promise<void> {
    const signature = milestones.map((m) => m.yardId).join('>');

    const candidates = await this.dataSource.getRepository(Route).find({
      where: {
        originYardId: milestones[0].yardId,
        destinationYardId: milestones[milestones.length - 1].yardId,
        status: Not<RouteStatus>('STOP_WORKING'),
      },
      relations: {
        originYard: true,
        destinationYard: true,
        milestones: { yard: true },
      },
    });

    const duplicate = candidates.find((route) => {
      if (route.id === excludeRouteId) return false;
      const stops = [...(route.milestones ?? [])]
        .sort((a, b) => a.sequenceNo - b.sequenceNo)
        .map((m) => m.yardId)
        .join('>');
      return stops === signature;
    });

    if (duplicate) {
      throw new ConflictException(
        `This route already exists: ${formatRouteLabel(duplicate)}. ` +
          'Edit the existing route instead of creating a duplicate.',
      );
    }
  }

  private async validateMilestones(milestones: Array<{ yardId: string }>) {
    if (milestones.length < 2) {
      throw new BadRequestException('A route requires at least two yards');
    }

    const uniqueYardIds = [...new Set(milestones.map((milestone) => milestone.yardId))];
    const yards = await this.dataSource
      .getRepository(Yard)
      .find({ where: uniqueYardIds.map((id) => ({ id })) });
    const yardIds = new Set(yards.map((yard) => yard.id));

    for (const milestone of milestones) {
      if (!yardIds.has(milestone.yardId)) {
        throw new BadRequestException(`Yard ${milestone.yardId} does not exist`);
      }
    }

    if (milestones[0].yardId === milestones[milestones.length - 1].yardId) {
      throw new BadRequestException('Origin and destination yards must be different');
    }

    const yardById = new Map(yards.map((yard) => [yard.id, yard]));
    const distanceByPair = await this.loadDistanceLookup(uniqueYardIds);

    // Segment km come from the configured yard-distance table, not the payload
    // — a route can only be built over pairs an admin has entered. Distances
    // are symmetric, so an A→B row also serves B→A.
    const missingPairs: string[] = [];
    const normalized = milestones.map((milestone, index) => {
      if (index === 0) {
        return { yardId: milestone.yardId, sequenceNo: 1, distanceKm: 0 };
      }
      const previousYardId = milestones[index - 1].yardId;
      const distanceKm = distanceByPair.get(pairKey(previousYardId, milestone.yardId));
      if (distanceKm == null) {
        const from = yardById.get(previousYardId);
        const to = yardById.get(milestone.yardId);
        missingPairs.push(
          `${from?.label ?? previousYardId} ↔ ${to?.label ?? milestone.yardId}`,
        );
      }
      return {
        yardId: milestone.yardId,
        sequenceNo: index + 1,
        distanceKm: distanceKm ?? null,
      };
    });

    if (missingPairs.length > 0) {
      throw new BadRequestException(
        `No distance configured for: ${missingPairs.join(', ')}. ` +
          'Add the missing yard distances in Configuration → Yard Distances first.',
      );
    }

    const originYardId = milestones[0].yardId;
    const destinationYardId = milestones[milestones.length - 1].yardId;
    const direction = deriveTradeDirection(
      yardById.get(originYardId) ?? { country: null },
      yardById.get(destinationYardId) ?? { country: null },
    );

    return {
      originYardId,
      destinationYardId,
      direction,
      milestones: normalized,
    };
  }

  /** Order-insensitive pair → km map over every configured distance touching the yards. */
  private async loadDistanceLookup(yardIds: string[]): Promise<Map<string, number>> {
    const rows = await this.dataSource
      .getRepository(YardDistance)
      .find({ where: [{ fromYardId: In(yardIds) }, { toYardId: In(yardIds) }] });

    const lookup = new Map<string, number>();
    for (const row of rows) {
      lookup.set(pairKey(row.fromYardId, row.toYardId), Number(row.distanceKm));
    }
    return lookup;
  }
}
