import {
  NotificationAudience,
  NotificationType,
  OPEN_WAGON_TRANSFER_STATUSES,
  PaginatedResponse,
  WagonStatus,
  WagonTransferRequestStatus,
} from '@edr/types';
import {
  BadRequestException,
  ConflictException,
  Injectable,
  Logger,
  NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, IsNull, Not, Repository } from 'typeorm';

import { paginateQuery } from '../../common/utils/pagination.util';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { CloseShortTransferRequestDto } from './dto/close-short-transfer-request.dto';
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
import { ListTransferRequestsQueryDto } from './dto/list-transfer-requests-query.dto';
import { Wagon } from './entities/wagon.entity';
import { WagonMovement } from './entities/wagon-movement.entity';
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
import { WagonsService } from './wagons.service';

/** Bundled per-user activity: requests they touched + wagons they moved. */
export interface TransferHistory {
  requests: WagonTransferRequest[];
  movements: WagonMovement[];
  /**
   * One pager drives both lists (they are shown side by side), so it carries a
   * total per list and the page count of the longer one.
   */
  meta: {
    page: number;
    pageSize: number;
    requestsTotal: number;
    movementsTotal: number;
    totalPages: number;
  };
}

/** Hard ceiling on a single history page, whatever the client asks for. */
const HISTORY_LIMIT = 100;

const REQUEST_RELATIONS = {
  fromYard: true,
  toYard: true,
  wagonType: true,
} as const;

/**
 * Two-person wagon-transfer workflow. A requester records a count-only request
 * (see `createRequest`); OCC staff later open the PENDING queue, hand-pick the
 * physical wagons, and `fulfillRequest` validates + executes the move. Replaces
 * the single-step instant bulk transfer.
 */
@Injectable()
export class WagonTransferRequestsService {
  private readonly logger = new Logger(WagonTransferRequestsService.name);

  constructor(
    @InjectRepository(WagonTransferRequest)
    private readonly requestRepo: Repository<WagonTransferRequest>,
    @InjectRepository(Wagon)
    private readonly wagonRepo: Repository<Wagon>,
    @InjectRepository(WagonMovement)
    private readonly movementRepo: Repository<WagonMovement>,
    private readonly wagonsService: WagonsService,
    private readonly inbox: NotificationInboxService,
  ) {}

  /**
   * Record a PENDING request. Count-only — no wagons are picked here, and the
   * count is NOT capped by what the source yard holds today: OCC fulfils in
   * instalments, so asking for 50 while only 20 sit there is a normal, useful
   * request. A reason is mandatory and is shown on the OCC queue.
   */
  async createRequest(
    dto: CreateTransferRequestDto,
    userId?: string | null,
  ): Promise<WagonTransferRequest> {
    if (dto.fromYardId === dto.toYardId) {
      throw new BadRequestException(
        'Source and destination yard must be different',
      );
    }
    const request = this.requestRepo.create({
      fromYardId: dto.fromYardId,
      toYardId: dto.toYardId,
      wagonTypeId: dto.wagonTypeId,
      quantity: dto.quantity,
      status: WagonTransferRequestStatus.Pending,
      requestedByUserId: userId ?? null,
      reason: dto.reason,
      note: dto.note ?? null,
    });
    const saved = await this.requestRepo.save(request);
    return this.findById(saved.id);
  }

  /**
   * AVAILABLE wagons of `wagonTypeId` currently in `yardId` — what OCC can move
   * right now. Shown on the desk beside the outstanding count so staff see at a
   * glance how much of a request the yard can cover today.
   */
  countAvailable(yardId: string, wagonTypeId: string): Promise<number> {
    return this.wagonRepo.count({
      where: {
        currentYardId: yardId,
        wagonTypeId,
        status: WagonStatus.Available,
      },
    });
  }

  /**
   * The transfer desk list: paginated, newest first, filterable by status (one
   * or a comma-separated set — the "Open" tab asks for PENDING +
   * PARTIALLY_FULFILLED), yards and wagon type. Search matches the reason text.
   */
  async listRequests(
    query: ListTransferRequestsQueryDto,
  ): Promise<PaginatedResponse<WagonTransferRequest>> {
    const qb = this.requestRepo
      .createQueryBuilder('r')
      .leftJoinAndSelect('r.fromYard', 'fromYard')
      .leftJoinAndSelect('r.toYard', 'toYard')
      .leftJoinAndSelect('r.wagonType', 'wagonType');

    const statuses = (query.status ?? '')
      .split(',')
      .map((s) => s.trim())
      .filter(Boolean);
    if (statuses.length) {
      qb.andWhere('r.status IN (:...statuses)', { statuses });
    }
    if (query.fromYardId) {
      qb.andWhere('r.from_yard_id = :fromYardId', { fromYardId: query.fromYardId });
    }
    if (query.toYardId) {
      qb.andWhere('r.to_yard_id = :toYardId', { toYardId: query.toYardId });
    }
    if (query.wagonTypeId) {
      qb.andWhere('r.wagon_type_id = :wagonTypeId', {
        wagonTypeId: query.wagonTypeId,
      });
    }
    if (query.search) {
      qb.andWhere('r.reason ILIKE :search', { search: `%${query.search}%` });
    }

    const sortColumn =
      query.sortBy === 'quantity'
        ? 'r.quantity'
        : query.sortBy === 'status'
          ? 'r.status'
          : 'r.created_at';
    qb.orderBy(sortColumn, query.sortOrder ?? 'DESC');

    return paginateQuery(qb, { page: query.page, pageSize: query.pageSize });
  }

  async findById(id: string): Promise<WagonTransferRequest> {
    const request = await this.requestRepo.findOne({
      where: { id },
      relations: REQUEST_RELATIONS,
    });
    if (!request) throw new NotFoundException(`Transfer request ${id} not found`);
    return request;
  }

  /** Wagons still owed on an open request. */
  private remainingOn(request: WagonTransferRequest): number {
    return Math.max(0, request.quantity - (request.fulfilledQuantity ?? 0));
  }

  /** True while OCC can still move wagons against this request. */
  private isOpen(request: WagonTransferRequest): boolean {
    return OPEN_WAGON_TRANSFER_STATUSES.includes(request.status);
  }

  /**
   * OCC moves hand-picked wagons against an open request. Any number from 1 up
   * to whatever is still owed — the yard rarely has the whole ask at once, so a
   * request for 50 can be met 20 now, 30 later. Every wagon must sit in the
   * source yard, match the type and be available. The request completes on its
   * own once the full count has moved; short of that it stays open as
   * PARTIALLY_FULFILLED and the requester is told what landed.
   */
  async fulfillRequest(
    id: string,
    dto: FulfillTransferRequestDto,
    userId?: string | null,
  ): Promise<WagonTransferRequest> {
    const request = await this.findById(id);
    if (!this.isOpen(request)) {
      throw new ConflictException(
        `Request is already ${request.status.toLowerCase().replace(/_/g, ' ')}`,
      );
    }

    const wagonIds = [...new Set(dto.wagonIds)];
    const remaining = this.remainingOn(request);
    if (wagonIds.length > remaining) {
      throw new BadRequestException(
        `Only ${remaining} wagon(s) still owed on this request; you selected ${wagonIds.length}`,
      );
    }

    const wagons = await this.wagonRepo.find({ where: { id: In(wagonIds) } });
    if (wagons.length !== wagonIds.length) {
      throw new NotFoundException('One or more selected wagons not found');
    }
    const offSource = wagons.filter((w) => w.currentYardId !== request.fromYardId);
    if (offSource.length) {
      throw new BadRequestException(
        `These wagons are not in the source yard: ${offSource
          .map((w) => w.wagonNumber)
          .join(', ')}`,
      );
    }
    const wrongType = wagons.filter((w) => w.wagonTypeId !== request.wagonTypeId);
    if (wrongType.length) {
      throw new BadRequestException(
        `These wagons are the wrong type: ${wrongType
          .map((w) => w.wagonNumber)
          .join(', ')}`,
      );
    }
    const notAvailable = wagons.filter((w) => w.status !== WagonStatus.Available);
    if (notAvailable.length) {
      throw new BadRequestException(
        `These wagons are not available: ${notAvailable
          .map((w) => w.wagonNumber)
          .join(', ')}`,
      );
    }

    // Reuse the audited bulk-transfer path (writes wagon_movements ledger rows,
    // each stamped with this request's id so history can link them back).
    await this.wagonsService.bulkTransfer(
      { wagonIds, toYardId: request.toYardId },
      userId,
      { transferRequestId: request.id },
    );

    await this.recordDelivery(request, wagonIds.length, userId);
    return this.findById(id);
  }

  /**
   * Book an instalment against a request: bump the delivered count, complete it
   * when the full ask has landed, and tell the requester what moved. Shared by
   * the hand-picked and auto-picked (bulk) fulfilment paths.
   */
  private async recordDelivery(
    request: WagonTransferRequest,
    moved: number,
    userId?: string | null,
  ): Promise<void> {
    request.fulfilledQuantity = (request.fulfilledQuantity ?? 0) + moved;
    request.status =
      request.fulfilledQuantity >= request.quantity
        ? WagonTransferRequestStatus.Fulfilled
        : WagonTransferRequestStatus.PartiallyFulfilled;
    request.fulfilledByUserId = userId ?? null;
    request.fulfilledAt = new Date();
    await this.requestRepo.save(request);
    this.notifyRequester(request, moved);
  }

  /**
   * Tell the requester what landed. Fire-and-forget: a notification failure must
   * never undo a transfer that already moved wagons.
   */
  private notifyRequester(
    request: WagonTransferRequest,
    moved: number,
    closedShortNote?: string | null,
  ): void {
    if (!request.requestedByUserId) return;
    const outstanding = this.remainingOn(request);
    const complete = request.status === WagonTransferRequestStatus.Fulfilled;
    const closedShort =
      request.status === WagonTransferRequestStatus.ClosedShort;

    const title = complete
      ? `All ${request.quantity} wagon(s) transferred`
      : closedShort
        ? `Transfer closed short — ${request.fulfilledQuantity} of ${request.quantity} wagon(s)`
        : `${moved} of ${request.quantity} wagon(s) transferred`;

    const body = complete
      ? `Your wagon transfer request is complete — all ${request.quantity} wagon(s) have arrived.`
      : closedShort
        ? `Only ${request.fulfilledQuantity} of the ${request.quantity} wagon(s) you asked for could be supplied` +
          `${closedShortNote ? `: ${closedShortNote}` : '.'} ` +
          `Request the remaining ${outstanding} from another yard.`
        : `${moved} wagon(s) have arrived against your request. ` +
          `${outstanding} of ${request.quantity} still to come.`;

    void this.inbox
      .notify({
        recipients: { userIds: [request.requestedByUserId] },
        audience: NotificationAudience.BACKOFFICE,
        type: NotificationType.GENERIC,
        title,
        body,
        link: `/dashboard/wagon-transfers/${request.id}`,
        data: {
          transferRequestId: request.id,
          delivered: request.fulfilledQuantity,
          requested: request.quantity,
          outstanding,
        },
      })
      .catch((err) =>
        this.logger.warn(
          `Transfer notification failed for ${request.id}: ${(err as Error).message}`,
        ),
      );
  }

  /**
   * OCC ends a request with fewer wagons than asked for — the source yard has
   * nothing more to give. What already moved stays moved; the requester is told
   * the shortfall so they can raise it against another yard. Cancelling is for
   * requests that never moved anything; this is the close for ones that did.
   */
  async closeShort(
    id: string,
    dto: CloseShortTransferRequestDto,
    userId?: string | null,
  ): Promise<WagonTransferRequest> {
    const request = await this.findById(id);
    if (!this.isOpen(request)) {
      throw new ConflictException(
        `Request is already ${request.status.toLowerCase().replace(/_/g, ' ')}`,
      );
    }
    if (this.remainingOn(request) === 0) {
      throw new ConflictException(
        'Nothing outstanding — this request is already fully supplied',
      );
    }

    request.status = WagonTransferRequestStatus.ClosedShort;
    request.closedShortAt = new Date();
    request.closedShortByUserId = userId ?? null;
    if (dto.note?.trim()) {
      request.note = dto.note.trim();
    }
    await this.requestRepo.save(request);
    this.notifyRequester(request, 0, dto.note ?? null);
    return this.findById(id);
  }

  /**
   * OCC executes a set of open requests in one action, auto-picking AVAILABLE
   * wagons of the requested type from each source yard (lowest wagon number
   * first). A yard that cannot cover the whole ask still sends what it has —
   * the request stays open for the rest rather than being skipped, which is the
   * whole point of instalments. Only a request with NOTHING available is
   * skipped, and nothing is rolled back for the others.
   */
  async bulkFulfill(
    requestIds: string[],
    userId?: string | null,
  ): Promise<{
    fulfilled: WagonTransferRequest[];
    skipped: Array<{ id: string; reason: string }>;
  }> {
    const fulfilled: WagonTransferRequest[] = [];
    const skipped: Array<{ id: string; reason: string }> = [];

    // Sequential on purpose: each executed transfer moves wagons out of the
    // source yard, and the next request's auto-pick must see that new state.
    for (const id of [...new Set(requestIds)]) {
      const request = await this.requestRepo.findOne({ where: { id } });
      if (!request) {
        skipped.push({ id, reason: 'Request not found' });
        continue;
      }
      if (!this.isOpen(request)) {
        skipped.push({
          id,
          reason: `Already ${request.status.toLowerCase().replace(/_/g, ' ')}`,
        });
        continue;
      }
      const remaining = this.remainingOn(request);
      const wagons = await this.wagonRepo.find({
        where: {
          currentYardId: request.fromYardId,
          wagonTypeId: request.wagonTypeId,
          status: WagonStatus.Available,
        },
        order: { wagonNumber: 'ASC' },
        take: remaining,
      });
      if (wagons.length === 0) {
        skipped.push({
          id,
          reason: 'No available wagons of this type in the source yard — left open',
        });
        continue;
      }
      await this.wagonsService.bulkTransfer(
        { wagonIds: wagons.map((w) => w.id), toYardId: request.toYardId },
        userId,
        { transferRequestId: request.id },
      );
      await this.recordDelivery(request, wagons.length, userId);
      fulfilled.push(await this.findById(id));
    }

    return { fulfilled, skipped };
  }

  /**
   * Per-user transfer history: the requests a user filed OR fulfilled, plus the
   * individual wagons they physically moved (linked back to their request when
   * one drove the move). Pass a `userId` to scope to one staffer; pass
   * `undefined` for the admin all-staff view. Scope is decided by the CALLER
   * (the controller passes the caller's id unless they hold the history-all
   * permission) — this method trusts its argument.
   */
  async getHistory(
    userId?: string | null,
    page?: number,
    pageSize?: number,
  ): Promise<TransferHistory> {
    const take = Math.min(pageSize ?? 20, HISTORY_LIMIT);
    const skip = ((page ?? 1) - 1) * take;

    const [requests, requestsTotal] = await this.requestRepo.findAndCount({
      where: userId
        ? [{ requestedByUserId: userId }, { fulfilledByUserId: userId }]
        : {},
      relations: REQUEST_RELATIONS,
      order: { createdAt: 'DESC' },
      skip,
      take,
    });

    const [movements, movementsTotal] = await this.movementRepo.findAndCount({
      // Own view: moves I made. All view: every user-attributed move (skip the
      // system-written loaded/reposition legs that carry no mover).
      where: userId
        ? { movedByUserId: userId }
        : { movedByUserId: Not(IsNull()) },
      relations: { wagon: true, fromYard: true, toYard: true, transferRequest: true },
      order: { occurredAt: 'DESC' },
      skip,
      take,
    });

    return {
      requests,
      movements,
      meta: {
        page: page ?? 1,
        pageSize: take,
        requestsTotal,
        movementsTotal,
        // Whichever list is longer decides how far the pager can go.
        totalPages: Math.max(
          1,
          Math.ceil(Math.max(requestsTotal, movementsTotal) / take),
        ),
      },
    };
  }

  /**
   * Withdraw a request before anything moved. Once wagons have been delivered
   * the request can only be completed or closed short — cancelling would erase
   * the fact that a transfer happened.
   */
  async cancelRequest(id: string): Promise<WagonTransferRequest> {
    const request = await this.findById(id);
    if (request.status !== WagonTransferRequestStatus.Pending) {
      throw new ConflictException(
        request.status === WagonTransferRequestStatus.PartiallyFulfilled
          ? 'Wagons have already moved against this request — close it short instead of cancelling'
          : `Only pending requests can be cancelled (this one is ${request.status.toLowerCase().replace(/_/g, ' ')})`,
      );
    }
    request.status = WagonTransferRequestStatus.Cancelled;
    await this.requestRepo.save(request);
    return this.findById(id);
  }
}
