import {
  BadRequestException,
  ConflictException,
  Injectable,
  Logger,
  ServiceUnavailableException,
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { randomUUID } from 'node:crypto';
import { Readable } from 'stream';
import { insertWithGeneratedReference } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';

import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder';
import { ContractRendererService } from '../../contracts/contract-renderer.service';
import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { ContractViewModel } from '../../contracts/contract-view-model.builder';
import { MinioService } from '../minio/minio.service';
import { FileRecord } from '../files/entities/file.entity';
import {
  assertCanApproveContractStep,
  assertFreightPermission,
  canEditContractStep,
  HAZARDOUS_APPROVAL_ROLES,
} from '../../common/freight-permission.util';
import {
  FREIGHT_PERMS,
  forFreightType,
} from '../../seed/freight-permissions.registry';
import { ContractDocumentHistoryService } from './contract-document-history.service';
import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
import { FilesService } from '../files/files.service';
import { SignaturesService } from '../signatures/signatures.service';
import { OtpService } from '../otp/otp.service';
import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
import { ContractPricingService } from './contract-pricing.service';
import { ContractNotifierService } from './contract-notifier.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractsRepository } from './contracts.repository';
import { ContractsService } from './contracts.service';
import { contractClearanceSettingCode } from './contract-clearance.util';
import {
  Contract,
  ContractDocumentArticle,
  ContractDocumentSnapshot,
  ContractDocumentSnapshotInput,
} from './entities/contract.entity';
import { ContractSignerRole } from './entities/contract-signature.entity';
import { ContractApprovalStep } from './entities/contract-approval-step.entity';
import { SignContractDto } from './dto/sign-contract.dto';

/** The editable contract-document draft returned for the accept/edit dialog. */
export interface ContractDocumentDraft {
  documentTitle: string | null;
  whereasClauses: string[];
  articles: ContractDocumentArticle[];
  code: string | null;
  name: string | null;
  /** True when THIS caller may not edit — the inverse of `editableByMe`. */
  locked: boolean;
  /** Whether the requesting user is the approver whose turn it is to edit. */
  editableByMe: boolean;
  /** Role holding editing rights right now, for "locked because…" messaging. */
  nextApproverRole: string | null;
  generatedAt: Date | null;
  status: string;
}

/**
 * Dropdown-settings code holding the admin-configured contract validity options
 * (each option's `value` is a day count). The staff accept dialog reads the same
 * code, so accept can only use a configured duration. See the seed migration
 * `SeedContractValidityPeriods`.
 */
const CONTRACT_VALIDITY_PERIODS_CODE = 'contract_validity_periods';

/**
 * Approval chains are configured in IAM position types, so a step's role no
 * longer maps onto the contract's fixed approver columns. These sets keep those
 * legacy columns populated for the roles that still correspond to one — both the
 * original role strings on historical rows and the position types that replaced
 * them. Steps outside these sets are recorded only in `contract_approval_steps`,
 * which is the source of truth.
 */
const LEGACY_STAFF_ROLES = new Set([
  'LINE_STAFF',
  'employee',
  'teamLeader',
  'officeHead',
  'recordOfficer',
]);
const LEGACY_DIRECTOR_ROLES = new Set([
  'DIRECTOR',
  'director',
  'operation-director',
]);
const LEGACY_CEO_ROLES = new Set(['CEO', 'chief', 'deputy']);

/**
 * Mask a phone for display — keep the last 4 digits, star the rest
 * (`+251986680099` → `•••••••0099`). Used to tell the customer WHERE the signing
 * code went without echoing the company's full registered number back to the UI.
 */
function maskPhone(phone: string): string {
  const trimmed = phone.trim();
  if (trimmed.length <= 4) return trimmed;
  return `${'•'.repeat(trimmed.length - 4)}${trimmed.slice(-4)}`;
}

/** Email counterpart of {@link maskPhone} (`jane@x.com` → `j•••@x.com`). */
function maskEmail(email: string): string {
  const [local, domain] = email.trim().split('@');
  if (!domain) return email.trim();
  return `${local.slice(0, 1)}${'•'.repeat(Math.max(local.length - 1, 1))}@${domain}`;
}

/**
 * Where the signing code went, for the "we sent a code to …" line in the UI.
 * Both contacts are listed when both were used — a signer who only watches their
 * handset otherwise has no idea the email carries the same code.
 */
function maskSignerContacts(contacts: { phone?: string; email?: string }): string {
  return [
    contacts.email ? maskEmail(contacts.email) : null,
    contacts.phone ? maskPhone(contacts.phone) : null,
  ]
    .filter(Boolean)
    .join(' and ');
}

/** Status-machine guard mirroring booking-status.util. */
function assertContractStatus(contract: Contract, allowed: string[]): void {
  if (!allowed.includes(contract.status)) {
    throw new ConflictException(
      `Cannot perform this action on status "${contract.status}". Allowed: ${allowed.join(', ')}`,
    );
  }
}

@Injectable()
export class ContractTransitionService {
  private readonly logger = new Logger(ContractTransitionService.name);

  constructor(
    private readonly documentHistory: ContractDocumentHistoryService,
    private readonly contractsRepository: ContractsRepository,
    private readonly contractsService: ContractsService,
    private readonly pricingService: ContractPricingService,
    private readonly approvalRulesService: ApprovalRulesService,
    private readonly cargoTypesService: CargoTypesService,
    private readonly dropdownSettingsService: DropdownSettingsService,
    private readonly filesService: FilesService,
    private readonly signaturesService: SignaturesService,
    private readonly milestoneService: ClearanceMilestoneService,
    private readonly documentViewModelBuilder: ContractDocumentViewModelBuilder,
    private readonly renderer: ContractRendererService,
    private readonly pdfService: ContractPdfService,
    private readonly minioService: MinioService,
    private readonly otpService: OtpService,
    private readonly notifier: ContractNotifierService,
    private readonly contractTemplates: ContractTemplatesService,
    @InjectDataSource()
    private readonly dataSource: DataSource,
  ) {}

  /**
   * The contacts the signing OTP is sent to and verified against: the signer's
   * own IAM account phone AND email. One code goes to both and either delivery
   * verifies it, so a signer whose SMS is delayed can still complete from their
   * inbox instead of abandoning a ready contract.
   *
   * H12(b): resolved server-side from the authenticated user id, never from the
   * request body — caller-supplied contacts would let an attacker point the code
   * at their own phone or mailbox. Ownership is already gated separately by
   * {@link ContractsService.assertCustomerCanAccessContract}, so this binds the
   * signature to the *person* signing rather than to a company landline that may
   * be shared, stale, or imported from eTrade.
   */
  private async resolveSignerContacts(
    signerUserId?: string,
  ): Promise<{ phone?: string; email?: string }> {
    if (!signerUserId) {
      // Unreachable in practice (the ownership gate rejects a missing user
      // first), but never fall back to another account if it ever changes.
      throw new BadRequestException('Authentication required to sign');
    }
    const rows: Array<{ phone_number: string | null; email: string | null }> =
      await this.dataSource.query(
        `SELECT phone_number, email FROM iam.users WHERE id = $1 AND is_active = true`,
        [signerUserId],
      );
    const phone = rows[0]?.phone_number?.trim();
    const email = rows[0]?.email?.trim();
    if (!phone && !email) {
      throw new BadRequestException(
        'Your account has no registered phone number or email. Add one in Settings → Account before signing.',
      );
    }
    return { ...(phone ? { phone } : {}), ...(email ? { email } : {}) };
  }

  /** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
  async submit(contractId: string): Promise<Contract> {
    const contract = await this.contractsService.findById(contractId);
    assertContractStatus(contract, ['DRAFT', 'CHANGES_REQUESTED']);

    await this.pricingService.generatePrice(contractId);
    await this.pricingService.freezeRateSnapshots(contractId);

    await this.contractsRepository.update(contractId, {
      status: 'SUBMITTED',
    } as never);
    const updated = await this.contractsService.findById(contractId);
    this.notifier.submittedToStaff(updated);
    return updated;
  }

  /** Confirm a price change before submit (mirrors booking confirm-submit). */
  async confirmSubmit(contractId: string): Promise<Contract> {
    const contract = await this.contractsService.findById(contractId);
    assertContractStatus(contract, ['PRICE_CHANGED_PENDING_CONFIRM']);

    await this.pricingService.generatePrice(contractId);
    await this.pricingService.freezeRateSnapshots(contractId);

    await this.contractsRepository.update(contractId, {
      status: 'SUBMITTED',
    } as never);
    const updated = await this.contractsService.findById(contractId);
    this.notifier.submittedToStaff(updated);
    return updated;
  }

  /**
   * Line staff accepts intake: set the validity window from validityDays and
   * instantiate the approval steps from approval_rules → PENDING_APPROVAL.
   */
  async staffAccept(
    contractId: string,
    actorId: string,
    validityDays: number,
    documentSnapshot?: ContractDocumentSnapshotInput | null,
    user?: TCurrentUser | null,
    window?: { validFrom?: string | null; validUntil?: string | null },
  ): Promise<Contract> {
    const contract = await this.contractsService.findById(contractId);
    // The route guard passes on either arm; the contract's freight type decides
    // which one is actually required (accept bulk ≠ accept container).
    assertFreightPermission(
      user,
      forFreightType(FREIGHT_PERMS.contracts.staffAccept, contract.freightType),
    );
    assertContractStatus(contract, ['SUBMITTED']);

    if (!Number.isInteger(validityDays) || validityDays < 1) {
      throw new BadRequestException(
        'A contract validity (in days) is required to accept this contract.',
      );
    }

    // Staff picked an explicit window in the accept dialog — honour it verbatim
    // (any start, any end). Only the legacy days-only payload is still held to
    // the admin-configured period list.
    const picked = window?.validFrom && window?.validUntil;
    if (!picked) await this.assertValidityDaysConfigured(validityDays);

    const validFrom = picked ? new Date(window!.validFrom!) : new Date();
    const validUntil = picked ? new Date(window!.validUntil!) : new Date(validFrom);
    if (!picked) validUntil.setDate(validUntil.getDate() + validityDays);
    if (validUntil.getTime() <= validFrom.getTime()) {
      throw new BadRequestException(
        'The contract validity end date must be after the start date.',
      );
    }

    await this.instantiateApprovalSteps(contract);

    // Freeze the contract document for THIS contract only. Staff may have edited
    // the articles in the accept dialog; otherwise the live template is captured
    // as-is so later template edits never change an in-flight contract. The
    // shared six templates are never written here.
    const snapshot = await this.resolveDocumentSnapshot(contract, documentSnapshot);

    // Audit whatever staff changed in the accept dialog. The baseline is the
    // template this contract would otherwise have frozen as-is, so an untouched
    // accept diffs to nothing and records no revision.
    if (documentSnapshot) {
      const baseline = await this.resolveDocumentSnapshot(contract);
      await this.documentHistory.record({
        contractId,
        before: baseline,
        after: snapshot,
        actorId,
        actorRole: 'Reviewing staff',
      });
    }

    await this.contractsRepository.update(contractId, {
      status: 'PENDING_APPROVAL',
      approvedByStaffId: actorId,
      approvedByStaffAt: validFrom,
      contractValidityDays: validityDays,
      contractValidFrom: validFrom,
      contractValidUntil: validUntil,
      documentSnapshot: snapshot,
    } as never);
    const updated = await this.contractsService.findById(contractId);
    this.notifier.accepted(updated);
    return updated;
  }

  // ── Per-contract document snapshot (US: edit articles for one contract) ─────

  /**
   * The editable document draft for the accept/edit dialog: the frozen snapshot
   * if one exists, else the live active template resolved for this contract's
   * direction/freight pair. `locked` flips true once the document may no longer
   * be edited (an approver has acted, or the contract has left the pre-approval
   * window).
   */
  async getContractDocumentDraft(
    contractId: string,
    user?: TCurrentUser | null,
  ): Promise<ContractDocumentDraft> {
    const contract = await this.contractsService.findById(contractId);
    const snapshot =
      (contract.documentSnapshot as ContractDocumentSnapshot | null) ??
      (await this.resolveDocumentSnapshot(contract));
    const editableByMe = await this.documentIsEditableBy(contract, user);
    return {
      documentTitle: snapshot?.documentTitle ?? null,
      whereasClauses: snapshot?.whereasClauses ?? [],
      articles: snapshot?.articles ?? [],
      code: snapshot?.code ?? null,
      name: snapshot?.name ?? null,
      locked: !editableByMe,
      editableByMe,
      nextApproverRole: await this.nextApproverRole(contract),
      generatedAt: contract.contractGeneratedAt ?? null,
      status: contract.status,
    };
  }

  /**
   * Replace this contract's document articles from the editor. Per-contract
   * only — it writes the contract's own snapshot and never the shared templates.
   * Allowed while the document is still editable (PENDING_APPROVAL, no approver
   * has acted).
   */
  async updateContractDocument(
    contractId: string,
    input: ContractDocumentSnapshotInput,
    user?: TCurrentUser | null,
    actorId?: string,
  ): Promise<Contract> {
    const contract = await this.contractsService.findById(contractId);
    assertContractStatus(contract, ['PENDING_APPROVAL']);
    await this.assertDocumentEditable(contract, user);

    const current =
      (contract.documentSnapshot as ContractDocumentSnapshot | null) ??
      (await this.resolveDocumentSnapshot(contract));
    const merged: ContractDocumentSnapshotInput = {
      code: current?.code ?? null,
      name: input.name ?? current?.name ?? null,
      documentTitle: input.documentTitle ?? current?.documentTitle ?? null,
      whereasClauses: input.whereasClauses ?? current?.whereasClauses ?? [],
      articles: input.articles ?? current?.articles ?? [],
    };
    const next = this.normalizeSnapshot(merged);
    await this.contractsRepository.update(contractId, {
      documentSnapshot: next,
    } as never);

    // Audit the edit after it lands. Recording history must never break the
    // edit itself, so the history service swallows its own failures.
    const step = await this.contractsRepository.findNextPendingApprovalStep(
      contractId,
    );
    await this.documentHistory.record({
      contractId,
      before: current,
      after: next,
      actorId: actorId ?? null,
      actorRole: step?.requiredRole ?? null,
      stepId: step?.id ?? null,
    });

    return this.contractsService.findById(contractId);
  }

  /**
   * Build the per-contract document snapshot. Prefer the staff's edited articles
   * from the dialog; otherwise freeze the active template matching the
   * contract's direction/freight. Returns null when no active template exists
   * (the renderer then falls back to the built-in generic layout at render time).
   */
  private async resolveDocumentSnapshot(
    contract: Contract,
    provided?: ContractDocumentSnapshotInput | null,
  ): Promise<ContractDocumentSnapshot | null> {
    if (provided && (provided.articles?.length ?? 0) > 0) {
      return this.normalizeSnapshot(provided);
    }
    const active = await this.contractTemplates.findActiveForContract(
      contract.tradeDirection,
      contract.freightType,
    );
    if (!active) return null;
    return {
      code: active.code,
      name: active.name,
      documentTitle: active.documentTitle,
      whereasClauses: active.whereasClauses ?? [],
      articles: this.normalizeArticles(active.articles ?? []),
    };
  }

  private normalizeSnapshot(
    input: ContractDocumentSnapshotInput,
  ): ContractDocumentSnapshot {
    return {
      code: input.code ?? null,
      name: input.name ?? null,
      documentTitle: input.documentTitle ?? null,
      whereasClauses: Array.isArray(input.whereasClauses)
        ? input.whereasClauses
            .map((c) => String(c))
            .filter((c) => c.trim().length > 0)
        : [],
      articles: this.normalizeArticles(input.articles ?? []),
    };
  }

  /** Re-key ids and renumber order sequentially, dropping empty-title rows. */
  private normalizeArticles(
    articles: Array<{ id?: string; title?: string; body?: string; order?: number }>,
  ): ContractDocumentArticle[] {
    return articles
      .filter((a) => (a.title ?? '').trim().length > 0 || (a.body ?? '').trim().length > 0)
      .map((a, index) => ({
        id: a.id ?? randomUUID(),
        title: (a.title ?? '').trim(),
        body: a.body ?? '',
        order: index + 1,
      }));
  }

  /**
   * The contract document stays editable for the whole approval chain, but only
   * by the approver whose turn it is: whoever can action the next pending step.
   * Approving therefore hands editing rights to the next approver in the chain.
   *
   * Edits never reset approvals already given — earlier approvers stay approved.
   */
  private async documentIsEditableBy(
    contract: Contract,
    user?: TCurrentUser | null,
  ): Promise<boolean> {
    if (contract.status === 'SUBMITTED') return true;
    if (contract.status !== 'PENDING_APPROVAL') return false;

    const next = await this.contractsRepository.findNextPendingApprovalStep(
      contract.id,
    );
    if (!next) return false;
    if (!user) return false;

    // Strict match: ONLY the approver whose turn it is (the next pending step's
    // role) may edit. Using the looser approve gate here let any approver who
    // held a contract-approve permission keep the edit button after acting —
    // approval must hand edit rights to the next approver, not share them.
    return canEditContractStep(user, next.requiredRole);
  }

  /** The role that currently holds editing rights, for UI messaging. */
  private async nextApproverRole(contract: Contract): Promise<string | null> {
    if (contract.status !== 'PENDING_APPROVAL') return null;
    const next = await this.contractsRepository.findNextPendingApprovalStep(
      contract.id,
    );
    return next?.requiredRole ?? null;
  }

  private async assertDocumentEditable(
    contract: Contract,
    user?: TCurrentUser | null,
  ): Promise<void> {
    if (await this.documentIsEditableBy(contract, user)) return;

    const role = await this.nextApproverRole(contract);
    throw new ConflictException(
      role
        ? `The contract document can only be edited by the current approver (${role}).`
        : 'The contract document is locked — the contract has advanced beyond approval.',
    );
  }

  /**
   * Ensure the chosen validity (days) is one of the admin-configured options in
   * the `contract_validity_periods` dropdown setting. If the setting is missing
   * or has no options yet, fall back to the DTO range check (already applied) so
   * acceptance is never hard-blocked before an admin configures the list.
   */
  private async assertValidityDaysConfigured(validityDays: number): Promise<void> {
    let setting;
    try {
      setting = await this.dropdownSettingsService.getByCode(
        CONTRACT_VALIDITY_PERIODS_CODE,
      );
    } catch {
      // Not configured yet — keep the flow working with the DTO range only.
      return;
    }

    const allowed = (setting.children ?? [])
      .map((o) => Number(o.value))
      .filter((n) => Number.isFinite(n));
    if (allowed.length === 0) return;

    if (!allowed.includes(validityDays)) {
      throw new BadRequestException(
        `Validity ${validityDays} days is not a configured option. Allowed: ${allowed
          .sort((a, b) => a - b)
          .join(', ')} days.`,
      );
    }
  }

  /**
   * Build contract approval steps from the system approval_rules chain (US-06:
   * container → line staff + director; bulk → directors + CEO). Mirrors the
   * booking transition's instantiateApprovalSteps but writes contract steps.
   */
  private async instantiateApprovalSteps(contract: Contract): Promise<void> {
    if ((contract.approvalSteps?.length ?? 0) > 0) return;

    const cargoTypeId =
      (contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId ?? null;

    // Resolve the chain from the cargo type flag only.
    let requiresDirectorApproval = false;
    if (cargoTypeId) {
      const cargoType = await this.cargoTypesService.findById(cargoTypeId);
      requiresDirectorApproval = cargoType?.requiresDirectorApproval ?? false;
    }

    const chain = await this.approvalRulesService.findChain(requiresDirectorApproval);
    if (chain.length === 0) {
      throw new BadRequestException(
        `Approval chain could not be loaded for requiresDirectorApproval=${requiresDirectorApproval}.`,
      );
    }

    // Dangerous goods clear two dedicated hazardous desks BEFORE the commercial
    // chain — if either refuses, the contract never reaches the approvers who
    // would price and sign it. Steps are renumbered sequentially so the prefix
    // and the configured chain form one ordered list.
    const roles: Array<{ requiredRole: string; blocksRole: string | null }> = [
      ...(contract.isHazardous ? [...HAZARDOUS_APPROVAL_ROLES] : []).map(
        (requiredRole) => ({ requiredRole, blocksRole: null }),
      ),
      ...chain.map((rule) => ({
        requiredRole: rule.requiredRole,
        blocksRole: rule.blocksRole ?? null,
      })),
    ];

    for (const [index, role] of roles.entries()) {
      await this.contractsRepository.createApprovalStep({
        contractId: contract.id,
        stepOrder: index + 1,
        requiredRole: role.requiredRole,
        blocksRole: role.blocksRole,
        status: 'PENDING',
      });
    }
  }

  async requestChanges(
    contractId: string,
    note: string,
    actorId: string,
    user?: TCurrentUser | null,
  ): Promise<Contract> {
    const contract = await this.contractsService.findById(contractId);
    assertFreightPermission(
      user,
      forFreightType(FREIGHT_PERMS.contracts.requestChanges, contract.freightType),
    );
    assertContractStatus(contract, ['SUBMITTED']);

    await this.contractsRepository.createReviewNote(
      contractId,
      note,
      'CHANGES_REQUESTED',
      actorId,
      'STAFF',
    );
    await this.contractsRepository.update(contractId, {
      status: 'CHANGES_REQUESTED',
    } as never);
    const updated = await this.contractsService.findById(contractId);
    this.notifier.changesRequested(updated, note);
    return updated;
  }

  async reject(
    contractId: string,
    reason: string,
    actorId: string,
    user?: TCurrentUser | null,
  ): Promise<Contract> {
    const contract = await this.contractsService.findById(contractId);
    assertFreightPermission(
      user,
      forFreightType(FREIGHT_PERMS.contracts.reject, contract.freightType),
    );
    assertContractStatus(contract, ['SUBMITTED', 'PENDING_APPROVAL']);

    await this.contractsRepository.createReviewNote(
      contractId,
      reason,
      'REJECTION',
      actorId,
      'STAFF',
    );
    await this.contractsRepository.update(contractId, {
      status: 'REJECTED',
    } as never);
    const updated = await this.contractsService.findById(contractId);
    this.notifier.rejected(updated, reason);
    return updated;
  }

  /**
   * Reject one approval step (line staff / director / CEO). The rejecting
   * approver must supply a reason, and picks where the rejection lands:
   *
   * - **To the customer** (`returnToStepId` omitted — the only option for the
   *   first approver): terminal. The whole contract moves to REJECTED with a
   *   REJECTION review note visible to the customer, who must resubmit.
   * - **To an earlier approver** (`returnToStepId` = an already-APPROVED
   *   earlier step): internal send-back. That step and everything after it
   *   reset to PENDING and the chain re-runs from there; the contract stays
   *   PENDING_APPROVAL and the customer never sees it. E.g. the director can
   *   return a contract to line staff, who fix it and approve again, after
   *   which every later stage re-approves in order.
   */
  async rejectStep(
    contractId: string,
    stepId: string,
    actorId: string,
    reason: string,
    returnToStepId?: string,
  ): Promise<Contract> {
    const contract = await this.contractsService.findById(contractId);
    assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);

    const step = await this.contractsRepository.findApprovalStepById(contractId, stepId);
    if (!step) throw new BadRequestException('Approval step not found');

    // Only the approver whose turn it is may reject — same ordering rule as
    // approveStep. Without this, an already-actioned or future step could be
    // "rejected" and wipe chain state it never owned.
    const next = await this.contractsRepository.findNextPendingApprovalStep(contractId);
    if (!next || next.id !== step.id) {
      throw new BadRequestException(
        'Only the current pending approval step can be rejected',
      );
    }

    if (returnToStepId) {
      return this.sendBackToStep(contract, step, actorId, reason, returnToStepId);
    }

    await this.contractsRepository.completeApprovalStep(step.id, actorId, 'REJECTED', reason);

    await this.contractsRepository.createReviewNote(
      contractId,
      reason,
      'REJECTION',
      actorId,
      'STAFF',
    );

    await this.contractsRepository.update(contractId, {
      status: 'REJECTED',
    } as never);
    const updated = await this.contractsService.findById(contractId);
    this.notifier.rejected(updated, reason);
    return updated;
  }

  /**
   * Internal send-back branch of rejectStep: return the contract to an earlier,
   * already-approved stage of the chain instead of rejecting it outright.
   * Deliberately NOT the terminal path: the contract is still alive and there
   * is no customer-facing REJECTION note — the trail is a staff note plus a
   * backoffice inbox ping.
   */
  private async sendBackToStep(
    contract: Contract,
    rejectingStep: ContractApprovalStep,
    actorId: string,
    reason: string,
    returnToStepId: string,
  ): Promise<Contract> {
    const target = await this.contractsRepository.findApprovalStepById(
      contract.id,
      returnToStepId,
    );
    if (!target) throw new BadRequestException('Return-to approval step not found');
    if (target.stepOrder >= rejectingStep.stepOrder) {
      throw new BadRequestException(
        'A rejection can only be returned to an EARLIER step in the chain — to reject to the customer, omit returnToStepId',
      );
    }
    if (target.status !== 'APPROVED') {
      throw new BadRequestException(
        `Return-to step ${target.requiredRole} has not approved yet (status ${target.status})`,
      );
    }

    // Staff-visible trail. Written before the reset so the reason survives the
    // wipe of per-step notes.
    await this.contractsRepository.createReviewNote(
      contract.id,
      `Returned to ${target.requiredRole} (step ${target.stepOrder}) by ${rejectingStep.requiredRole}: ${reason}`,
      'STAFF_NOTE',
      actorId,
      'STAFF',
    );

    // Chain re-runs from the target stage: it and every later step (including
    // the rejecting one) go back to PENDING. Legacy approved-by columns are
    // left stale on purpose — approval steps are the source of truth and the
    // columns get re-stamped on re-approval.
    await this.contractsRepository.resetApprovalStepsFrom(
      contract.id,
      target.stepOrder,
    );

    // A send-back can only happen mid-chain, so the contract must remain (or
    // return to) PENDING_APPROVAL — relevant when rejecting from
    // APPROVED_PENDING_SIGNATURE.
    await this.contractsRepository.update(contract.id, {
      status: 'PENDING_APPROVAL',
    } as never);

    const updated = await this.contractsService.findById(contract.id);
    this.notifier.sentBackToStep(updated, target.requiredRole, reason);
    return updated;
  }

  /** Approve one approval step in sequence; → APPROVED when all complete. */
  async approveStep(
    contractId: string,
    stepId: string,
    actorId: string,
    authUser?: TCurrentUser,
  ): Promise<Contract> {
    const contract = await this.contractsService.findById(contractId);
    assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);

    const step = await this.contractsRepository.findApprovalStepById(contractId, stepId);
    if (!step || step.status !== 'PENDING') {
      throw new BadRequestException('Approval step not found or already actioned');
    }

    const next = await this.contractsRepository.findNextPendingApprovalStep(contractId);
    if (!next || next.id !== step.id) {
      throw new BadRequestException('Approval steps must be completed in order');
    }

    // The role is the step's own — never the caller's claim about themselves.
    const requiredRole = step.requiredRole;
    if (authUser) {
      assertCanApproveContractStep(authUser, requiredRole);
    }

    await this.contractsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');

    // Record who acted on this step, but DO NOT advance the contract status here —
    // approving one step must not finalize the chain while later steps are still
    // pending. Status only moves to APPROVED once every step in the chain is
    // complete; until then the contract stays in PENDING_APPROVAL so the next
    // required approver can act.
    //
    // `contract_approval_steps` is the source of truth for who approved what — a
    // chain is an arbitrary sequence of position types and cannot be represented
    // by fixed columns. The legacy columns below are still stamped, best-effort,
    // for the three roles that map onto them so older readers keep working.
    const updates: Record<string, unknown> = {};
    const now = new Date();
    if (LEGACY_STAFF_ROLES.has(requiredRole)) {
      updates.approvedByStaffId = actorId;
      updates.approvedByStaffAt = now;
    } else if (LEGACY_DIRECTOR_ROLES.has(requiredRole)) {
      updates.signedByDirectorId = actorId;
      updates.signedByDirectorAt = now;
    } else if (LEGACY_CEO_ROLES.has(requiredRole)) {
      updates.signedByCeoId = actorId;
      updates.signedByCeoAt = now;
    }

    const allDone = await this.contractsRepository.allApprovalStepsComplete(contractId);
    updates.status = allDone ? 'APPROVED' : 'PENDING_APPROVAL';

    if (Object.keys(updates).length > 0) {
      await this.contractsRepository.update(contractId, updates as never);
    }
    const updated = await this.contractsService.findById(contractId);
    if (allDone) {
      this.notifier.approved(updated);
      // Final approval is what produces the contract PDF — until now there was
      // only a live preview. The approval steps are already committed, so a
      // render failure must not roll them back; surface it instead of swallowing
      // it, since an APPROVED contract with no document needs operator action.
      try {
        return await this.finalizeApprovedContract(contractId);
      } catch (err) {
        this.logger.error(
          `Contract PDF generation failed after final approval for ${updated.reference}: ${err}`,
        );
        throw new ServiceUnavailableException(
          'All approvals were recorded, but generating the contract PDF failed. ' +
            'Retry generation from the contract page.',
        );
      }
    }
    return updated;
  }

  /**
   * Retry path for a contract that finished approval but whose PDF failed to
   * render (Chromium unavailable, etc.). The normal flow generates the document
   * automatically on the final approval — there is no manual generate step
   * before that, only the live preview.
   */
  async generateContract(contractId: string): Promise<Contract> {
    const contract = await this.contractsService.findById(contractId);
    assertContractStatus(contract, ['APPROVED', 'APPROVED_PENDING_SIGNATURE']);
    await this.renderContractDocument(contract);
    await this.contractsRepository.update(contractId, {
      status: 'CONTRACT_READY',
    } as never);
    return this.contractsService.findById(contractId);
  }

  /**
   * Render the contract PDF from the Contract aggregate (snapshot-driven), store
   * it via FilesService, and stamp the template key + generated timestamp. Never
   * changes status. Rendering is best-effort — a Chromium hiccup defers the file
   * (it re-renders on view/download) but the timestamp is still stamped.
   */
  private async renderContractDocument(
    contract: Contract,
    options: { strict?: boolean } = {},
  ): Promise<void> {
    const { view } = await this.documentViewModelBuilder.build(contract.id);
    try {
      await this.upsertContractPdf(contract.id, contract.reference, view);
    } catch (err) {
      // Strict callers (final approval) need to know the PDF is missing — it is
      // the artifact of the completed chain, not a cache that can refill later.
      if (options.strict) throw err;
      this.logger.warn(
        `Contract PDF deferred for ${contract.reference}: ${err}. It will render on view/download once Chromium is available.`,
      );
    }
    await this.contractsRepository.update(contract.id, {
      contractTemplateKey: view.templateKey,
      contractGeneratedAt: new Date(),
    } as never);
  }

  /**
   * Every approval step landed → generate the contract PDF, then CONTRACT_READY.
   * This is the only point at which the document is produced: approvers review a
   * live preview, and the final approval is what turns it into a PDF. Renders
   * unconditionally so the file reflects every edit made during the chain.
   */
  private async finalizeApprovedContract(contractId: string): Promise<Contract> {
    const contract = await this.contractsService.findById(contractId);
    await this.renderContractDocument(contract, { strict: true });
    await this.contractsRepository.update(contractId, {
      status: 'CONTRACT_READY',
    } as never);
    return this.contractsService.findById(contractId);
  }

  /**
   * Build the contract PDF view-model and rendered HTML for portal/backoffice
   * signing. Sourced entirely from the Contract aggregate (unit-rate schedule, no
   * totals). Returns the view-model, the rendered HTML and the signature rows.
   */
  async getContractDocumentView(contractId: string): Promise<{
    view: ContractViewModel;
    html: string;
    signatures: ContractViewModel['signatures'];
  }> {
    const { view } = await this.documentViewModelBuilder.build(contractId);
    await this.inlineSignatureImages(view.signatures);
    const html = this.renderer.render(view);
    return { view, html, signatures: view.signatures };
  }

  /** Lazy-generate (or refresh) the stored contract PDF and stream it for download. */
  async streamContractPdf(contractId: string) {
    const contract = await this.contractsService.findById(contractId);
    const { view } = await this.documentViewModelBuilder.build(contractId);
    const record = await this.upsertContractPdf(contractId, contract.reference, view);
    return this.filesService.streamById(record.id);
  }

  /**
   * Rebuild the stored `contract` PDF from the current aggregate (now including
   * the latest signatures) so the downloaded/viewed file matches the live HTML
   * view. Best-effort — a Chromium hiccup must never fail the signing
   * transaction; the doc still re-renders live on /contract/view.
   */
  private async regenerateContractPdf(
    contractId: string,
    reference: string,
  ): Promise<void> {
    try {
      const { view } = await this.documentViewModelBuilder.build(contractId);
      await this.upsertContractPdf(contractId, reference, view);
    } catch (err) {
      this.logger.warn(
        `Signed contract PDF regen deferred for ${reference}: ${err}. It re-renders live on view.`,
      );
    }
  }

  /** Render the contract PDF and upsert it as the `contract` file on the contract. */
  private async upsertContractPdf(
    contractId: string,
    reference: string,
    view: ContractViewModel,
  ): Promise<FileRecord> {
    await this.inlineSignatureImages(view.signatures);
    const html = this.renderer.render(view);
    const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html);
    const file: Express.Multer.File = {
      fieldname: 'contract',
      originalname: `contract-${reference}.pdf`,
      encoding: '7bit',
      mimetype: 'application/pdf',
      size: pdfBuffer.length,
      buffer: pdfBuffer,
      stream: Readable.from(pdfBuffer),
      destination: '',
      filename: '',
      path: '',
    };

    return this.filesService.upsertByCode({
      resourceId: contractId,
      resource: 'contracts',
      code: 'contract',
      file,
    });
  }

  /**
   * Replace MinIO signature/stamp URLs with inline data URIs so they render in
   * the PDF — Chromium cannot fetch the private bucket.
   */
  private async inlineSignatureImages(
    signatures: Array<{
      signatureImageUrl?: string | null;
      stampImageUrl?: string | null;
    }>,
  ): Promise<void> {
    for (const sig of signatures) {
      sig.signatureImageUrl = await this.inlineImageUrl(sig.signatureImageUrl);
      sig.stampImageUrl = await this.inlineImageUrl(sig.stampImageUrl);
    }
  }

  /** MinIO URL → data URI. Returns the input unchanged if absent or on failure. */
  private async inlineImageUrl(
    url?: string | null,
  ): Promise<string | null | undefined> {
    if (!url || url.startsWith('data:')) return url;
    try {
      const objectName = this.minioService.getObjectNameFromUrl(url);
      const stream = await this.minioService.getFileStream(objectName);
      const buffer = await this.streamToBuffer(stream);
      const extension = objectName.split('.').pop()?.toLowerCase();
      const mime =
        extension === 'jpg' || extension === 'jpeg'
          ? 'image/jpeg'
          : extension === 'webp'
            ? 'image/webp'
            : 'image/png';
      return `data:${mime};base64,${buffer.toString('base64')}`;
    } catch {
      return url;
    }
  }

  private streamToBuffer(stream: Readable): Promise<Buffer> {
    return new Promise((resolve, reject) => {
      const chunks: Buffer[] = [];
      stream.on('data', (chunk: Buffer | string) => {
        chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
      });
      stream.on('error', reject);
      stream.on('end', () => resolve(Buffer.concat(chunks)));
    });
  }

  /**
   * base64 (data URL or raw) → image FileRecord stored on the contract under
   * `code`. Drawn signatures are always PNG; an uploaded stamp may be JPEG or
   * WebP, so the type is read off the data-URL prefix rather than assumed —
   * the stored extension is what {@link inlineImageUrl} reads it back as.
   */
  private async uploadSignatureAsset(
    contract: Contract,
    code: string,
    imageBase64: string,
  ): Promise<FileRecord> {
    const mimetype =
      /^data:(image\/[a-z+]+);base64,/i.exec(imageBase64)?.[1]?.toLowerCase() ??
      'image/png';
    const extension = mimetype === 'image/jpeg' ? 'jpg' : mimetype.split('/')[1];
    const raw = imageBase64.includes(',')
      ? imageBase64.split(',')[1]!
      : imageBase64;
    const buffer = Buffer.from(raw, 'base64');
    const file: Express.Multer.File = {
      fieldname: code,
      originalname: `${code.replace(/_/g, '-')}-${contract.reference}.${extension}`,
      encoding: '7bit',
      mimetype,
      size: buffer.length,
      buffer,
      stream: Readable.from(buffer),
      destination: '',
      filename: '',
      path: '',
    };

    return this.filesService.upsertByCode({
      resourceId: contract.id,
      resource: 'contracts',
      code,
      file,
    });
  }

  /** Apply a digital signature row (mirrors booking-contract.service). */
  private async applySignature(
    contract: Contract,
    dto: SignContractDto,
    options: { signerUserId?: string },
  ): Promise<void> {
    const role = dto.role as ContractSignerRole;

    // Resolve the signature image. The client may send a freshly-drawn image, or
    // omit it to reuse the signer's saved profile signature. Fall back to the
    // saved one whenever no image is supplied.
    let imageBase64 = dto.signatureImageBase64;
    let signerDisplayName = dto.signerDisplayName;
    if (!imageBase64 && options.signerUserId) {
      const saved = await this.signaturesService.getForUser(options.signerUserId);
      if (saved?.signatureImageUrl) {
        imageBase64 = saved.signatureImageUrl;
        signerDisplayName = signerDisplayName || saved.signerDisplayName;
      }
    }
    if (!imageBase64) {
      throw new BadRequestException(
        'No signature provided and no saved signature found on the profile.',
      );
    }

    // The company stamp is a separate image from the drawn signature. Both
    // parties to the contract (client + EDR) must seal it; DIRECTOR/CEO rows
    // are internal approval signatures, not party seals, so they stay exempt.
    const stampRequired = role === 'CUSTOMER' || role === 'STAFF';
    if (stampRequired && !dto.stampImageBase64) {
      throw new BadRequestException(
        'A company stamp is required to sign this contract.',
      );
    }

    const fileRecord = await this.uploadSignatureAsset(
      contract,
      `signature_${role.toLowerCase()}`,
      imageBase64,
    );
    const stampRecord = dto.stampImageBase64
      ? await this.uploadSignatureAsset(
          contract,
          `stamp_${role.toLowerCase()}`,
          dto.stampImageBase64,
        )
      : null;

    await this.contractsRepository.saveSignature({
      contractId: contract.id,
      role,
      signerDisplayName,
      signedAt: new Date(),
      signatureFileId: fileRecord.id,
      stampFileId: stampRecord?.id ?? null,
      consentText: dto.consentText ?? null,
    });

    // Only (re)save the reusable profile signature when the signer drew a NEW
    // image. Reusing the saved signature must not rewrite it with itself.
    if (options.signerUserId && dto.signatureImageBase64) {
      try {
        await this.signaturesService.upsertForUser({
          userId: options.signerUserId,
          signerDisplayName,
          signatureImageBase64: dto.signatureImageBase64,
        });
      } catch (err) {
        this.logger.warn(
          `Could not save reusable signature for user ${options.signerUserId}: ${err}`,
        );
      }
    }
  }

  /**
   * Send the sudo-mode signing OTP to the SIGNER's own registered phone and
   * email — the same contacts {@link sign} verifies against. The client never
   * picks them (that is the H12(b) trust property): it only asks us to send, and
   * we resolve them from the authenticated user id. Returns a masked hint so the
   * UI can say where the code went without exposing the full values.
   */
  async sendSigningOtp(
    contractId: string,
    options: { signerUserId?: string },
  ): Promise<{ sentTo: string }> {
    const contract = await this.contractsService.findById(contractId);
    // Same ownership gate as signing — only the owning company's customer may
    // trigger a code for this contract.
    await this.contractsService.assertCustomerCanAccessContract(
      options.signerUserId,
      contract,
    );
    // SIGNED_CUSTOMER is allowed only for the re-sign-to-add-a-stamp case that
    // {@link sign} permits — otherwise the code would be useless on arrival.
    const existing = await this.contractsRepository.findSignature(
      contractId,
      'CUSTOMER',
    );
    const addingMissingStamp = Boolean(existing) && !existing?.stampFileId;
    assertContractStatus(
      contract,
      addingMissingStamp ? ['CONTRACT_READY', 'SIGNED_CUSTOMER'] : ['CONTRACT_READY'],
    );

    const signerContacts = await this.resolveSignerContacts(options.signerUserId);
    await this.otpService.sendOtp(signerContacts);
    return { sentTo: maskSignerContacts(signerContacts) };
  }

  /** Customer signs the ready contract → SIGNED_CUSTOMER. */
  async sign(
    contractId: string,
    dto: SignContractDto,
    options: { signerUserId?: string },
  ): Promise<Contract> {
    const contract = await this.contractsService.findById(contractId);

    if (dto.role === 'CUSTOMER') {
      // H12(a): only the owning company's customer may sign — assert ownership
      // before anything else (hidden as NotFound otherwise). A signing customer
      // has no permission key, so this is the gate that binds the sign to the
      // contract's company.
      await this.contractsService.assertCustomerCanAccessContract(
        options.signerUserId,
        contract,
      );
      const existing = await this.contractsRepository.findSignature(contractId, 'CUSTOMER');
      // Signing is one-shot, with one exception: a contract signed before the
      // company stamp was required has to be sealed before EDR can counter-sign
      // it, so the customer may sign again purely to attach the missing stamp.
      const addingMissingStamp = Boolean(existing) && !existing?.stampFileId;
      assertContractStatus(
        contract,
        addingMissingStamp ? ['CONTRACT_READY', 'SIGNED_CUSTOMER'] : ['CONTRACT_READY'],
      );
      if (existing && !addingMissingStamp) {
        throw new BadRequestException('Customer has already signed this contract');
      }
      // Sudo-mode gate: a fresh, single-use OTP must be verified before the
      // signature is applied. H12(b): verify against the SIGNER's own registered
      // contacts, resolved server-side from the authenticated user id — never
      // caller-supplied ones, which an attacker could point at their own phone
      // or mailbox. Ownership is already asserted above, so this proves the
      // specific person holding the account is present, not merely that someone
      // reached a shared company line. Must resolve identically to
      // sendSigningOtp, or send and verify would target different contacts.
      const signerContacts = await this.resolveSignerContacts(options.signerUserId);
      if (!dto.otp) {
        throw new BadRequestException('OTP verification is required to sign the contract');
      }
      await this.otpService.verifyOtpForAction(signerContacts, dto.otp);
      await this.applySignature(contract, dto, options);
      await this.contractsRepository.update(contractId, {
        status: 'SIGNED_CUSTOMER',
        customerSignedAt: new Date(),
      } as never);
      await this.regenerateContractPdf(contractId, contract.reference);
      const updated = await this.contractsService.findById(contractId);
      this.notifier.customerSignedToStaff(updated);
      return updated;
    }

    return this.counterSign(contractId, dto, options);
  }

  /**
   * Staff/Director/CEO counter-sign → branch on the execution path. A customs
   * border (IMPORT/EXPORT) always requires a clearance gate before any shipment;
   * who reviews differs:
   *  - Path B (customs bundled): customer uploads GL-input docs, GL reviews, GL
   *    uploads output, then GL creates the booking.
   *  - Path A (no customs): the customer clears the cargo himself and uploads his
   *    own clearance proof; Operations reviews it; then the CUSTOMER books.
   * DOMESTIC/intercity has no border, so it goes straight to executed.
   */
  async counterSign(
    contractId: string,
    dto: SignContractDto,
    options: { signerUserId?: string },
  ): Promise<Contract> {
    const contract = await this.contractsService.findById(contractId);
    assertContractStatus(contract, ['SIGNED_CUSTOMER']);

    // Both parties' stamps must be on file before the contract executes. The
    // EDR stamp is enforced by applySignature below; the customer's is checked
    // here so a contract signed before stamps existed can't slip through.
    const customerSignature = await this.contractsRepository.findSignature(
      contractId,
      'CUSTOMER',
    );
    if (!customerSignature?.stampFileId) {
      throw new BadRequestException(
        'The customer stamp is missing on this contract — it cannot be counter-signed until the customer signs again with their company stamp.',
      );
    }

    await this.applySignature(contract, dto, options);

    const now = new Date();
    const updates: Record<string, unknown> = {
      fullyExecutedAt: now,
      lockedAt: now,
    };

    // A clearance gate applies whenever a clearance doc set resolves — Path B
    // (customs), Path A self-clearance (IMPORT/EXPORT without customs), or the
    // intercity document set (DOMESTIC, ops-reviewed like Path A).
    const clearanceCode = contractClearanceSettingCode(
      contract.tradeDirection,
      contract.freightType,
      contract.customsClearingEnabled ?? false,
    );

    // GENERAL contracts run clearance PER BOOKING, not at the contract level —
    // both paths. Customs (Path B): the customer files shipment requests, GL
    // books each one and the booking carries its own clearance. Self-clearance
    // (Path A): the customer books, then uploads the clearance docs on that
    // booking for Operations to review. Only ONE_TIME contracts keep the
    // contract-level cycle below.
    const isGeneral = contract.contractKind === 'GENERAL';

    if (clearanceCode && !isGeneral) {
      // Open a clearance cycle, seed the pre-booking milestones, and route the
      // customer to upload. Path A is ops-reviewed; Path B is GL-reviewed — the
      // distinction is enforced at the review/finalize endpoints, not here.
      const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1;
      const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber);
      await this.milestoneService.seedPreBookingMilestones(contract, cycle.id);
      // No prepay gate: the customs clearance service fee (Path B) is billed on
      // the booking invoice together with the freight, so the document step
      // opens immediately.
      updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
      updates.clearanceStatus = 'AWAITING_DOCUMENTS';
      updates.clearanceCycleNumber = cycleNumber;
    } else {
      // No contract-level clearance gate — DOMESTIC, or any GENERAL contract
      // (which clears per booking). Ready for shipment requests / direct booking.
      updates.status =
        contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
      updates.clearanceStatus = 'NOT_APPLICABLE';
    }

    await this.contractsRepository.update(contractId, updates as never);
    await this.regenerateContractPdf(contractId, contract.reference);
    const updated = await this.contractsService.findById(contractId);
    this.notifier.signedActive(updated);
    return updated;
  }

  /** Customer requests renewal → RENEWAL_DRAFT linked via renewalOfId. */
  async renew(contractId: string, userId?: string): Promise<Contract> {
    const source = await this.contractsService.findById(contractId);

    // Retry past a concurrent insert that grabbed the same CTR sequence number.
    const renewal = await insertWithGeneratedReference(
      () => this.generateRenewalReference(),
      (reference) =>
        this.contractsRepository.create({
      reference,
      companyId: source.companyId,
      companyProfileId: source.companyProfileId,
      isGovernment: source.isGovernment,
      governmentInstitution: source.governmentInstitution,
      contractKind: source.contractKind,
      renewalOfId: source.id,
      tradeDirection: source.tradeDirection,
      freightType: source.freightType,
      serviceTypeId: source.serviceTypeId,
      paymentCurrency: source.paymentCurrency,
      customsClearingEnabled: source.customsClearingEnabled,
      customsClearingAgent: source.customsClearingAgent,
      equipmentReturn: source.equipmentReturn,
      firstMilePickupAddress: source.firstMilePickupAddress,
      firstMilePickupLat: source.firstMilePickupLat,
      firstMilePickupLng: source.firstMilePickupLng,
      lastMileDeliveryAddress: source.lastMileDeliveryAddress,
      lastMileDeliveryLat: source.lastMileDeliveryLat,
      lastMileDeliveryLng: source.lastMileDeliveryLng,
      isHazardous: source.isHazardous,
      isReefer: source.isReefer,
      contractType: source.contractType,
      versionNumber: (source.versionNumber ?? 1) + 1,
      status: 'RENEWAL_DRAFT',
      clearanceStatus: 'NOT_APPLICABLE',
      clearanceCycleNumber: 0,
        } as never),
    );

    void userId;
    return this.contractsService.findById(renewal.id);
  }

  private async generateRenewalReference(): Promise<string> {
    const year = new Date().getFullYear();
    const seq = await this.contractsRepository.maxReferenceSequence(year);
    return `CTR-${year}-${String(seq + 1).padStart(5, '0')}`;
  }
}
