import {
  Injectable,
  NotFoundException,
  ConflictException,
  BadRequestException,
  ForbiddenException,
} from "@nestjs/common";
import { DataSource, EntityManager } from "typeorm";
import { CompaniesRepository } from "./companies.repository";
import { CompanyProfileRepository } from "./company-profile.repository";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
import { ExternalProfileRepository } from "./external-profile.repository";
import {
  CompanyDashboardRepository,
  DashboardScope,
} from "./company-dashboard.repository";
import { FilesService } from "../files/files.service";
import { FileRecord } from "../files/entities/file.entity";
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
import { ETradeService } from "./services/etrade.service";
import { CompanyNotifierService } from "./company-notifier.service";
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
import { CreateCompanyDto } from "./dto/create-company.dto";
import { UpdateCompanyDto } from "./dto/update-company.dto";
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
import { UpdateProfileDto } from "./dto/update-profile.dto";
import { ProfileResponseDto } from "./dto/profile-response.dto";
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
import { ListCompaniesQueryDto } from "./dto/list-companies-query.dto";
import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto";
import {
  Company,
  CompanyNationality,
  CompanyStatus,
  CompanyType,
} from "./entities/company.entity";
import { ExternalProfile } from "./entities/external-profile.entity";
import {
  BusinessLicenseFile,
  CompanyDocumentFileView,
  CompanyProfile,
  ProfileLicenseFileView,
  ProfileType,
  ProfileStatus,
} from "./entities/company-profile.entity";
import {
  ChangeRequestStatus,
  CompanyChangeRequest,
  DocumentChangeIntent,
  LicenseChangeIntent,
} from "./entities/company-change-request.entity";

/** FileRecord `resource` + `code` slots for business-license documents. */
const LICENSE_RESOURCE = "company_profiles";
const LICENSE_CODE = "business_license";
/** Code for a license file staged in an open change request (not yet live). */
const LICENSE_PENDING_CODE = "business_license_pending";

/** Mirrors the field seeded in seed/file-upload-settings.seeder.ts. */
const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
/** Code for a PoA letter staged in an open change request (not yet live). */
const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending";
/** FileRecord resource that company-level documents are stored under. */
const COMPANY_RESOURCE = "companies";
/** company.attributes keys that together mean "a PoA was entered". */
const POA_ATTRIBUTES = [
  "poaName",
  "poaPhone",
  "poaEmail",
  "poaLocation",
  "poaAddress",
] as const;
/** Mandatory once the company operates as a freight forwarder. */
const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [
  { key: "poaName", label: "PoA name" },
  { key: "poaEmail", label: "PoA email" },
  { key: "poaPhone", label: "PoA phone" },
];

export interface UserIdentity {
  userId: string;
  firstName: string;
  lastName: string;
  email: string;
  phone: string;
}

@Injectable()
export class CompaniesService {
  constructor(
    private readonly companiesRepo: CompaniesRepository,
    private readonly companyProfilesRepo: CompanyProfileRepository,
    private readonly changeRequestRepo: CompanyChangeRequestRepository,
    private readonly profilesRepo: ExternalProfileRepository,
    private readonly dashboardRepo: CompanyDashboardRepository,
    private readonly filesService: FilesService,
    private readonly fileUploadSettingsService: FileUploadSettingsService,
    private readonly etradeService: ETradeService,
    private readonly companyNotifier: CompanyNotifierService,
    private readonly dataSource: DataSource,
  ) { }

  /**
   * Required company-information fields that must be filled before onboarding can
   * be submitted. The backend owns this list so the portal never has to know
   * which fields are mandatory — it just renders what's reported outstanding.
   * `get` reads the value from the company (some live in the attributes blob).
   */
  private readonly REQUIRED_COMPANY_INFO: {
    key: string;
    label: string;
    get: (company: Company) => unknown;
  }[] = [
    {
      key: "tinNumber",
      label: "Company TIN",
      get: (c) => (c.tin && !c.tin.startsWith("D") ? c.tin : null),
    },
    { key: "companyEmail", label: "Company email", get: (c) => c.email },
    { key: "companyPhone", label: "Company phone", get: (c) => c.phone },
    { key: "companyAddress", label: "Company address", get: (c) => c.address },
    { key: "fanNumber", label: "FAN number", get: (c) => c.fanNumber },
    {
      key: "contactPersonName",
      label: "Contact person name",
      get: (c) => c.attributes?.contactPersonName,
    },
    {
      key: "contactPersonPhone",
      label: "Contact person phone",
      get: (c) => c.attributes?.contactPersonPhone,
    },
    {
      key: "generalManagerName",
      label: "General manager name",
      get: (c) => c.attributes?.generalManagerName,
    },
    {
      key: "generalManagerEmail",
      label: "General manager email",
      get: (c) => c.attributes?.generalManagerEmail,
    },
    {
      key: "generalManagerPhone",
      label: "General manager phone",
      get: (c) => c.attributes?.generalManagerPhone,
    },
  ];

  /** The nationality-based document setting code for a company. */
  private documentSettingCodeFor(
    nationality: CompanyNationality | null | undefined,
  ): string {
    return nationality === CompanyNationality.Foreign
      ? "company_onboarding_documents_foreign"
      : "company_onboarding_documents_ethiopian";
  }

  async createCompany(dto: CreateCompanyDto): Promise<Company> {
    const exists = await this.companiesRepo.existsByTin(dto.tin);
    if (exists) {
      throw new ConflictException(`Company with TIN ${dto.tin} already exists`);
    }
    return this.companiesRepo.create(dto);
  }

  async createCompanyWithProfile(
    identity: UserIdentity,
    dto: CreateCompanyWithProfileDto,
  ): Promise<{ company: Company; profile: ExternalProfile }> {
    if (dto.tin) {
      const exists = await this.companiesRepo.existsByTin(dto.tin);
      if (exists) {
        throw new ConflictException(
          `Company with TIN ${dto.tin} already exists`,
        );
      }
    }

    const existingProfile = await this.profilesRepo.findByUserId(
      identity.userId,
    );
    if (existingProfile) {
      throw new ConflictException(
        `Profile for user ${identity.userId} already exists`,
      );
    }

    const company = await this.companiesRepo.create({
      name: dto.companyName,
      type: dto.companyType,
      tin: dto.tin ?? "",
      vatNumber: dto.vatNumber ?? null,
      fanNumber: dto.fanNumber ?? null,
      country: dto.companyLocation ?? "Ethiopia",
      address: dto.companyAddress ?? null,
      phone: normalizeE164(dto.companyPhone) ?? null,
      email: dto.companyEmail ?? null,
      attributes: dto.attributes ?? null,
    });

    const profile = await this.profilesRepo.create({
      userId: identity.userId,
      companyId: company.id,
      firstName: identity.firstName,
      lastName: identity.lastName,
      jobTitle: dto.jobTitle ?? null,
      isPrimaryContact: dto.isPrimaryContact ?? true,
      onboardingStep: "company",
    });

    // Persist the operational role(s) chosen during onboarding. Types are
    // already constrained to the company type on the client; any that don't
    // match are skipped defensively rather than failing the whole signup.
    if (dto.companyProfiles?.length) {
      const allowedTypes = this.getProfileTypeForCompanyType(company.type);
      for (const input of dto.companyProfiles) {
        if (!allowedTypes.includes(input.type)) continue;
        const existing = await this.companyProfilesRepo.findByType(
          company.id,
          input.type,
        );
        if (existing) continue;
        // No reference yet — these profiles await backoffice approval, which
        // is when the reference is minted (see setCompanyProfileStatus).
        await this.companyProfilesRepo.create({
          companyId: company.id,
          type: input.type,
          businessLicense: input.businessLicense ?? null,
          status: ProfileStatus.Pending,
        });
      }
      company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(
        company.id,
      );
    }

    return { company, profile };
  }

  async listCompanies(
    query: ListCompaniesQueryDto,
  ): Promise<{ items: Company[]; total: number }> {
    return this.companiesRepo.findPaginated(query);
  }

  async getCompanyStats(): Promise<CompanyStatsResponseDto> {
    return this.companiesRepo.getStats();
  }

  /**
   * Begin onboarding: create a DRAFT company + the user's external profile + the
   * chosen operational role(s) up front, so every subsequent wizard step can
   * save incrementally (PATCH /profile, /onboarding-step) against existing rows.
   *
   * Idempotent: if the user already has a profile, returns it unchanged (only
   * adding any newly-chosen roles). The draft company carries a placeholder TIN
   * (the real one is filled on the Company Information step) and stays
   * status=pending / onboardingCompleted=false until the wizard finishes.
   */
  async startOnboarding(
    identity: UserIdentity,
    companyType: CompanyType,
    roles: ProfileType[],
    nationality?: CompanyNationality,
  ): Promise<{ profile: ExternalProfile; company: Company }> {
    // Already started — reuse the existing draft, just ensure roles exist and
    // keep the nationality up to date if it was (re)selected.
    const existing = await this.profilesRepo.findByUserId(identity.userId);
    if (existing) {
      const companyId = existing.company?.id ?? existing.companyId;
      await this.ensureCompanyProfiles(companyId, companyType, roles);
      if (nationality) {
        await this.companiesRepo.update(companyId, { nationality });
      }
      return this.getCompanyInfoByUserId(identity.userId);
    }

    const allowedTypes = this.getProfileTypeForCompanyType(companyType);
    const chosenTypes = roles.filter((t) => allowedTypes.includes(t));

    const company = await this.companiesRepo.create({
      name: identity.firstName
        ? `${identity.firstName}'s company`
        : "New company",
      type: companyType,
      tin: await this.generateDraftTin(),
      country: "Ethiopia",
      nationality: nationality ?? CompanyNationality.Ethiopian,
      status: CompanyStatus.Pending,
    });

    await this.profilesRepo.create({
      userId: identity.userId,
      companyId: company.id,
      firstName: identity.firstName,
      lastName: identity.lastName,
      isPrimaryContact: true,
      onboardingStep: "company",
      onboardingCompleted: false,
    });

    await this.ensureCompanyProfiles(company.id, companyType, chosenTypes);

    return this.getCompanyInfoByUserId(identity.userId);
  }

  /** Create any of the requested operational profiles that don't exist yet. */
  private async ensureCompanyProfiles(
    companyId: string,
    companyType: CompanyType,
    roles: ProfileType[],
  ): Promise<void> {
    const allowedTypes = this.getProfileTypeForCompanyType(companyType);
    for (const type of roles) {
      if (!allowedTypes.includes(type)) continue;
      const existing = await this.companyProfilesRepo.findByType(
        companyId,
        type,
      );
      if (existing) continue;
      // No reference yet — minted on backoffice approval (setCompanyProfileStatus).
      await this.companyProfilesRepo.create({
        companyId,
        type,
        status: ProfileStatus.Pending,
      });
    }
  }

  /**
   * A unique 10-char placeholder TIN for a draft company (the column is
   * NOT NULL + unique). Overwritten with the real TIN on the company step.
   */
  private async generateDraftTin(): Promise<string> {
    for (let i = 0; i < 10; i++) {
      const candidate =
        "D" +
        Math.floor(Math.random() * 1_000_000_000)
          .toString()
          .padStart(9, "0");
      if (!(await this.companiesRepo.existsByTin(candidate))) return candidate;
    }
    // Extremely unlikely; fall back to a timestamp-derived value.
    return ("D" + Date.now().toString()).slice(0, 10);
  }

  async findAllCompanies(): Promise<Company[]> {
    return this.companiesRepo.findAll({ order: { name: "ASC" } });
  }

  async findCompanyById(id: string): Promise<Company> {
    const company = await this.companiesRepo.findById(id);
    if (!company) throw new NotFoundException(`Company ${id} not found`);
    company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id);
    // External profiles carry the onboarding flag the backoffice gates
    // approval decisions on (see ResponseCompanyDto.onboardingCompleted).
    company.profiles = await this.profilesRepo.findByCompanyId(id);
    return company;
  }

  /**
   * Validate an explicitly-chosen company profile for a booking: it must belong
   * to the booking's company and be Active. Used for government bookings (staff
   * pick the profile) and any staff booking that pins a profile directly.
   */
  async getActiveCompanyProfileForBooking(
    companyId: string,
    profileId: string,
  ): Promise<CompanyProfile> {
    const profile = await this.companyProfilesRepo.findById(profileId);
    if (!profile || profile.companyId !== companyId) {
      throw new BadRequestException(
        "Selected company profile does not belong to the chosen company",
      );
    }
    if (profile.status !== ProfileStatus.Active) {
      throw new BadRequestException(
        "Selected company profile is not active",
      );
    }
    return profile;
  }

  async getCompanyInfoByUserId(
    userId: string,
  ): Promise<{ profile: ExternalProfile; company: Company }> {
    const profile = await this.profilesRepo.findByUserId(userId);
    if (!profile)
      throw new NotFoundException(`Profile for user ${userId} not found`);

    const company = profile.company;
    if (!company)
      throw new NotFoundException(
        `Company for profile ${profile.id} not found`,
      );

    company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(
      company.id,
    );

    return { profile, company };
  }

  /**
   * Dashboard KPIs for the portal home (MyPortalPage), aggregated from the
   * current user's company bookings. All figures are scoped to that company.
   *
   * Note: delivered/spend/volume all derive from the bookings table — there is
   * no separate data source for them. On-time delivery rate is replaced by
   * completion rate (delivered ÷ committed): the schema has no ETA /
   * promised-delivery date, so on-time cannot be computed.
   *
   * Period attribution uses booking.created_at: there is no delivery-date
   * column, so "delivered YTD" counts bookings created this year that reached a
   * delivered/completed status.
   */
  async getDashboardSummary(
    userId: string,
    companyProfileId?: string,
  ): Promise<DashboardSummaryResponseDto> {
    // A user without a company profile has no bookings — return an empty summary
    // rather than 404, so the portal home still renders.
    const profile = await this.profilesRepo.findByUserId(userId);
    const companyId = profile?.company?.id ?? profile?.companyId ?? null;
    if (!companyId) return this.emptyDashboardSummary();

    // Company-wide by default (all services' data). An optional companyProfileId
    // (from the per-page service filter) narrows to one operational profile —
    // but only after we confirm it belongs to this user's company, since the
    // dashboard scope has no company guard at the repository layer.
    let scope: DashboardScope = { companyId };
    if (companyProfileId) {
      const owned = await this.companyProfilesRepo.findByCompanyId(companyId);
      if (owned.some((p) => p.id === companyProfileId)) {
        scope = { companyProfileId };
      }
    }

    const now = new Date();
    const yearStart = new Date(now.getFullYear(), 0, 1);
    const prevYearStart = new Date(now.getFullYear() - 1, 0, 1);
    // Same point in the previous year, so YoY compares like-for-like windows.
    const prevYearToDate = new Date(
      prevYearStart.getTime() + (now.getTime() - yearStart.getTime()),
    );

    const [
      deliveredThis,
      committedThis,
      spendThisByCcy,
      spendPrevByCcy,
      tonnageThis,
      tonnagePrev,
      monthlyRows,
    ] = await Promise.all([
      this.dashboardRepo.countDelivered(scope, yearStart, now),
      this.dashboardRepo.countCommitted(scope, yearStart, now),
      this.dashboardRepo.sumPaidSpendByCurrency(scope, yearStart, now),
      this.dashboardRepo.sumPaidSpendByCurrency(
        scope,
        prevYearStart,
        prevYearToDate,
      ),
      this.dashboardRepo.sumCommittedTonnage(scope, yearStart, now),
      this.dashboardRepo.sumCommittedTonnage(
        scope,
        prevYearStart,
        prevYearToDate,
      ),
      this.dashboardRepo.monthlyCommittedTonnage(
        scope,
        this.monthsAgo(now, 5),
        now,
      ),
    ]);

    // Spend can span currencies; report the dominant one (prefer ETB on ties).
    const spend = this.pickCurrencyTotal(spendThisByCcy);
    const spendPrev =
      spendPrevByCcy.find((c) => c.currency === spend.currency)?.total ?? 0;

    return {
      deliveredCount: deliveredThis,
      // Share of committed bookings that reached delivered/completed.
      completionRate:
        committedThis > 0
          ? Math.round((deliveredThis / committedThis) * 100)
          : 0,
      spendYtd: spend.total,
      spendCurrency: spend.currency,
      spendYtdChangePct: this.changePct(spend.total, spendPrev),
      freightVolume: {
        totalTonnes: Math.round(tonnageThis),
        totalValue: spend.total,
        currency: spend.currency,
        ytdChangePct: this.changePct(tonnageThis, tonnagePrev),
        monthly: this.buildMonthlySeries(now, monthlyRows),
      },
    };
  }

  private emptyDashboardSummary(): DashboardSummaryResponseDto {
    const now = new Date();
    return {
      deliveredCount: 0,
      completionRate: 0,
      spendYtd: 0,
      spendCurrency: "ETB",
      spendYtdChangePct: 0,
      freightVolume: {
        totalTonnes: 0,
        totalValue: 0,
        currency: "ETB",
        ytdChangePct: 0,
        monthly: this.buildMonthlySeries(now, []),
      },
    };
  }

  /** First day of the month `n` months before `from`. */
  private monthsAgo(from: Date, n: number): Date {
    return new Date(from.getFullYear(), from.getMonth() - n, 1);
  }

  /** Pick the currency with the largest total, preferring ETB on ties / when empty. */
  private pickCurrencyTotal(totals: { currency: string; total: number }[]): {
    currency: string;
    total: number;
  } {
    if (totals.length === 0) return { currency: "ETB", total: 0 };
    return totals.reduce((best, cur) => (cur.total > best.total ? cur : best));
  }

  /** Percentage change vs a prior value, rounded; 0 when there is no prior base. */
  private changePct(current: number, previous: number): number {
    if (previous <= 0) return 0;
    return Math.round(((current - previous) / previous) * 100);
  }

  /** Build a fixed 6-month tonnage series ending on `now`, zero-filling gaps. */
  private buildMonthlySeries(
    now: Date,
    rows: { year: number; month: number; tonnes: number }[],
  ): { month: string; tonnes: number }[] {
    const labels = [
      "Jan",
      "Feb",
      "Mar",
      "Apr",
      "May",
      "Jun",
      "Jul",
      "Aug",
      "Sep",
      "Oct",
      "Nov",
      "Dec",
    ];
    const byKey = new Map(rows.map((r) => [`${r.year}-${r.month}`, r.tonnes]));
    const series: { month: string; tonnes: number }[] = [];
    for (let i = 5; i >= 0; i--) {
      const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
      const key = `${d.getFullYear()}-${d.getMonth() + 1}`;
      series.push({
        month: labels[d.getMonth()],
        tonnes: Math.round(byKey.get(key) ?? 0),
      });
    }
    return series;
  }

  async updateCompany(id: string, dto: UpdateCompanyDto): Promise<Company> {
    const before = await this.findCompanyById(id);
    const updated = await this.companiesRepo.update(id, dto);
    if (!updated) throw new NotFoundException(`Company ${id} not found`);

    // Suspending or blacklisting locks the customer out, so they must be told.
    // This is the only path that writes those statuses.
    this.companyNotifier.statusChanged(updated, before.status);
    return updated;
  }

  /** Keep only the keys that were actually provided (drop `undefined`). */
  private pickDefined(dto: Record<string, any>): Record<string, any> {
    const out: Record<string, any> = {};
    for (const [k, v] of Object.entries(dto)) {
      if (v !== undefined) out[k] = v;
    }
    return out;
  }

  /**
   * Translate an UpdateProfileDto (or a staged change-request snapshot) into a
   * `Company` patch: scalar columns plus a merged `attributes` blob (contact/GM/
   * PoA live there). Pure — the caller runs the async TIN-uniqueness check.
   */
  private mapProfileDtoToCompanyUpdates(
    company: Company,
    dto: Partial<UpdateProfileDto>,
  ): Record<string, any> {
    const companyUpdates: Record<string, any> = {};
    const attrUpdates: Record<string, any> = { ...(company.attributes ?? {}) };

    if (dto.nationality !== undefined)
      companyUpdates.nationality = dto.nationality;
    if (dto.companyName !== undefined) companyUpdates.name = dto.companyName;
    if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail;
    if (dto.companyPhone !== undefined)
      companyUpdates.phone = normalizeE164(dto.companyPhone);
    if (dto.companyLocation !== undefined)
      companyUpdates.country = dto.companyLocation;
    if (dto.companyAddress !== undefined)
      companyUpdates.address = dto.companyAddress;
    if (dto.tin !== undefined && dto.tin !== company.tin)
      companyUpdates.tin = dto.tin;
    if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
    if (dto.fanNumber !== undefined) companyUpdates.fanNumber = dto.fanNumber;

    if (dto.contactPersonName !== undefined)
      attrUpdates.contactPersonName = dto.contactPersonName;
    if (dto.contactPersonPosition !== undefined)
      attrUpdates.contactPersonPosition = dto.contactPersonPosition;
    if (dto.contactPersonEmail !== undefined)
      attrUpdates.contactPersonEmail = dto.contactPersonEmail;
    if (dto.contactPersonPhone !== undefined)
      attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone);
    if (dto.contactVerifiedPhone !== undefined)
      attrUpdates.contactVerifiedPhone = normalizeE164(dto.contactVerifiedPhone);
    if (dto.generalManagerName !== undefined)
      attrUpdates.generalManagerName = dto.generalManagerName;
    if (dto.generalManagerEmail !== undefined)
      attrUpdates.generalManagerEmail = dto.generalManagerEmail;
    if (dto.generalManagerPhone !== undefined)
      attrUpdates.generalManagerPhone = normalizeE164(dto.generalManagerPhone);
    if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName;
    if (dto.poaPhone !== undefined)
      attrUpdates.poaPhone = normalizeE164(dto.poaPhone);
    if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
    if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation;
    if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;

    if (dto.licenceNumber !== undefined)
      companyUpdates.licenceNumber = dto.licenceNumber;
    if (dto.statusDescription !== undefined)
      companyUpdates.statusDescription = dto.statusDescription;
    if (dto.dateRegistered !== undefined)
      companyUpdates.dateRegistered = dto.dateRegistered;
    if (dto.renewedFrom !== undefined)
      companyUpdates.renewedFrom = dto.renewedFrom;
    if (dto.renewalDate !== undefined)
      companyUpdates.renewalDate = dto.renewalDate;
    if (dto.renewedTo !== undefined) companyUpdates.renewedTo = dto.renewedTo;
    if (dto.region !== undefined) companyUpdates.region = dto.region;
    if (dto.zone !== undefined) companyUpdates.zone = dto.zone;
    if (dto.woreda !== undefined) companyUpdates.woreda = dto.woreda;
    if (dto.kebele !== undefined) companyUpdates.kebele = dto.kebele;
    if (dto.houseNo !== undefined) companyUpdates.houseNo = dto.houseNo;
    if (dto.etradePhone !== undefined)
      companyUpdates.etradePhone = normalizeE164(dto.etradePhone);

    companyUpdates.attributes = attrUpdates;
    return companyUpdates;
  }

  /** Reject a TIN already registered to a *different* company. */
  private async assertTinAvailable(
    company: Company,
    tin: string | undefined,
  ): Promise<void> {
    if (tin === undefined || tin === company.tin) return;
    const owner = await this.companiesRepo.findByTin(tin);
    if (owner && owner.id !== company.id) {
      throw new ConflictException(
        `This TIN (${tin}) is already registered to another company. Please check the number and try again.`,
      );
    }
  }

  /** The company's open (pending or last-rejected) profile change request. */
  async getOpenChangeRequestForCompany(
    companyId: string,
  ): Promise<CompanyChangeRequest | null> {
    return this.changeRequestRepo.findLatestOpenByCompanyId(companyId);
  }

  /**
   * Update the current user's profile.
   *
   * - Company not yet approved (onboarding) → write straight to the Company row,
   *   as before. The company/role pending→approve gate already covers first-run.
   * - Company already `active` → do NOT touch the live Company. Stage the edit in
   *   a pending change request (merging into any open one) so a backoffice
   *   reviewer can approve (apply) or reject (with a note). This locks the
   *   customer until the review resolves.
   */
  async updateProfile(
    userId: string,
    dto: UpdateProfileDto,
  ): Promise<ProfileResponseDto> {
    const { profile, company } = await this.getCompanyInfoByUserId(userId);

    if (company.status !== CompanyStatus.Active) {
      await this.assertTinAvailable(company, dto.tin);
      const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, dto);
      const updated = await this.companiesRepo.update(
        company.id,
        companyUpdates,
      );
      if (!updated)
        throw new NotFoundException(`Company ${company.id} not found`);
      return new ProfileResponseDto(profile, updated);
    }

    // Approved company: stage the change for review, leaving the live row intact.
    await this.assertTinAvailable(company, dto.tin);
    const fields = this.pickDefined(dto);

    const existing = await this.changeRequestRepo.findPendingByCompanyId(
      company.id,
    );
    const now = new Date();
    let request: CompanyChangeRequest;
    if (existing) {
      request =
        (await this.changeRequestRepo.update(existing.id, {
          snapshot: { ...(existing.snapshot ?? {}), ...fields },
          submittedBy: userId,
          submittedAt: now,
          note: null,
        })) ?? existing;
      this.companyNotifier.changeRequestSubmitted(company, request.id, false);
    } else {
      // Rejecting a request leaves it Rejected rather than reopening it, so a
      // customer amending after a rejection lands here with a fresh Pending row.
      // That is the resubmission case the reviewer needs flagged.
      const history = await this.changeRequestRepo.findByCompanyId(company.id);
      const resubmitted = history.some(
        (r) => r.status === ChangeRequestStatus.Rejected,
      );
      request = await this.changeRequestRepo.create({
        companyId: company.id,
        snapshot: fields,
        status: ChangeRequestStatus.Pending,
        submittedBy: userId,
        submittedAt: now,
      });
      this.companyNotifier.changeRequestSubmitted(
        company,
        request.id,
        resubmitted,
      );
    }

    // Live company is unchanged; surface the pending state for the settings page.
    return new ProfileResponseDto(profile, company, request);
  }

  /** List a company's change requests, newest first (backoffice review). */
  async listChangeRequests(
    companyId: string,
  ): Promise<CompanyChangeRequest[]> {
    await this.findCompanyById(companyId);
    return this.changeRequestRepo.findByCompanyId(companyId);
  }

  /**
   * Approve a pending change request: apply its snapshot to the live Company and
   * mark the request approved. Any staged documents are already attached to the
   * company, so nothing else needs promoting.
   */
  async approveChangeRequest(
    id: string,
    reviewerId?: string,
  ): Promise<CompanyChangeRequest> {
    const request = await this.changeRequestRepo.findById(id);
    if (!request)
      throw new NotFoundException(`Change request ${id} not found`);
    if (request.status !== ChangeRequestStatus.Pending) {
      throw new BadRequestException(
        `Change request ${id} is already ${request.status}`,
      );
    }

    const company = await this.companiesRepo.findById(request.companyId);
    if (!company)
      throw new NotFoundException(`Company ${request.companyId} not found`);

    const snapshot = (request.snapshot ?? {}) as Partial<UpdateProfileDto>;
    await this.assertTinAvailable(company, snapshot.tin);
    const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, snapshot);
    await this.companiesRepo.update(company.id, companyUpdates);
    await this.applyLicenseChanges(request);
    await this.applyDocumentChanges(request);

    return (
      (await this.changeRequestRepo.update(id, {
        status: ChangeRequestStatus.Approved,
        reviewedBy: reviewerId ?? null,
        reviewedAt: new Date(),
        note: null,
      })) ?? request
    );
  }

  /**
   * Upload company documents. For an approved company this also opens/updates a
   * pending change request (recording the uploaded file ids) so the upload is
   * reviewed and the customer is locked until it clears — consistent with the
   * field-edit review. During onboarding (company not yet active) it's a plain
   * upload with no review.
   */
  async uploadCompanyDocuments(
    companyId: string,
    files: Express.Multer.File[],
    submittedBy?: string,
  ): Promise<FileRecord[]> {
    const company = await this.findCompanyById(companyId);
    const uploaded = await this.filesService.uploadMany(
      companyId,
      "companies",
      files,
    );
    await this.resolveDocumentChangeRequests(
      companyId,
      "companies",
      uploaded.map((f) => f.code),
      uploaded.map((f) => f.id),
    );
    if (company.status === CompanyStatus.Active) {
      await this.stageDocumentChange(
        company.id,
        uploaded.map((f) => f.id),
        submittedBy,
      );
    }
    return uploaded;
  }

  /**
   * Clear the `change_requested` flag from the documents a fresh upload replaces.
   *
   * Uploading does not overwrite the old row — it adds a new one under the same
   * `code` — so the flagged original would otherwise linger and keep the approval
   * gate closed even after the customer did exactly what was asked. Only rows of
   * the same code are touched, and never the newly uploaded ones.
   */
  private async resolveDocumentChangeRequests(
    resourceId: string,
    resource: string,
    codes: string[],
    uploadedIds: string[],
  ): Promise<void> {
    if (codes.length === 0) return;
    const replaced = new Set(codes);
    const fresh = new Set(uploadedIds);
    const open = await this.filesService.findWithOpenChangeRequest(
      [resourceId],
      resource,
    );
    await Promise.all(
      open
        .filter((f) => replaced.has(f.code) && !fresh.has(f.id))
        .map((f) => this.filesService.clearReview(f.id)),
    );
  }

  /**
   * Backoffice: ask the customer to correct one specific document, instead of
   * rejecting their whole role over it. Mirrors the contract change-request
   * flow — a note the customer sees verbatim, plus a block on approval until
   * they re-upload.
   */
  async requestDocumentChange(
    fileId: string,
    note: string,
    reviewerId?: string,
  ): Promise<FileRecord> {
    const file = await this.filesService.findById(fileId);
    const companyId = await this.resolveDocumentCompanyId(file);
    const company = await this.findCompanyById(companyId);

    // Flag the document while holding a write lock on its company row. The
    // approval gate takes the same lock before it reads the flags, so the two
    // serialize: a change request can never land in the window between the gate
    // checking "any open corrections?" and writing the profile Active.
    const updated = await this.dataSource.transaction(async (manager) => {
      await manager.findOne(Company, {
        where: { id: companyId },
        lock: { mode: "pessimistic_write" },
      });
      return this.filesService.setReviewStatus(
        file.id,
        "change_requested",
        note,
        reviewerId,
      );
    });
    this.companyNotifier.documentChangeRequested(
      company,
      file.name,
      note,
      file.id,
    );
    return updated;
  }

  /**
   * Which company a stored document belongs to. Company documents are keyed by
   * the company id directly; profile licences and POA letters hang off a company
   * profile, so those resolve through it.
   */
  private async resolveDocumentCompanyId(file: FileRecord): Promise<string> {
    if (file.resource === "companies") return file.resourceId;
    if (file.resource === "company_profiles") {
      const profile = await this.companyProfilesRepo.findById(file.resourceId);
      if (!profile) {
        throw new NotFoundException(
          `Company profile ${file.resourceId} not found`,
        );
      }
      return profile.companyId;
    }
    throw new BadRequestException(
      `Documents on "${file.resource}" do not support change requests`,
    );
  }

  /** Open or append a pending change request recording staged document uploads. */
  private async stageDocumentChange(
    companyId: string,
    fileIds: string[],
    submittedBy?: string,
  ): Promise<void> {
    if (fileIds.length === 0) return;
    const now = new Date();
    const existing =
      await this.changeRequestRepo.findPendingByCompanyId(companyId);
    const company = await this.companiesRepo.findById(companyId);
    if (existing) {
      const prev = existing.documents?.documentFileIds ?? [];
      await this.changeRequestRepo.update(existing.id, {
        // Spread the existing documents blob: a bare object would drop any
        // licenseChanges/documentChanges already staged on this request.
        documents: {
          ...existing.documents,
          documentFileIds: [...prev, ...fileIds],
        },
        submittedBy: submittedBy ?? existing.submittedBy ?? null,
        submittedAt: now,
        note: null,
      });
      if (company) {
        this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
      }
    } else {
      const history = await this.changeRequestRepo.findByCompanyId(companyId);
      const resubmitted = history.some(
        (r) => r.status === ChangeRequestStatus.Rejected,
      );
      const created = await this.changeRequestRepo.create({
        companyId,
        snapshot: {},
        documents: { documentFileIds: fileIds },
        status: ChangeRequestStatus.Pending,
        submittedBy: submittedBy ?? null,
        submittedAt: now,
      });
      if (company) {
        this.companyNotifier.changeRequestSubmitted(
          company,
          created.id,
          resubmitted,
        );
      }
    }
  }

  /** Reject a pending change request with a note (customer amends & resubmits). */
  async rejectChangeRequest(
    id: string,
    note: string,
    reviewerId?: string,
  ): Promise<CompanyChangeRequest> {
    const request = await this.changeRequestRepo.findById(id);
    if (!request)
      throw new NotFoundException(`Change request ${id} not found`);
    if (request.status !== ChangeRequestStatus.Pending) {
      throw new BadRequestException(
        `Change request ${id} is already ${request.status}`,
      );
    }
    await this.discardLicenseChanges(request);
    await this.discardDocumentChanges(request);
    return (
      (await this.changeRequestRepo.update(id, {
        status: ChangeRequestStatus.Rejected,
        // Staged license/document uploads were just discarded; drop their intents
        // so an amended resubmit never re-references deleted files.
        documents: {
          ...request.documents,
          licenseChanges: [],
          documentChanges: [],
        },
        note,
        reviewedBy: reviewerId ?? null,
        reviewedAt: new Date(),
      })) ?? request
    );
  }

  async deleteCompany(id: string): Promise<void> {
    await this.findCompanyById(id);
    await this.companiesRepo.softDelete(id);
  }

  async createProfile(dto: CreateExternalProfileDto): Promise<ExternalProfile> {
    await this.findCompanyById(dto.companyId);

    const existing = await this.profilesRepo.findByUserId(dto.userId);
    if (existing) {
      throw new ConflictException(
        `Profile for user ${dto.userId} already exists`,
      );
    }

    return this.profilesRepo.create(dto);
  }

  async findProfileByUserId(userId: string): Promise<ExternalProfile> {
    const profile = await this.profilesRepo.findByUserId(userId);
    if (!profile)
      throw new NotFoundException(`Profile for user ${userId} not found`);
    return profile;
  }

  async findProfilesByCompany(companyId: string): Promise<ExternalProfile[]> {
    return this.profilesRepo.findByCompanyId(companyId);
  }

  private getProfileTypeForCompanyType(companyType: string): ProfileType[] {
    switch (companyType) {
      case "customer":
        // A customer can operate as an importer and/or exporter, and may also
        // add a freight-forwarder service profile under the same company.
        return [
          ProfileType.importer,
          ProfileType.exporter,
          ProfileType.freightForwarder,
        ];
      case "freight_forwarder":
        return [ProfileType.freightForwarder];
      case "dj_freight_forwarder":
        return [ProfileType.djFreightForwarder];
      case "transporter":
        return [ProfileType.transporter];
      default:
        return [];
    }
  }

  async setCompanyProfileStatus(
    profileId: string,
    status: ProfileStatus,
    note?: string,
    reviewerId?: string,
  ): Promise<CompanyProfile> {
    const existing = await this.companyProfilesRepo.findById(profileId);
    if (!existing)
      throw new NotFoundException(`Company profile ${profileId} not found`);

    // Suspension and reactivation must carry a staff explanation — the customer
    // sees it, so "why" can never be left blank. Reactivation is the
    // active-write that leaves Suspended; a first approval stays note-free.
    const reactivating =
      status === ProfileStatus.Active &&
      existing.status === ProfileStatus.Suspended;
    if (
      (status === ProfileStatus.Suspended || reactivating) &&
      !note?.trim()
    ) {
      throw new BadRequestException(
        status === ProfileStatus.Suspended
          ? "A message explaining the suspension is required — the customer will see it."
          : "A message explaining the reactivation is required — the customer will see it.",
      );
    }

    // A self-registered company is only reviewable once its owner submits the
    // onboarding wizard (markOnboardingComplete) — until then its profiles are
    // half-filled drafts and approving one would mint a reference against an
    // application that doesn't exist yet. Staff-created companies have no
    // external profiles and are exempt.
    //
    // Only the review decision itself is gated (a profile still awaiting one:
    // Pending, or Rejected and awaiting re-approval). Profiles already in
    // service stay managable so staff can suspend/blacklist them — including to
    // undo an approval granted before this guard existed.
    const awaitingReview =
      existing.status === ProfileStatus.Pending ||
      existing.status === ProfileStatus.Rejected;
    if (awaitingReview) {
      const owners = await this.profilesRepo.findByCompanyId(existing.companyId);
      if (owners.length > 0 && !owners.some((o) => o.onboardingCompleted)) {
        throw new BadRequestException(
          "This customer hasn't finished onboarding yet. Their roles can be reviewed once they submit their application.",
        );
      }
    }

    // Anything other than approval has no document gate and no concurrency
    // hazard — no row lock, just the write.
    if (status !== ProfileStatus.Active) {
      return this.dataSource.transaction((manager) =>
        this.applyProfileStatus(manager, existing, status, note, reviewerId),
      );
    }

    // Approving over an outstanding document correction would silently accept the
    // very document a reviewer just rejected, and would strand the customer's
    // "please fix this" banner with nothing left to fix. The gate check and the
    // status write share a write lock on the company row — `requestDocumentChange`
    // takes the same lock, so a fresh correction can never land in the window
    // between "any open corrections?" and the profile going Active. Suspend and
    // blacklist skip all this — staff must always be able to act against a bad
    // account.
    return this.dataSource.transaction(async (manager) => {
      await manager.findOne(Company, {
        where: { id: existing.companyId },
        lock: { mode: "pessimistic_write" },
      });

      const [companyDocs, profileDocs] = await Promise.all([
        this.filesService.findWithOpenChangeRequest(
          [existing.companyId],
          "companies",
        ),
        this.filesService.findWithOpenChangeRequest(
          [existing.id],
          "company_profiles",
        ),
      ]);
      const pending = [...companyDocs, ...profileDocs];
      if (pending.length > 0) {
        const names = pending.map((f) => f.name).join(", ");
        throw new BadRequestException(
          `This role has ${pending.length} document(s) awaiting customer correction (${names}). ` +
            `Approve it once the customer has re-uploaded them, or withdraw the change request first.`,
        );
      }

      return this.applyProfileStatus(manager, existing, status, note, reviewerId);
    });
  }

  /**
   * Write a reviewed profile status (reference minting, note handling, reviewer
   * stamp) and promote the company if this is its first approved role. Split out
   * of `setCompanyProfileStatus` so the approval path can run it inside the gate
   * transaction while every other status skips that overhead.
   */
  private async applyProfileStatus(
    manager: EntityManager,
    existing: CompanyProfile,
    status: ProfileStatus,
    note?: string,
    reviewerId?: string,
  ): Promise<CompanyProfile> {
    // Every write below goes through `manager`. The approval path holds a
    // pessimistic_write lock on the company row, and the injected repositories
    // are bound to the DataSource's default pool — writing the same row through
    // one of them would block on a lock this very transaction holds, hanging the
    // request until the statement timed out. That deadlocked the first approval
    // of any customer: the profile went Active on its own connection while the
    // company stayed Pending and the caller never got a response.
    const profileRepo = manager.getRepository(CompanyProfile);
    const companyRepo = manager.getRepository(Company);
    // A reference number is only minted the first time a profile is approved
    // (status → Active). Pending/unapproved profiles carry no reference.
    const patch: Partial<CompanyProfile> = { status };
    if (status === ProfileStatus.Active && !existing.reference) {
      patch.reference = await this.companyProfilesRepo.generateReference(
        existing.type,
      );
    }

    // Track the review outcome. Rejection and suspension keep the note so the
    // customer knows why; approval/reactivation clears it. Any decision stamps
    // the reviewer + time.
    if (
      status === ProfileStatus.Rejected ||
      status === ProfileStatus.Suspended
    ) {
      patch.reviewNote = note ?? null;
    } else if (status === ProfileStatus.Active) {
      patch.reviewNote = null;
    }
    if (status !== ProfileStatus.Pending) {
      patch.reviewedBy = reviewerId ?? null;
      patch.reviewedAt = new Date();
    }

    await profileRepo.update(existing.id, patch);
    const updated = await profileRepo.findOne({ where: { id: existing.id } });
    if (!updated)
      throw new NotFoundException(`Company profile ${existing.id} not found`);

    // Every reviewed transition that changes what the customer can do is told
    // to them, carrying the staff message so they know why. Approval has no
    // message (the note is cleared); the others require one.
    const change =
      status === ProfileStatus.Suspended
        ? "suspended"
        : status === ProfileStatus.Rejected
          ? "rejected"
          : status === ProfileStatus.Active
            ? existing.status === ProfileStatus.Suspended
              ? "reactivated"
              : "approved"
            : null;
    if (change) {
      const company = await companyRepo.findOne({
        where: { id: updated.companyId },
      });
      if (company) {
        this.companyNotifier.profileStatusChanged(
          company,
          updated.type,
          change,
          note ?? "",
        );
        // The first approved role promotes a pending company to active — a
        // bigger event (the account itself goes live), so tell them that too.
        if (
          status === ProfileStatus.Active &&
          company.status === CompanyStatus.Pending
        ) {
          await companyRepo.update(updated.companyId, {
            status: CompanyStatus.Active,
          });
          this.companyNotifier.companyApproved(company);
        }
      }
    }
    return updated;
  }

  /**
   * Customer reapplies for a rejected or suspended operational role (after
   * fixing whatever the reviewer flagged, e.g. re-uploading a license): flip it
   * back to Pending and clear the review note so it re-enters the approval
   * queue. Suspension is a staff lockout, so resubmitting is an appeal — the
   * backoffice still has to approve before the role goes live again.
   */
  async reapplyCompanyProfile(
    userId: string,
    profileId: string,
  ): Promise<CompanyProfile> {
    const profile = await this.profilesRepo.findByUserId(userId);
    if (!profile)
      throw new NotFoundException(`Profile for user ${userId} not found`);
    const companyId = profile.company?.id ?? profile.companyId;

    const target = await this.companyProfilesRepo.findById(profileId);
    if (!target || target.companyId !== companyId) {
      throw new NotFoundException(`Company profile ${profileId} not found`);
    }
    if (
      target.status !== ProfileStatus.Rejected &&
      target.status !== ProfileStatus.Suspended
    ) {
      throw new BadRequestException(
        "Only a rejected or suspended role can be resubmitted for approval",
      );
    }

    const updated = await this.companyProfilesRepo.update(profileId, {
      status: ProfileStatus.Pending,
      reviewNote: null,
      reviewedBy: null,
      reviewedAt: null,
    });
    if (!updated)
      throw new NotFoundException(`Company profile ${profileId} not found`);

    // The role is back in the pending queue — tell the reviewers, otherwise the
    // resubmission is invisible until someone happens to reopen the customer.
    const company = await this.companiesRepo.findById(companyId);
    if (company) {
      this.companyNotifier.roleReapplied(company, updated.id, updated.type);
    }
    return updated;
  }

  async createCompanyProfile(
    companyId: string,
    profileType?: ProfileType,
  ): Promise<CompanyProfile> {
    const company = await this.findCompanyById(companyId);

    const allowedTypes = this.getProfileTypeForCompanyType(company.type);

    const type = profileType ?? allowedTypes[0];
    if (!allowedTypes.includes(type)) {
      throw new BadRequestException(
        `Profile type "${type}" is not allowed for company type "${company.type}"`,
      );
    }

    const existing = await this.companyProfilesRepo.findByType(companyId, type);
    if (existing) {
      throw new ConflictException(
        `Company already has a ${type} profile (${existing.reference ?? "pending approval"})`,
      );
    }

    // No reference is minted here: it is issued by setCompanyProfileStatus when
    // a reviewer approves the role. Creating it Active would bypass that review.
    return this.companyProfilesRepo.create({
      companyId,
      type,
      status: ProfileStatus.Pending,
    });
  }

  async createDefaultProfilesForCompany(
    companyId: string,
  ): Promise<CompanyProfile[]> {
    const company = await this.findCompanyById(companyId);
    const types = this.getProfileTypeForCompanyType(company.type);

    const profiles: CompanyProfile[] = [];
    for (const type of types) {
      const existing = await this.companyProfilesRepo.findByType(
        companyId,
        type,
      );
      if (!existing) {
        profiles.push(await this.createCompanyProfile(companyId, type));
      }
    }

    if (profiles.length === 0) {
      throw new BadRequestException(
        `Company of type "${company.type}" must have at least one operational profile`,
      );
    }

    return profiles;
  }

  /**
   * Add operational profile(s) to the current user's company (portal settings).
   * Add-only and idempotent: each requested type must be allowed for the
   * company's type, profiles that already exist are skipped (not re-created or
   * rejected), and the full updated list is returned.
   */
  async addCompanyProfilesForUser(
    userId: string,
    types: ProfileType[],
  ): Promise<CompanyProfile[]> {
    const profile = await this.profilesRepo.findByUserId(userId);
    if (!profile)
      throw new NotFoundException(`Profile for user ${userId} not found`);

    const companyId = profile.company?.id ?? profile.companyId;
    const company = await this.findCompanyById(companyId);
    const allowedTypes = this.getProfileTypeForCompanyType(company.type);

    for (const type of types) {
      if (!allowedTypes.includes(type)) {
        throw new BadRequestException(
          `Profile type "${type}" is not allowed for company type "${company.type}"`,
        );
      }

      const existing = await this.companyProfilesRepo.findByType(
        companyId,
        type,
      );
      if (existing) continue;

      // Self-service role adds start Pending and carry no reference — a reference
      // is minted only when a backoffice reviewer approves the role.
      await this.companyProfilesRepo.create({
        companyId,
        type,
        status: ProfileStatus.Pending,
      });
    }

    return this.companyProfilesRepo.findByCompanyId(companyId);
  }

  /**
   * Create a single operational profile for the current user's company. The new
   * role starts Pending and carries no reference until a backoffice reviewer
   * approves it; a booking/contract resolves its profile from the trade
   * direction at creation time, so no "active mode" is stored.
   */
  async createCompanyProfileForUser(
    userId: string,
    type: ProfileType,
    businessLicense?: string,
  ): Promise<CompanyProfile> {
    const profile = await this.profilesRepo.findByUserId(userId);
    if (!profile)
      throw new NotFoundException(`Profile for user ${userId} not found`);

    const companyId = profile.company?.id ?? profile.companyId;
    const company = await this.findCompanyById(companyId);
    const allowedTypes = this.getProfileTypeForCompanyType(company.type);
    if (!allowedTypes.includes(type)) {
      throw new BadRequestException(
        `Profile type "${type}" is not allowed for company type "${company.type}"`,
      );
    }

    let created = await this.companyProfilesRepo.findByType(companyId, type);
    if (!created) {
      // New self-service roles start Pending (awaiting backoffice approval) and
      // carry no reference until approved.
      created = await this.companyProfilesRepo.create({
        companyId,
        type,
        businessLicense: businessLicense ?? null,
        status: ProfileStatus.Pending,
      });
    }

    return created;
  }

  async setOnboardingStep(userId: string, step: string): Promise<void> {
    const profile = await this.profilesRepo.findByUserId(userId);
    if (!profile)
      throw new NotFoundException(`Profile for user ${userId} not found`);
    await this.profilesRepo.update(profile.id, { onboardingStep: step });
  }

  /**
   * Server-driven onboarding requirements for the current user's company.
   *
   * The backend resolves the nationality-based document set, checks which
   * company documents and per-profile licenses are already uploaded, and reports
   * exactly what is still outstanding. The portal renders this list verbatim and
   * relies on `isComplete` to decide when to auto-finish — it never decides for
   * itself which documents apply or which fields are mandatory.
   */
  async getOnboardingRequirements(
    userId: string,
  ): Promise<OnboardingRequirementsResponseDto> {
    const { profile, company } = await this.getCompanyInfoByUserId(userId);

    // 1. Required company-information fields.
    const missingInfo = this.REQUIRED_COMPANY_INFO.filter(
      (f) => !f.get(company),
    ).map((f) => ({ key: f.key, label: f.label }));

    // 2. Nationality-based company documents + which are already uploaded.
    const documentSettingCode = this.documentSettingCodeFor(company.nationality);
    const [setting, uploadedFiles] = await Promise.all([
      this.fileUploadSettingsService
        .getByCode(documentSettingCode)
        .catch(() => null),
      this.filesService.findByResource(company.id, "companies"),
    ]);
    const uploadedCodes = new Set(uploadedFiles.map((f) => f.code));
    const documents = (setting?.fields ?? [])
      .slice()
      .sort((a, b) => a.displayOrder - b.displayOrder)
      .map((f) => ({
        fileKey: f.fileKey,
        fileLabel: f.fileLabel,
        helpText: f.helpText ?? null,
        isRequired: f.isRequired,
        isMultiple: f.isMultiple,
        maxFiles: f.maxFiles,
        allowedExtensions: f.allowedExtensions,
        maxSizeMb: f.maxSizeMb,
        displayOrder: f.displayOrder,
        uploaded: uploadedCodes.has(f.fileKey),
      }));
    const missingDocs = documents.filter((d) => d.isRequired && !d.uploaded);

    // 3. Per-operational-profile business licenses (FileRecord-backed).
    const licenseProfiles = await Promise.all(
      (company.companyProfiles ?? []).map(async (p) => {
        const records = await this.filesService.findByResource(
          p.id,
          LICENSE_RESOURCE,
        );
        return {
          profileId: p.id,
          type: p.type,
          reference: p.reference ?? "",
          uploaded: records.some((r) => r.code === LICENSE_CODE),
        };
      }),
    );
    const missingLicenses = licenseProfiles.filter((p) => !p.uploaded);

    // 4. Power of Attorney. Optional in general, but a freight forwarder acts on
    // other companies' behalf so its PoA is mandatory. Either way, a PoA that
    // has been entered must be evidenced by the delegation letter.
    const poaRequired = (company.companyProfiles ?? []).some(
      (p) => p.type === ProfileType.freightForwarder,
    );
    const poaProvided = POA_ATTRIBUTES.some((k) =>
      (company.attributes?.[k] as string | undefined)?.trim(),
    );
    const missingPoaFields = poaRequired
      ? REQUIRED_POA_FIELDS.filter(
          (f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
        )
      : [];
    // Only gate on the letter once the document set actually carries the field.
    const delegationField = (setting?.fields ?? []).find(
      (f) => f.fileKey === POA_DELEGATION_FILE_KEY,
    );
    const missingDelegation =
      Boolean(delegationField) &&
      (poaRequired || poaProvided) &&
      !uploadedCodes.has(POA_DELEGATION_FILE_KEY);

    const outstanding = [
      ...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`),
      ...missingDocs.map((d) => `Upload your ${d.fileLabel}`),
      ...missingLicenses.map(
        (p) =>
          `Upload a business license for your ${p.type.replace(/_/g, " ")} profile`,
      ),
      ...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`),
      ...(missingDelegation
        ? ["Upload the delegation letter for your Power of Attorney"]
        : []),
    ];

    // Progress spans every required item the user has to satisfy: company-info
    // fields, required documents, one license per operational profile, and the
    // PoA details/letter whenever those are mandatory.
    const requiredDocCount = documents.filter((d) => d.isRequired).length;
    const poaItemCount =
      (poaRequired ? REQUIRED_POA_FIELDS.length : 0) +
      (delegationField && (poaRequired || poaProvided) ? 1 : 0);
    const total =
      this.REQUIRED_COMPANY_INFO.length +
      requiredDocCount +
      licenseProfiles.length +
      poaItemCount;
    const completed =
      total -
      (missingInfo.length +
        missingDocs.length +
        missingLicenses.length +
        missingPoaFields.length +
        (missingDelegation ? 1 : 0));

    return new OnboardingRequirementsResponseDto({
      documentSettingCode,
      nationality: company.nationality ?? CompanyNationality.Ethiopian,
      companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo },
      documents,
      licenseProfiles,
      poa: {
        required: poaRequired,
        provided: poaProvided,
        delegationLetterUploaded: uploadedCodes.has(POA_DELEGATION_FILE_KEY),
        missingFields: missingPoaFields,
        complete: missingPoaFields.length === 0 && !missingDelegation,
      },
      progress: { completed, total },
      isComplete: outstanding.length === 0,
      onboardingCompleted: profile.onboardingCompleted,
      outstanding,
    });
  }

  /**
   * Submit onboarding for review. Validation is delegated entirely to
   * getOnboardingRequirements (the same source of truth the portal renders), so
   * the gate can never drift from what the UI shows. On success the company and
   * all its operational profiles move to PENDING — the backoffice approves each
   * profile before it can be used (see setCompanyProfileStatus).
   */
  async markOnboardingComplete(
    userId: string,
  ): Promise<{ profile: ExternalProfile; company: Company }> {
    const profile = await this.profilesRepo.findByUserId(userId);
    if (!profile)
      throw new NotFoundException(`Profile for user ${userId} not found`);

    const companyId = profile.company?.id ?? profile.companyId;

    const requirements = await this.getOnboardingRequirements(userId);
    if (!requirements.isComplete) {
      throw new BadRequestException(
        requirements.outstanding[0] ??
          "Your onboarding is incomplete. Please complete all required steps before submitting.",
      );
    }

    // Send every operational profile in for approval; the company itself becomes
    // active once the backoffice approves at least one profile.
    const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
    for (const cp of profiles) {
      if (cp.status !== ProfileStatus.Pending) {
        await this.companyProfilesRepo.updateStatus(cp.id, ProfileStatus.Pending);
      }
    }

    await this.profilesRepo.update(profile.id, {
      onboardingCompleted: true,
      onboardingStep: "done",
    });
    // Awaiting backoffice approval — stays Pending until an admin activates it.
    await this.companiesRepo.update(companyId, {
      status: CompanyStatus.Pending,
    });
    return this.getCompanyInfoByUserId(userId);
  }

  /**
   * Block a self-service action when the company account isn't active, naming
   * the actual status — a suspended customer told "awaiting approval" has no
   * idea what happened or who to call.
   */
  assertCompanyActiveFor(company: Company, action: string): void {
    if (company.status === CompanyStatus.Active) return;
    switch (company.status) {
      case CompanyStatus.Suspended:
        throw new ForbiddenException(
          `Your company account is suspended — you can't create ${action} right now. ` +
            `Please contact EDR support for details.`,
        );
      case CompanyStatus.Blacklisted:
        throw new ForbiddenException(
          `Your company account is blacklisted — you can't create ${action}. ` +
            `Please contact EDR support.`,
        );
      default:
        throw new ForbiddenException(
          `Your company is awaiting approval — you can't create ${action} yet.`,
        );
    }
  }

  /**
   * Block a customer from booking under a profile that isn't approved yet — or
   * that a reviewer has since suspended. Called from the booking/contract
   * create path for self-service actions; staff- and government-initiated ones
   * bypass this. No-op when the profile can't be found (defensive — resolution
   * is best-effort upstream). The message names the profile's real status:
   * suspension in particular is per-role, so the customer must learn which
   * operation is blocked (their other roles still work).
   */
  async assertCompanyProfileApprovedForBooking(
    companyProfileId: string,
  ): Promise<void> {
    const profile = await this.companyProfilesRepo.findById(companyProfileId);
    if (!profile) return;
    if (profile.status === ProfileStatus.Active) return;

    const role = profile.type.replace(/_/g, " ");
    switch (profile.status) {
      case ProfileStatus.Suspended:
        throw new ForbiddenException(
          `Your ${role} role is suspended${
            profile.reviewNote ? ` — ${profile.reviewNote}` : ""
          }. Your other roles are unaffected. Please contact EDR support to resolve this.`,
        );
      case ProfileStatus.Blacklisted:
        throw new ForbiddenException(
          `Your ${role} role is blacklisted. Please contact EDR support.`,
        );
      case ProfileStatus.Rejected:
        throw new ForbiddenException(
          `Your ${role} role was rejected${
            profile.reviewNote ? ` — ${profile.reviewNote}` : ""
          }. Amend and resubmit it from your settings page.`,
        );
      default:
        throw new ForbiddenException(
          `Your ${role} profile is awaiting approval. You'll be able to proceed once it has been approved.`,
        );
    }
  }

  /**
   * Authorize and resolve a company_profile that must belong to the current
   * user's company — used before accepting/returning its license files.
   */
  async resolveOwnedProfile(
    userId: string,
    profileId: string,
  ): Promise<CompanyProfile> {
    const { company } = await this.getCompanyInfoByUserId(userId);
    const owned = (company.companyProfiles ?? []).find(
      (p) => p.id === profileId,
    );
    if (!owned) {
      throw new NotFoundException(`Profile ${profileId} not found`);
    }
    return owned;
  }

  // ─── Business-license files ────────────────────────────────────────────────
  //
  // License documents live in the FileRecord model (`freight.files`) with
  // `resource = "company_profiles"`, `resourceId = <profileId>`. Live files use
  // code `LICENSE_CODE`; files staged inside an open change request (add /
  // replacement) use `LICENSE_PENDING_CODE` and only become live on approval.
  // Preview streams through `GET /api/files/:id` (server-side proxy) — the same
  // path regular documents use — so it never hits MinIO directly from the
  // browser (which fails on the internal bucket endpoint).

  /**
   * Upload business-license file(s) for one of the user's profiles. For a role
   * not yet approved (a fresh onboarding profile, or a newly added service on an
   * already-active company) they go live immediately and are reviewed together
   * with the role itself. Only for an already-approved role are they staged under
   * the pending code and recorded as `add` intents on a pending change request —
   * a licence swap on a live role is a change; a licence on a new role is not.
   */
  async addProfileLicenseFiles(
    userId: string,
    profileId: string,
    files: Express.Multer.File[],
  ): Promise<ProfileLicenseFileView[]> {
    const profile = await this.resolveOwnedProfile(userId, profileId);
    const company = await this.findCompanyById(profile.companyId);
    const gated = profile.status === ProfileStatus.Active;
    const code = gated ? LICENSE_PENDING_CODE : LICENSE_CODE;

    const uploaded = await Promise.all(
      files.map((file) =>
        this.filesService.upload({
          resourceId: profileId,
          resource: LICENSE_RESOURCE,
          code,
          file,
        }),
      ),
    );

    if (gated) {
      await this.stageLicenseChange(
        company.id,
        uploaded.map((r) => ({
          profileId,
          op: "add" as const,
          fileId: r.id,
          fileName: r.name,
        })),
        userId,
      );
    }

    // A fresh licence upload answers any correction the reviewer asked for on the
    // previous one, so the old row must stop blocking approval.
    await this.resolveDocumentChangeRequests(
      profileId,
      LICENSE_RESOURCE,
      [LICENSE_CODE, LICENSE_PENDING_CODE],
      uploaded.map((r) => r.id),
    );

    return this.getProfileLicenseView(profileId, company.id);
  }

  /**
   * Remove a license file. A staged (pending) file is withdrawn outright
   * (soft-deleted, its `add` intent dropped). A live file on an already-approved
   * role is kept and recorded as a `remove` intent for review; on a role still
   * awaiting approval it is deleted immediately.
   */
  async removeProfileLicenseFile(
    userId: string,
    profileId: string,
    fileId: string,
  ): Promise<ProfileLicenseFileView[]> {
    const profile = await this.resolveOwnedProfile(userId, profileId);
    const record = await this.filesService.findById(fileId);
    if (
      record.resource !== LICENSE_RESOURCE ||
      record.resourceId !== profileId
    ) {
      throw new NotFoundException(`License file ${fileId} not found`);
    }
    const company = await this.findCompanyById(profile.companyId);
    const gated = profile.status === ProfileStatus.Active;

    if (record.code === LICENSE_PENDING_CODE) {
      // Withdraw a not-yet-approved upload: delete it and drop its add intent.
      await this.filesService.remove(fileId);
      await this.withdrawLicenseIntent(company.id, fileId);
    } else if (gated) {
      await this.stageLicenseChange(
        company.id,
        [{ profileId, op: "remove", fileId, fileName: record.name }],
        userId,
      );
    } else {
      await this.filesService.remove(fileId);
    }

    return this.getProfileLicenseView(profileId, company.id);
  }

  /**
   * Replace a live license file with a freshly uploaded one — recorded as a
   * `remove` of the old file plus an `add` of the new, so approval swaps them
   * atomically. On a role still awaiting approval the swap is applied immediately.
   */
  async replaceProfileLicenseFile(
    userId: string,
    profileId: string,
    fileId: string,
    file: Express.Multer.File,
  ): Promise<ProfileLicenseFileView[]> {
    const profile = await this.resolveOwnedProfile(userId, profileId);
    const old = await this.filesService.findById(fileId);
    if (old.resource !== LICENSE_RESOURCE || old.resourceId !== profileId) {
      throw new NotFoundException(`License file ${fileId} not found`);
    }
    const company = await this.findCompanyById(profile.companyId);
    const gated = profile.status === ProfileStatus.Active;

    const created = await this.filesService.upload({
      resourceId: profileId,
      resource: LICENSE_RESOURCE,
      code: gated ? LICENSE_PENDING_CODE : LICENSE_CODE,
      file,
    });

    if (gated) {
      await this.stageLicenseChange(
        company.id,
        [
          { profileId, op: "remove", fileId, fileName: old.name },
          { profileId, op: "add", fileId: created.id, fileName: created.name },
        ],
        userId,
      );
    } else {
      await this.filesService.remove(fileId);
    }

    await this.resolveDocumentChangeRequests(
      profileId,
      LICENSE_RESOURCE,
      [LICENSE_CODE, LICENSE_PENDING_CODE],
      [created.id],
    );

    return this.getProfileLicenseView(profileId, company.id);
  }

  /** License files for one profile, with each file's review status resolved. */
  async listProfileLicenseFiles(
    userId: string,
    profileId: string,
  ): Promise<ProfileLicenseFileView[]> {
    const profile = await this.resolveOwnedProfile(userId, profileId);
    return this.getProfileLicenseView(profileId, profile.companyId);
  }

  /**
   * Live license files for a profile, shaped for by-reference reuse (bookings /
   * contracts snapshot these). No ownership check — internal callers only.
   * Returns the raw stored URLs; pending (unapproved) files are excluded.
   */
  async getProfileOnboardingFiles(
    profileId: string,
  ): Promise<BusinessLicenseFile[]> {
    const records = await this.filesService.findByResource(
      profileId,
      LICENSE_RESOURCE,
    );
    return records
      .filter((r) => r.code === LICENSE_CODE)
      .map((r) => ({
        name: r.name,
        url: r.url,
        size: r.size,
        mimeType: r.mimeType,
      }));
  }

  /**
   * Assemble the review-aware license view for a set of profiles in one pass
   * (single change-request lookup). Used to enrich company/profile responses.
   */
  async assembleLicenseFilesByProfile(
    companyId: string,
    profileIds: string[],
  ): Promise<Record<string, ProfileLicenseFileView[]>> {
    const pending =
      await this.changeRequestRepo.findPendingByCompanyId(companyId);
    const removeIds = new Set(
      (pending?.documents?.licenseChanges ?? [])
        .filter((c) => c.op === "remove")
        .map((c) => c.fileId),
    );
    const result: Record<string, ProfileLicenseFileView[]> = {};
    await Promise.all(
      profileIds.map(async (pid) => {
        result[pid] = await this.mapLicenseRecords(pid, removeIds);
      }),
    );
    return result;
  }

  /** Single-profile license view (fetches the company's pending request once). */
  private async getProfileLicenseView(
    profileId: string,
    companyId: string,
  ): Promise<ProfileLicenseFileView[]> {
    const pending =
      await this.changeRequestRepo.findPendingByCompanyId(companyId);
    const removeIds = new Set(
      (pending?.documents?.licenseChanges ?? [])
        .filter((c) => c.op === "remove")
        .map((c) => c.fileId),
    );
    return this.mapLicenseRecords(profileId, removeIds);
  }

  private async mapLicenseRecords(
    profileId: string,
    pendingRemoveIds: Set<string>,
  ): Promise<ProfileLicenseFileView[]> {
    const records = await this.filesService.findByResource(
      profileId,
      LICENSE_RESOURCE,
    );
    return records
      .filter(
        (r) => r.code === LICENSE_CODE || r.code === LICENSE_PENDING_CODE,
      )
      .map((r) => ({
        id: r.id,
        name: r.name,
        size: r.size,
        mimeType: r.mimeType,
        status:
          r.code === LICENSE_PENDING_CODE
            ? ("pending_add" as const)
            : pendingRemoveIds.has(r.id)
              ? ("pending_remove" as const)
              : ("live" as const),
        reviewStatus: r.reviewStatus,
        reviewNote: r.reviewNote,
      }));
  }

  /** Open or append a pending change request recording license add/remove intents. */
  private async stageLicenseChange(
    companyId: string,
    changes: LicenseChangeIntent[],
    submittedBy?: string,
  ): Promise<void> {
    if (changes.length === 0) return;
    const now = new Date();
    const existing =
      await this.changeRequestRepo.findPendingByCompanyId(companyId);
    if (existing) {
      const prev = existing.documents?.licenseChanges ?? [];
      await this.changeRequestRepo.update(existing.id, {
        documents: {
          ...existing.documents,
          licenseChanges: [...prev, ...changes],
        },
        submittedBy: submittedBy ?? existing.submittedBy ?? null,
        submittedAt: now,
        note: null,
      });
    } else {
      await this.changeRequestRepo.create({
        companyId,
        snapshot: {},
        documents: { licenseChanges: changes },
        status: ChangeRequestStatus.Pending,
        submittedBy: submittedBy ?? null,
        submittedAt: now,
      });
    }
  }

  /**
   * Drop a staged license intent (add or remove) referencing `fileId` from the
   * company's open request. If that empties the request entirely, delete it so
   * the customer's settings page unlocks.
   */
  private async withdrawLicenseIntent(
    companyId: string,
    fileId: string,
  ): Promise<void> {
    const existing =
      await this.changeRequestRepo.findPendingByCompanyId(companyId);
    if (!existing) return;
    const remaining = (existing.documents?.licenseChanges ?? []).filter(
      (c) => c.fileId !== fileId,
    );
    const docs = existing.documents ?? {};
    const stillHasWork =
      remaining.length > 0 ||
      (docs.documentFileIds?.length ?? 0) > 0 ||
      Object.keys(existing.snapshot ?? {}).length > 0;

    if (stillHasWork) {
      await this.changeRequestRepo.update(existing.id, {
        documents: { ...docs, licenseChanges: remaining },
      });
    } else {
      await this.changeRequestRepo.softDelete(existing.id);
    }
  }

  /** Apply a request's staged license changes: promote adds, delete removes. */
  private async applyLicenseChanges(
    request: CompanyChangeRequest,
  ): Promise<void> {
    for (const change of request.documents?.licenseChanges ?? []) {
      if (change.op === "add") {
        await this.filesService.setCode(change.fileId, LICENSE_CODE);
      } else {
        await this.filesService.remove(change.fileId);
      }
    }
  }

  /** Discard a rejected request's staged license uploads (adds only). */
  private async discardLicenseChanges(
    request: CompanyChangeRequest,
  ): Promise<void> {
    for (const change of request.documents?.licenseChanges ?? []) {
      if (change.op === "add") {
        await this.filesService.remove(change.fileId);
      }
    }
  }

  // ---------------------------------------------------------------------------
  // Power of Attorney delegation letter
  //
  // A company-level document that follows the same staged-review model as the
  // business license: on an approved (Active) company an upload lands under the
  // pending code and the live letter is flagged for removal, so the reviewer
  // sees both and approval swaps them atomically. During onboarding it goes live.
  // ---------------------------------------------------------------------------

  /** The company's PoA letter(s), with each file's review status resolved. */
  async listPoaDelegationFiles(
    userId: string,
  ): Promise<CompanyDocumentFileView[]> {
    const { company } = await this.getCompanyInfoByUserId(userId);
    return this.getPoaDelegationView(company.id);
  }

  /**
   * Upload the PoA delegation letter, replacing whatever is already on file.
   * On an Active company this stages an `add` for the new file plus a `remove`
   * for each live one; a letter still awaiting approval is withdrawn outright
   * rather than stacking a second pending upload.
   */
  async uploadPoaDelegationLetter(
    userId: string,
    file: Express.Multer.File,
  ): Promise<CompanyDocumentFileView[]> {
    const { company } = await this.getCompanyInfoByUserId(userId);
    const gated = company.status === CompanyStatus.Active;

    const records = await this.filesService.findByResource(
      company.id,
      COMPANY_RESOURCE,
    );
    const live = records.filter((r) => r.code === POA_DELEGATION_FILE_KEY);
    const staged = records.filter(
      (r) => r.code === POA_DELEGATION_PENDING_CODE,
    );

    // Supersede an unreviewed upload instead of queueing another one.
    for (const r of staged) {
      await this.filesService.remove(r.id);
      await this.withdrawDocumentIntent(company.id, r.id);
    }

    const created = await this.filesService.upload({
      resourceId: company.id,
      resource: COMPANY_RESOURCE,
      code: gated ? POA_DELEGATION_PENDING_CODE : POA_DELEGATION_FILE_KEY,
      file,
    });

    if (gated) {
      await this.stageDocumentIntent(
        company.id,
        [
          ...live.map((r) => ({
            op: "remove" as const,
            fileId: r.id,
            code: POA_DELEGATION_FILE_KEY,
            fileName: r.name,
          })),
          {
            op: "add" as const,
            fileId: created.id,
            code: POA_DELEGATION_FILE_KEY,
            fileName: created.name,
          },
        ],
        userId,
      );
    } else {
      // Onboarding: no review, so the old letter is simply replaced.
      for (const r of live) await this.filesService.remove(r.id);
    }

    await this.resolveDocumentChangeRequests(
      company.id,
      COMPANY_RESOURCE,
      [POA_DELEGATION_FILE_KEY, POA_DELEGATION_PENDING_CODE],
      [created.id],
    );

    return this.getPoaDelegationView(company.id);
  }

  /**
   * Remove the PoA letter. A staged upload is withdrawn outright; a live file on
   * an Active company is kept and flagged for deletion on approval; during
   * onboarding it is deleted immediately.
   */
  async removePoaDelegationLetter(
    userId: string,
    fileId: string,
  ): Promise<CompanyDocumentFileView[]> {
    const { company } = await this.getCompanyInfoByUserId(userId);
    const record = await this.filesService.findById(fileId);
    if (
      record.resource !== COMPANY_RESOURCE ||
      record.resourceId !== company.id ||
      (record.code !== POA_DELEGATION_FILE_KEY &&
        record.code !== POA_DELEGATION_PENDING_CODE)
    ) {
      throw new NotFoundException(`Delegation letter ${fileId} not found`);
    }

    if (record.code === POA_DELEGATION_PENDING_CODE) {
      await this.filesService.remove(fileId);
      await this.withdrawDocumentIntent(company.id, fileId);
    } else if (company.status === CompanyStatus.Active) {
      await this.stageDocumentIntent(
        company.id,
        [
          {
            op: "remove",
            fileId,
            code: POA_DELEGATION_FILE_KEY,
            fileName: record.name,
          },
        ],
        userId,
      );
    } else {
      await this.filesService.remove(fileId);
    }

    return this.getPoaDelegationView(company.id);
  }

  private async getPoaDelegationView(
    companyId: string,
  ): Promise<CompanyDocumentFileView[]> {
    const pending =
      await this.changeRequestRepo.findPendingByCompanyId(companyId);
    const removeIds = new Set(
      (pending?.documents?.documentChanges ?? [])
        .filter((c) => c.op === "remove")
        .map((c) => c.fileId),
    );
    const records = await this.filesService.findByResource(
      companyId,
      COMPANY_RESOURCE,
    );
    return records
      .filter(
        (r) =>
          r.code === POA_DELEGATION_FILE_KEY ||
          r.code === POA_DELEGATION_PENDING_CODE,
      )
      .map((r) => ({
        id: r.id,
        name: r.name,
        size: r.size,
        mimeType: r.mimeType,
        status:
          r.code === POA_DELEGATION_PENDING_CODE
            ? ("pending_add" as const)
            : removeIds.has(r.id)
              ? ("pending_remove" as const)
              : ("live" as const),
        reviewStatus: r.reviewStatus,
        reviewNote: r.reviewNote,
      }));
  }

  /** Open or append a pending change request recording document add/remove intents. */
  private async stageDocumentIntent(
    companyId: string,
    changes: DocumentChangeIntent[],
    submittedBy?: string,
  ): Promise<void> {
    if (changes.length === 0) return;
    const now = new Date();
    const existing =
      await this.changeRequestRepo.findPendingByCompanyId(companyId);
    if (existing) {
      const prev = existing.documents?.documentChanges ?? [];
      // Re-uploading twice before review would otherwise stage a second `remove`
      // for the same live file, and the duplicate would fail on approval.
      const seen = new Set(prev.map((c) => `${c.op}:${c.fileId}`));
      const fresh = changes.filter((c) => !seen.has(`${c.op}:${c.fileId}`));
      if (fresh.length === 0) return;
      await this.changeRequestRepo.update(existing.id, {
        documents: {
          ...existing.documents,
          documentChanges: [...prev, ...fresh],
        },
        submittedBy: submittedBy ?? existing.submittedBy ?? null,
        submittedAt: now,
        note: null,
      });
    } else {
      await this.changeRequestRepo.create({
        companyId,
        snapshot: {},
        documents: { documentChanges: changes },
        status: ChangeRequestStatus.Pending,
        submittedBy: submittedBy ?? null,
        submittedAt: now,
      });
    }
  }

  /**
   * Drop a staged document intent referencing `fileId`. If that empties the
   * request entirely, delete it so the customer's settings page unlocks.
   */
  private async withdrawDocumentIntent(
    companyId: string,
    fileId: string,
  ): Promise<void> {
    const existing =
      await this.changeRequestRepo.findPendingByCompanyId(companyId);
    if (!existing) return;
    const remaining = (existing.documents?.documentChanges ?? []).filter(
      (c) => c.fileId !== fileId,
    );
    const docs = existing.documents ?? {};
    const stillHasWork =
      remaining.length > 0 ||
      (docs.licenseChanges?.length ?? 0) > 0 ||
      (docs.documentFileIds?.length ?? 0) > 0 ||
      Object.keys(existing.snapshot ?? {}).length > 0;

    if (stillHasWork) {
      await this.changeRequestRepo.update(existing.id, {
        documents: { ...docs, documentChanges: remaining },
      });
    } else {
      await this.changeRequestRepo.softDelete(existing.id);
    }
  }

  /** Apply a request's staged document changes: promote adds, delete removes. */
  private async applyDocumentChanges(
    request: CompanyChangeRequest,
  ): Promise<void> {
    for (const change of request.documents?.documentChanges ?? []) {
      if (change.op === "add") {
        await this.filesService.setCode(change.fileId, change.code);
      } else {
        await this.filesService.remove(change.fileId);
      }
    }
  }

  /** Discard a rejected request's staged document uploads (adds only). */
  private async discardDocumentChanges(
    request: CompanyChangeRequest,
  ): Promise<void> {
    for (const change of request.documents?.documentChanges ?? []) {
      if (change.op === "add") {
        await this.filesService.remove(change.fileId);
      }
    }
  }

  /**
   * Resolve which company_profile a new booking belongs to, from the company
   * and the booking's trade direction. IMPORT → importer profile, EXPORT →
   * exporter profile; for DOMESTIC (or when the natural profile doesn't exist,
   * e.g. a freight forwarder) it falls back to the company's first profile.
   * Callers that need a specific role (a forwarder) pass an explicit
   * companyProfileId instead. Returns null when the company has no profiles.
   */
  async resolveCompanyProfileIdForBooking(
    companyId: string,
    tradeDirection: string,
  ): Promise<string | null> {
    const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
    if (profiles.length === 0) return null;

    const naturalType =
      tradeDirection === "IMPORT"
        ? ProfileType.importer
        : tradeDirection === "EXPORT"
          ? ProfileType.exporter
          : null;

    const match =
      (naturalType && profiles.find((p) => p.type === naturalType)) ??
      profiles[0];
    return match?.id ?? null;
  }

  async fetchETradeData(tin: string) {
    const { businessInfo, companyInfo } =
      await this.etradeService.resolveCompanyData(tin);
    if (!businessInfo) {
      throw new BadRequestException(
        "We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.",
      );
    }
    const registrationData = this.etradeService.extractRegistrationData(
      businessInfo,
      companyInfo,
    );
    const tinTaken = await this.companiesRepo.existsByTin(tin);
    return { ...registrationData, tinTaken };
  }
}
