import {
  BadRequestException,
  ConflictException,
  ForbiddenException,
  Injectable,
  NotFoundException,
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { insertWithGeneratedReference } from '@edr/api-common';

import { YardCountry } from '@edr/types';

import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { CompaniesService } from '../companies/companies.service';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service';
import { ContractsRepository } from './contracts.repository';
import { CreateContractDto } from './dto/create-contract.dto';
import { UpdateContractDto } from './dto/update-contract.dto';
import { FilterContractDto } from './dto/filter-contract.dto';
import { ContractListSummaryDto } from './dto/contract-list-summary.dto';
import { Contract, CONTRACT_STATUSES, CONTRACT_CUSTOMER_EDITABLE_STATUSES } from './entities/contract.entity';
import { ContractRoute } from './entities/contract-route.entity';
import { ContractCargoScope } from './entities/contract-cargo-scope.entity';
import { isEffectivelyExpired } from './utils/contract-expiry.util';
import { diffContractFields } from './contract-document-diff.util';
import { ContractDocumentHistoryService } from './contract-document-history.service';
import { FileRecord } from '../files/entities/file.entity';

/** Paginated contract list: flat `total` (backoffice) + `meta` block (portal). */
export interface PaginatedContracts {
  items: Contract[];
  total: number;
  meta: {
    page: number;
    pageSize: number;
    total: number;
    totalPages: number;
    hasNextPage: boolean;
    hasPreviousPage: boolean;
  };
}

/** Route list as a readable lane string, e.g. "Nagad → Mojo, Mojo → Adama". */
function describeRoutes(routes?: ContractRoute[]): string | null {
  if (!routes?.length) return null;
  return [...routes]
    .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
    .map(
      (r) =>
        `${r.originYard?.label ?? r.originYardId} → ${r.destinationYard?.label ?? r.destinationYardId}`,
    )
    .join(', ');
}

/** Cargo scope as a readable string, e.g. "20ft ×2, 40ft ×1" or "Wheat ×500". */
function describeCargoScope(scope?: ContractCargoScope[]): string | null {
  if (!scope?.length) return null;
  return scope
    .map((row) => {
      const label =
        row.containerSize ??
        row.cargoType?.cargoTypeName ??
        row.cargoFreeText ??
        row.cargoTypeId ??
        'cargo';
      return row.quantityCap != null ? `${label} ×${row.quantityCap}` : String(label);
    })
    .sort()
    .join(', ');
}

const NEEDS_ACTION_STATUSES = [
  'SUBMITTED',
  'PENDING_APPROVAL',
  'APPROVED_PENDING_SIGNATURE',
  'SIGNED_CUSTOMER',
] as const;

@Injectable()
export class ContractsService {
  constructor(
    @InjectDataSource() private readonly dataSource: DataSource,
    private readonly contractsRepository: ContractsRepository,
    private readonly companiesService: CompaniesService,
    private readonly filesService: FilesService,
    private readonly minioService: MinioService,
    private readonly documentHistory: ContractDocumentHistoryService,
  ) {}

  /** Generate a unique contract reference number (CTR-YYYY-NNNNN). */
  private async generateReference(): Promise<string> {
    const year = new Date().getFullYear();
    const seq = await this.contractsRepository.maxReferenceSequence(year);
    return `CTR-${year}-${String(seq + 1).padStart(5, '0')}`;
  }

  /** Whether a service type bundles customs clearance. */
  private async resolveIncludesCustoms(serviceTypeId: string): Promise<boolean> {
    const serviceType = await this.dataSource
      .getRepository(ServiceType)
      .findOne({ where: { id: serviceTypeId } });
    return serviceType?.includesCustoms ?? false;
  }

  /** Validate cargo-scope rows against freight type (doc §5.4). */
  private assertCargoScopeShape(
    freightType: string,
    cargoScope: CreateContractDto['cargoScope'],
  ): void {
    if (freightType === 'CONTAINER') {
      const sizes = cargoScope.filter((c) =>
        ['20ft', '40ft'].includes(c.containerSize ?? ''),
      );
      if (sizes.length === 0) {
        throw new BadRequestException(
          'CONTAINER contracts require at least one container size (20ft/40ft) in scope',
        );
      }
    } else {
      const bulk = cargoScope.filter((c) => c.cargoTypeId);
      if (bulk.length !== 1) {
        throw new BadRequestException(
          'BULK contracts require exactly one cargo-type scope row',
        );
      }
      if (cargoScope.some((c) => c.containerSize)) {
        throw new BadRequestException('BULK contracts must not set a container size');
      }
    }
  }

  /** Validate route count against contract kind (doc §5.3). */
  private assertRouteShape(
    contractKind: string,
    routes: CreateContractDto['routes'],
  ): void {
    if (contractKind === 'ONE_TIME' && routes.length !== 1) {
      throw new BadRequestException('ONE_TIME contracts require exactly one route');
    }
    if (routes.length < 1) {
      throw new BadRequestException('A contract requires at least one route');
    }
  }

  /**
   * Every route must match the contract's declared trade direction as derived
   * from the yard countries (IMPORT = DJ→ET, EXPORT = ET→DJ, DOMESTIC =
   * intercity). Intercity is Ethiopian-domestic only: both yards must be in
   * Ethiopia — a Djibouti-internal pair is rejected. Direction mismatches
   * (e.g. an export lane on an import contract) are rejected for every kind.
   */
  private async assertRoutesMatchDirection(
    tradeDirection: string,
    routes: CreateContractDto['routes'],
  ): Promise<void> {
    const yardIds = [
      ...new Set(routes.flatMap((r) => [r.originYardId, r.destinationYardId])),
    ];
    const yards = await this.dataSource
      .getRepository(Yard)
      .find({ where: yardIds.map((id) => ({ id })) });
    const yardById = new Map(yards.map((y) => [y.id, y]));

    for (const route of routes) {
      const origin = yardById.get(route.originYardId);
      const destination = yardById.get(route.destinationYardId);
      if (!origin || !destination) {
        throw new BadRequestException('Route references a yard that does not exist');
      }
      const derived = deriveTradeDirection(origin, destination);
      if (derived !== tradeDirection) {
        throw new BadRequestException(
          `Route ${origin.label} → ${destination.label} is ${derived === 'DOMESTIC' ? 'an intercity' : `an ${derived.toLowerCase()}`} lane and does not match the contract's ${tradeDirection === 'DOMESTIC' ? 'intercity' : tradeDirection.toLowerCase()} direction`,
        );
      }
      if (
        derived === 'DOMESTIC' &&
        (origin.country !== YardCountry.ETHIOPIA ||
          destination.country !== YardCountry.ETHIOPIA)
      ) {
        throw new BadRequestException(
          `Route ${origin.label} → ${destination.label}: intercity service only runs between Ethiopian yards`,
        );
      }
    }
  }

  /**
   * Same customer + same service type + an overlapping route already has a
   * non-expired contract → block. A route "overlaps" if any origin/destination
   * pair matches — good enough today since ONE_TIME and GENERAL contracts both
   * carry a single route in practice, and still correct if that changes.
   */
  private async assertNoDuplicateContract(
    companyId: string,
    serviceTypeId: string,
    routes: CreateContractDto['routes'],
  ): Promise<void> {
    const candidates = await this.contractsRepository.findDuplicateCandidates(
      companyId,
      serviceTypeId,
    );
    const duplicate = candidates.find(
      (c) =>
        !isEffectivelyExpired(c) &&
        (c.routes ?? []).some((existingRoute) =>
          routes.some(
            (r) =>
              r.originYardId === existingRoute.originYardId &&
              r.destinationYardId === existingRoute.destinationYardId,
          ),
        ),
    );
    if (duplicate) {
      const until = duplicate.contractValidUntil
        ? duplicate.contractValidUntil.toISOString().slice(0, 10)
        : 'its approval completes';
      throw new ConflictException(
        `An active contract already exists for this service type and route (${duplicate.reference}, valid until ${until}). A new request can't be submitted until it expires or is rejected/cancelled.`,
      );
    }
  }

  /** Create a new contract (DRAFT) with its routes and cargo-scope rows. */
  async create(
    dto: CreateContractDto,
    files: Express.Multer.File[],
    userId?: string,
  ): Promise<{ contract: Contract; warnings: string[] }> {
    const warnings: string[] = [];
    const isGovernment = dto.isGovernment === true;

    let companyId: string | null | undefined = dto.companyId;
    if (isGovernment) {
      if (!dto.governmentInstitution?.trim()) {
        throw new BadRequestException(
          'governmentInstitution is required for government contracts',
        );
      }
      companyId = dto.companyId ?? null;
    } else if (!companyId) {
      if (!userId) {
        throw new BadRequestException(
          'companyId is required or must be resolvable from auth token',
        );
      }
      const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
      this.companiesService.assertCompanyActiveFor(company, 'contracts');
      companyId = company.id;
    }

    this.assertCargoScopeShape(dto.freightType, dto.cargoScope);
    this.assertRouteShape(dto.contractKind, dto.routes);
    await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
    if (companyId) {
      await this.assertNoDuplicateContract(companyId, dto.serviceTypeId, dto.routes);
    }

    // Stamp the operational profile for portal scoping. A forwarder contract
    // pins its profile explicitly (trade direction can't tell it apart from a
    // direct import/export); everything else resolves from the trade direction.
    let companyProfileId: string | null = null;
    if (!isGovernment && companyId) {
      if (dto.companyProfileId) {
        const profile =
          await this.companiesService.getActiveCompanyProfileForBooking(
            companyId,
            dto.companyProfileId,
          );
        companyProfileId = profile.id;
      } else {
        companyProfileId =
          await this.companiesService.resolveCompanyProfileIdForBooking(
            companyId,
            dto.tradeDirection,
          );

        const customerSelfBooking = !dto.companyId && !!userId;
        if (customerSelfBooking && companyProfileId) {
          await this.companiesService.assertCompanyProfileApprovedForBooking(
            companyProfileId,
          );
        }
      }
    }

    // Customs clearing is owned by the service type, not the customer.
    const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
    // Intercity never crosses a border, so a customs-including service type is
    // a contradiction — the wizard hides them, the API enforces it.
    if (dto.tradeDirection === 'DOMESTIC' && includesCustoms) {
      throw new BadRequestException(
        'Intercity contracts cannot use a service type that includes customs clearing',
      );
    }

    // An explicit reference is caller-chosen — a collision there is a real
    // conflict and should surface. Auto-generated references retry past a
    // concurrent insert that grabbed the same sequence number.
    const contract = dto.reference
      ? await this.insertContract(dto.reference, {
          companyId,
          companyProfileId,
          isGovernment,
          includesCustoms,
          dto,
        })
      : await insertWithGeneratedReference(
          () => this.generateReference(),
          (reference) =>
            this.insertContract(reference, {
              companyId,
              companyProfileId,
              isGovernment,
              includesCustoms,
              dto,
            }),
        );

    await this.persistRoutes(contract.id, dto.routes);
    await this.persistCargoScope(contract.id, dto.cargoScope, contract.contractKind);

    if (files.length > 0) {
      try {
        await this.filesService.uploadMany(contract.id, 'contracts', files);
      } catch {
        warnings.push('File upload failed — contract was created without attached files.');
      }
    }

    // Attach the company's onboarding documents (TIN, licenses, IDs) and the
    // profile's business-license documents to the contract by reference. The
    // separate "Documents" intake step was removed — the profile documents are
    // simply carried onto every contract automatically.
    await this.attachProfileDocuments(contract.id, companyId ?? null, companyProfileId);

    return { contract: await this.findById(contract.id), warnings };
  }

  /** Insert one DRAFT contract row with the given reference (no children). */
  private insertContract(
    reference: string,
    ctx: {
      companyId: string | null | undefined;
      companyProfileId: string | null;
      isGovernment: boolean;
      includesCustoms: boolean;
      dto: CreateContractDto;
    },
  ): Promise<Contract> {
    const { companyId, companyProfileId, isGovernment, includesCustoms, dto } = ctx;
    return this.contractsRepository.create({
      reference,
      companyId: companyId ?? null,
      companyProfileId,
      isGovernment,
      governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
      contractKind: dto.contractKind,
      renewalOfId: dto.renewalOfId ?? null,
      tradeDirection: dto.tradeDirection,
      freightType: dto.freightType,
      serviceTypeId: dto.serviceTypeId,
      // A contract is always QUOTED in USD — the billing currency is chosen per
      // booking (or on the shipment request when GL books for the customer), so
      // any client-supplied currency here is ignored. Contracts created before
      // this rule keep whatever they stored; update() never rewrites it.
      paymentCurrency: 'USD',
      customsClearingEnabled: includesCustoms,
      customsClearingAgent: includesCustoms ? null : (dto.customsClearingAgent ?? null),
      equipmentReturn: dto.equipmentReturn ?? null,
      firstMilePickupAddress: dto.firstMilePickupAddress ?? null,
      firstMilePickupLat: dto.firstMilePickupLat ?? null,
      firstMilePickupLng: dto.firstMilePickupLng ?? null,
      lastMileDeliveryAddress: dto.lastMileDeliveryAddress ?? null,
      lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null,
      lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null,
      isHazardous: dto.isHazardous ?? false,
      // Hazard class / UN number only exist on a hazardous contract — a stale
      // pair from an earlier draft must never survive the flag being turned off.
      hazardClass: dto.isHazardous ? (dto.hazardClass ?? null) : null,
      unNumber: dto.isHazardous ? (dto.unNumber ?? null) : null,
      isReefer: dto.isReefer ?? false,
      contractType: dto.contractType ?? null,
      status: 'DRAFT',
      clearanceStatus: 'NOT_APPLICABLE',
      clearanceCycleNumber: 0,
    } as never);
  }

  /**
   * Copy the company's onboarding documents (TIN certificate, commercial /
   * investment license, national ID, passport — resource "companies", coded by
   * the upload-setting fileKey) and the company profile's business-license
   * documents (resource "company_profiles") onto a contract by reference (no
   * byte re-upload). Idempotent: codes already present on the contract — user
   * uploads or an earlier carry — are never duplicated or overwritten, so it is
   * safe to run on every create and update. No-op when there is nothing to copy.
   */
  private async attachProfileDocuments(
    contractId: string,
    companyId: string | null,
    companyProfileId: string | null,
  ): Promise<void> {
    if (!companyId && !companyProfileId) return;

    const existingCodes = new Set(
      (await this.filesService.findByResource(contractId, 'contracts')).map(
        (r) => r.code,
      ),
    );
    const docs: Array<{
      code: string;
      name: string;
      url: string;
      size: number;
      mimeType?: string;
    }> = [];

    if (companyId) {
      // Company onboarding documents keep their fileKey codes (tin_certificate,
      // commercial_license, …) so the portal can match them against the
      // onboarding upload-setting fields. Re-uploads append rows, so keep only
      // the newest record per code.
      const companyRecords = await this.filesService.findByResource(
        companyId,
        'companies',
      );
      const latestByCode = new Map<string, (typeof companyRecords)[number]>();
      for (const r of companyRecords) {
        const prev = latestByCode.get(r.code);
        if (!prev || r.createdAt > prev.createdAt) latestByCode.set(r.code, r);
      }
      for (const r of latestByCode.values()) {
        if (existingCodes.has(r.code)) continue;
        docs.push({
          code: r.code,
          name: r.name,
          url: r.url,
          size: r.size,
          mimeType: r.mimeType,
        });
      }
    }

    if (companyProfileId) {
      // Business-license files are FileRecords (resource "company_profiles");
      // carry the live ones by reference. Staged/pending uploads are excluded by
      // code. The `business_license` prefix is preserved so the portal groups
      // them under "Business license" instead of the clearance catch-all — the
      // index suffix keeps multiple licences distinct.
      const records = await this.filesService.findByResource(
        companyProfileId,
        'company_profiles',
      );

      records
        .filter((r) => r.code === 'business_license')
        .forEach((r, i) => {
          const code = `business_license_${i + 1}`;
          if (existingCodes.has(code)) return;
          docs.push({
            code,
            name: r.name,
            url: r.url,
            size: r.size,
            mimeType: r.mimeType,
          });
        });
    }

    if (docs.length === 0) return;

    try {
      await this.filesService.attachExistingFiles(contractId, 'contracts', docs);
    } catch {
      // Non-fatal — the contract is still valid without the carried documents.
    }
  }

  private async persistRoutes(
    contractId: string,
    routes: CreateContractDto['routes'],
  ): Promise<void> {
    const repo = this.dataSource.getRepository(ContractRoute);
    await repo.save(
      routes.map((r, i) =>
        repo.create({
          contractId,
          originYardId: r.originYardId,
          destinationYardId: r.destinationYardId,
          km: r.km ?? null,
          sortOrder: r.sortOrder ?? i,
        }),
      ),
    );
  }

  private async persistCargoScope(
    contractId: string,
    cargoScope: CreateContractDto['cargoScope'],
    contractKind: string,
  ): Promise<void> {
    const repo = this.dataSource.getRepository(ContractCargoScope);
    // A quantity cap only governs GENERAL contracts (multi-shipment draw-down).
    // ONE_TIME allows a single booking, so any cap on it is meaningless → null.
    const isGeneral = contractKind === 'GENERAL';
    await repo.save(
      cargoScope.map((c) =>
        repo.create({
          contractId,
          containerSize: c.containerSize ?? null,
          cargoTypeId: c.cargoTypeId ?? null,
          cargoFreeText: c.cargoFreeText ?? null,
          quantityCap: isGeneral ? (c.quantityCap ?? null) : null,
        }),
      ),
    );
  }

  /** Update a DRAFT / CHANGES_REQUESTED contract. */
  async update(
    id: string,
    dto: UpdateContractDto,
    files: Express.Multer.File[],
    actorId?: string,
  ): Promise<{ contract: Contract; warnings: string[] }> {
    const existing = await this.findById(id);
    if (!CONTRACT_CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) {
      throw new BadRequestException(
        'Only DRAFT or CHANGES_REQUESTED contracts can be updated',
      );
    }

    const warnings: string[] = [];
    const freightType = dto.freightType ?? existing.freightType;
    const contractKind = dto.contractKind ?? existing.contractKind;

    if (dto.cargoScope) this.assertCargoScopeShape(freightType, dto.cargoScope);
    if (dto.routes) this.assertRouteShape(contractKind, dto.routes);
    if (dto.routes) {
      await this.assertRoutesMatchDirection(
        dto.tradeDirection ?? existing.tradeDirection,
        dto.routes,
      );
    }

    const updates: Record<string, unknown> = {
      contractKind,
      tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
      freightType,
      serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId,
      // Never rewritten: grandfathered contracts keep the currency (and frozen
      // snapshots) they were signed with.
      paymentCurrency: existing.paymentCurrency,
      isHazardous: dto.isHazardous ?? existing.isHazardous,
      isReefer: dto.isReefer ?? existing.isReefer,
      // Same rule as create: clearing the flag clears the declaration with it.
      hazardClass: (dto.isHazardous ?? existing.isHazardous)
        ? (dto.hazardClass ?? existing.hazardClass ?? null)
        : null,
      unNumber: (dto.isHazardous ?? existing.isHazardous)
        ? (dto.unNumber ?? existing.unNumber ?? null)
        : null,
      equipmentReturn: dto.equipmentReturn ?? existing.equipmentReturn,
      firstMilePickupAddress: dto.firstMilePickupAddress ?? existing.firstMilePickupAddress,
      firstMilePickupLat: dto.firstMilePickupLat ?? existing.firstMilePickupLat,
      firstMilePickupLng: dto.firstMilePickupLng ?? existing.firstMilePickupLng,
      lastMileDeliveryAddress:
        dto.lastMileDeliveryAddress ?? existing.lastMileDeliveryAddress,
      lastMileDeliveryLat: dto.lastMileDeliveryLat ?? existing.lastMileDeliveryLat,
      lastMileDeliveryLng: dto.lastMileDeliveryLng ?? existing.lastMileDeliveryLng,
      contractType: dto.contractType ?? existing.contractType,
    };
    if (dto.renewalOfId !== undefined) updates.renewalOfId = dto.renewalOfId ?? null;

    // Customs clearing always mirrors the (possibly changed) service type.
    const includesCustoms = await this.resolveIncludesCustoms(
      dto.serviceTypeId ?? existing.serviceTypeId,
    );
    if ((dto.tradeDirection ?? existing.tradeDirection) === 'DOMESTIC' && includesCustoms) {
      throw new BadRequestException(
        'Intercity contracts cannot use a service type that includes customs clearing',
      );
    }
    updates.customsClearingEnabled = includesCustoms;
    updates.customsClearingAgent = includesCustoms
      ? null
      : (dto.customsClearingAgent ?? existing.customsClearingAgent ?? null);

    await this.contractsRepository.update(id, updates);

    if (dto.routes) {
      await this.dataSource.getRepository(ContractRoute).delete({ contractId: id });
      await this.persistRoutes(id, dto.routes);
    }
    if (dto.cargoScope) {
      await this.dataSource.getRepository(ContractCargoScope).delete({ contractId: id });
      await this.persistCargoScope(id, dto.cargoScope, existing.contractKind);
    }

    if (files.length > 0) {
      await this.filesService.uploadMany(id, 'contracts', files);
    }

    // Re-carry any company/profile document that is still missing from the
    // contract (runs after the upload so fresh replacements keep their slot).
    // Backfills contracts created before profile documents were carried over.
    await this.attachProfileDocuments(
      id,
      existing.companyId ?? null,
      existing.companyProfileId ?? null,
    );

    const updated = await this.findById(id);
    // Audit what this edit actually changed. Runs after the writes so the
    // "after" side is read back from the contract rather than from the DTO.
    await this.recordFieldRevision(existing, updated, actorId);

    return { contract: updated, warnings };
  }

  /** Fields worth auditing on a customer edit, read off a loaded contract. */
  private auditableFields(contract: Contract): Record<string, unknown> {
    return {
      contractKind: contract.contractKind,
      tradeDirection: contract.tradeDirection,
      freightType: contract.freightType,
      serviceType: contract.serviceType?.serviceName ?? contract.serviceTypeId,
      paymentCurrency: contract.paymentCurrency,
      contractType: contract.contractType,
      isHazardous: contract.isHazardous,
      hazardClass: contract.hazardClass,
      unNumber: contract.unNumber,
      isReefer: contract.isReefer,
      equipmentReturn: contract.equipmentReturn,
      customsClearingAgent: contract.customsClearingAgent,
      firstMilePickupAddress: contract.firstMilePickupAddress,
      lastMileDeliveryAddress: contract.lastMileDeliveryAddress,
      routes: describeRoutes(contract.routes),
      cargoScope: describeCargoScope(contract.cargoScope),
    };
  }

  /** Append a revision describing a customer's edit to the contract itself. */
  private async recordFieldRevision(
    before: Contract,
    after: Contract,
    actorId?: string,
  ): Promise<void> {
    const changes = diffContractFields(
      this.auditableFields(before),
      this.auditableFields(after),
    );
    await this.documentHistory.recordChanges({
      contractId: after.id,
      changes,
      actorId: actorId ?? null,
      actorRole: 'Customer',
    });
  }

  /** Parse comma-separated or repeated status query values. */
  private parseStatusFilter(filter: FilterContractDto): {
    statuses?: string[];
    status?: string;
  } {
    const allowed = new Set<string>(CONTRACT_STATUSES);
    const raw = filter.statuses;
    const statusList = raw
      ? raw
          .split(',')
          .map((s) => s.trim())
          .filter((s) => allowed.has(s))
      : [];

    if (statusList.length > 0) return { statuses: statusList };
    if (filter.status && allowed.has(filter.status)) return { status: filter.status };
    return {};
  }

  async findAll(
    filter: FilterContractDto,
    forceCompanyId?: string,
    forceCompanyProfileId?: string,
  ): Promise<PaginatedContracts> {
    const page = filter.page ?? 1;
    const pageSize = filter.pageSize ?? 20;
    const statusFilter = this.parseStatusFilter(filter);

    return this.contractsRepository.findAllPaginated({
      page,
      pageSize,
      ...statusFilter,
      companyId: forceCompanyId ?? filter.companyId,
      companyProfileId: forceCompanyProfileId ?? filter.companyProfileId,
      contractKind: filter.contractKind,
      serviceTypeId: filter.serviceTypeId,
      freightType: filter.freightType,
      tradeDirection: filter.tradeDirection,
      paymentCurrency: filter.paymentCurrency,
      createdFrom: filter.createdFrom,
      createdTo: filter.createdTo,
      search: filter.search,
      sortBy: filter.sortBy,
      sortOrder: filter.sortOrder,
    });
  }

  /** Aggregate metrics and status counts for the backoffice contract list. */
  async getListSummary(filter: FilterContractDto): Promise<ContractListSummaryDto> {
    const page = filter.page ?? 1;
    const pageSize = filter.pageSize ?? 20;
    const statusFilter = this.parseStatusFilter(filter);
    const listFilter = {
      ...statusFilter,
      companyId: filter.companyId,
      contractKind: filter.contractKind,
      serviceTypeId: filter.serviceTypeId,
      freightType: filter.freightType,
      tradeDirection: filter.tradeDirection,
      paymentCurrency: filter.paymentCurrency,
      createdFrom: filter.createdFrom,
      createdTo: filter.createdTo,
    };

    const [statusCounts, metrics] = await Promise.all([
      this.contractsRepository.getStatusCounts(),
      this.contractsRepository.getListSummaryMetrics({
        ...listFilter,
        page,
        pageSize,
        needsActionStatuses: NEEDS_ACTION_STATUSES,
      }),
    ]);

    return { metrics, statusCounts };
  }

  /** Get a single contract by ID with relations and signed file URLs. */
  async findById(id: string): Promise<Contract> {
    const contract = await this.contractsRepository.findByIdWithRelations(id);
    if (!contract) {
      throw new NotFoundException(`Contract ${id} not found`);
    }

    if (contract.files && contract.files.length > 0) {
      contract.files = await Promise.all(
        contract.files.map(async (file: FileRecord) => {
          const objectName = this.minioService.getObjectNameFromUrl(file.url);
          const signedUrl = await this.minioService.getSignedUrl(objectName, 300);
          return { ...file, signedUrl } as FileRecord;
        }),
      );
    }

    // Surface the staff "request changes" note so the portal can show the
    // customer what to fix. Degrade to null on lookup failure — a missing note
    // must never 500 a contract fetch.
    if (contract.status === 'CHANGES_REQUESTED') {
      try {
        const note = await this.contractsRepository.findLatestReviewNote(
          contract.id,
          'CHANGES_REQUESTED',
        );
        contract.latestChangeRequestNote = note?.body ?? null;
      } catch {
        contract.latestChangeRequestNote = null;
      }
    }

    // Surface the rejection reason. The approval-step note is wiped on
    // send-back resets, so the review-note trail is the only durable source.
    if (contract.status === 'REJECTED') {
      try {
        const note = await this.contractsRepository.findLatestReviewNote(
          contract.id,
          'REJECTION',
        );
        contract.latestRejectionNote = note?.body ?? null;
      } catch {
        contract.latestRejectionNote = null;
      }
    }

    // Surface the send-back reason to the returned-to approver, but only while
    // it is still actionable: once any step acts after the send-back the note
    // is stale and stays out of the response (the trail keeps it in the DB).
    if (contract.status === 'PENDING_APPROVAL') {
      try {
        const note = await this.contractsRepository.findLatestReviewNote(
          contract.id,
          'STAFF_NOTE',
        );
        // Stale when any step acted after it (send-back resolved) or when the
        // chain itself is newer than the note (fresh cycle after a resubmit).
        const staleAfter = Math.max(
          0,
          ...(contract.approvalSteps ?? []).flatMap((s) => [
            s.actedAt ? new Date(s.actedAt).getTime() : 0,
            s.createdAt ? new Date(s.createdAt).getTime() : 0,
          ]),
        );
        contract.latestSendBackNote =
          note && new Date(note.createdAt).getTime() > staleAfter
            ? note.body
            : null;
      } catch {
        contract.latestSendBackNote = null;
      }
    }

    return contract;
  }

  async findByReference(reference: string): Promise<Contract> {
    const found = await this.contractsRepository.findByReference(reference);
    if (!found) {
      throw new NotFoundException(`Contract with reference "${reference}" not found`);
    }
    return this.findById(found.id);
  }

  /** Upload intake documents for a DRAFT contract. */
  async uploadDocuments(
    id: string,
    files: Express.Multer.File[],
  ): Promise<Contract> {
    const contract = await this.findById(id);
    if (contract.status !== 'DRAFT') {
      throw new BadRequestException(
        'Documents can only be uploaded for DRAFT contracts',
      );
    }
    await this.filesService.uploadMany(id, 'contracts', files);
    return this.findById(id);
  }

  async remove(id: string): Promise<void> {
    const contract = await this.findById(id);
    if (contract.status !== 'DRAFT') {
      throw new BadRequestException('Only DRAFT contracts can be deleted');
    }
    await this.contractsRepository.softDelete(id);
  }

  /** Resolve the company a customer user belongs to, for scoping their contracts. */
  async resolveCustomerCompanyId(userId: string): Promise<string | null> {
    try {
      const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
      return company?.id ?? null;
    } catch {
      return null;
    }
  }

  /** Authorize a customer's access to a single contract (hides as NotFound otherwise). */
  async assertCustomerCanAccessContract(
    userId: string | undefined,
    contract: Contract,
  ): Promise<void> {
    if (!userId) {
      throw new ForbiddenException('Authentication required');
    }
    const companyId = await this.resolveCustomerCompanyId(userId);
    if (!companyId || contract.companyId !== companyId) {
      throw new NotFoundException(`Contract ${contract.id} not found`);
    }
  }
}
