import {
  Company,
  Opportunity,
  OpportunityInterest,
  Role,
  User,
} from "../../generated/prisma/client";
import { prisma } from "../constants";
import { CompanyController } from "./CompanyController";
import { ActivityController } from "./ActivityController";
import { opportunityCw } from "../modules/cw-utils/opportunities/opportunities";
import {
  CWCustomField,
  CWForecastItem,
  CWForecastItemCreate,
  CWOpportunity,
  CWOpportunityNote,
  CWOpportunityUpdate,
  CWProcurementProduct,
  CWProcurementProductCreate,
} from "../modules/cw-utils/opportunities/opportunity.types";
import { ForecastProductController } from "./ForecastProductController";
import GenericError from "../Errors/GenericError";
import UserController from "./UserController";
import {
  generateQuote as generateQuotePdf,
  type QuoteMetadata,
} from "../modules/pdf-utils";
import { generatedQuotes } from "../managers/generatedQuotes";
import { normalizeProbabilityPercent } from "../modules/sales-utils/normalizeProbability";

// Helper to resolve member info from DB
async function resolveMember(identifier: string | null | undefined) {
  if (!identifier) return null;
  const member = await prisma.cwMember.findFirst({
    where: { identifier },
  });
  return member
    ? {
      id: member.id,
      identifier: member.identifier,
      name: `${member.firstName} ${member.lastName}`.trim(),
      cwMemberId: member.cwMemberId,
    }
    : { id: null, identifier, name: identifier, cwMemberId: null };
}

async function resolveMembers(identifiers: string[]) {
  const unique = [...new Set(identifiers.filter(Boolean))];
  if (unique.length === 0) return new Map<string, any>();

  const members = await prisma.cwMember.findMany({
    where: { identifier: { in: unique } },
  });

  const map = new Map<string, any>();
  for (const m of members) {
    map.set(m.identifier, {
      id: m.id,
      identifier: m.identifier,
      name: `${m.firstName} ${m.lastName}`.trim(),
      cwMemberId: m.cwMemberId,
    });
  }
  return map;
}

function mapRatingNameToInterest(
  ratingName?: string | null
): OpportunityInterest | null {
  const normalized = (ratingName ?? "").trim().toUpperCase();
  if (!normalized) return null;
  if (normalized.includes("HOT")) return "HOT";
  if (normalized.includes("WARM") || normalized.includes("MED")) return "WARM";
  if (normalized.includes("COLD") || normalized.includes("COOL")) return "COLD";
  return null;
}

function formatOpportunityContactName(
  firstName?: string | null,
  lastName?: string | null
): string {
  const first = (firstName ?? "").trim();
  const last = (lastName ?? "").trim();

  if (first && last.toLowerCase() === "contact") {
    return first;
  }

  return `${first} ${last}`.trim();
}

/**
 * Opportunity Controller
 *
 * Domain model class that encapsulates an Opportunity entity and provides
 * methods for accessing, refreshing from ConnectWise, and serializing
 * opportunity data.
 */
export class OpportunityController {
  private static readonly QUOTE_NARRATIVE_FIELD_ID = 35;

  public readonly id: string;
  public readonly cwOpportunityId: number;
  public name: string;
  public notes: string | null;

  public typeName: string | null;
  public typeCwId: number | null;
  public stageName: string | null;
  public stageCwId: number | null;
  public statusName: string | null;
  public statusCwId: number | null;
  public priorityName: string | null;
  public priorityCwId: number | null;
  public ratingName: string | null;
  public ratingCwId: number | null;
  public interest: OpportunityInterest | null;
  public source: string | null;
  public campaignName: string | null;
  public campaignCwId: number | null;

  public primarySalesRepName: string | null;
  public primarySalesRepIdentifier: string | null;
  public primarySalesRepCwId: number | null;
  public secondarySalesRepName: string | null;
  public secondarySalesRepIdentifier: string | null;
  public secondarySalesRepCwId: number | null;

  public companyCwId: number | null;
  public companyName: string | null;
  public contactCwId: number | null;
  public contactName: string | null;
  public siteCwId: number | null;
  public siteName: string | null;
  public customerPO: string | null;

  public totalSalesTax: number;
  public probability: number;

  public locationName: string | null;
  public locationCwId: number | null;
  public departmentName: string | null;
  public departmentCwId: number | null;

  public expectedCloseDate: Date | null;
  public pipelineChangeDate: Date | null;
  public dateBecameLead: Date | null;
  public closedDate: Date | null;
  public closedFlag: boolean;
  public closedByName: string | null;
  public closedByCwId: number | null;

  public companyId: string | null;
  public cwLastUpdated: Date | null;
  public cwDateEntered: Date | null;

  public taxCodeDescription: string | null;
  public taxCodeRate: number | null;

  // Local product display order — array of CW forecast item IDs.
  // When non-empty, fetchProducts() uses this instead of CW sequenceNumber.
  public productSequence: number[];

  public readonly createdAt: Date;
  public updatedAt: Date;

  private _company: CompanyController | null = null;
  private _primarySalesRep: UserController | null = null;
  private _secondarySalesRep: UserController | null = null;
  private _siteData: any | null = null;
  private _customFields: CWCustomField[] | null = null;
  private _activities: ActivityController[] | null = null;

  /**
   * Resolve primary sales rep info for quote generation.
   *
   * Looks up the primary sales rep in the DB and returns
   * their name and email. Returns undefined if no rep is assigned.
   */
  private async _resolveSalesRep(): Promise<
    { name: string; email?: string } | undefined
  > {
    if (!this.primarySalesRepIdentifier) return undefined;
    const user = await prisma.user.findFirst({
      where: { cwIdentifier: this.primarySalesRepIdentifier },
      select: {
        firstName: true,
        lastName: true,
        email: true,
        login: true,
      },
    });
    const resolvedName = user
      ? `${user.firstName ?? ""} ${user.lastName ?? ""}`.trim() ||
      user.login ||
      user.email
      : null;
    const name =
      resolvedName ??
      this.primarySalesRepName ??
      this.primarySalesRepIdentifier;
    return {
      name,
      email: user?.email ?? undefined,
    };
  }

  constructor(
    data: Opportunity & {
      company?:
      | (Company & { contacts?: any[]; companyAddresses?: any[] })
      | null;
      primarySalesRep?: (User & { roles: Role[] }) | null;
      secondarySalesRep?: (User & { roles: Role[] }) | null;
    },
    opts?: {
      company?: CompanyController;
      primarySalesRep?: UserController;
      secondarySalesRep?: UserController;
      customFields?: CWCustomField[];
      activities?: ActivityController[];
    }
  ) {
    // New schema: uid is the internal PK (string), id is the CW opportunity ID (Int)
    this.id = data.uid;
    this.cwOpportunityId = data.id;
    this.name = data.name;
    this.notes = data.notes ?? null;

    // Type from included relation (typeId is the CW type Int ID)
    const typeRel = (data as any).type as
      | { id: number; name: string }
      | null
      | undefined;
    this.typeName = typeRel?.name ?? null;
    this.typeCwId = data.typeId ?? null;

    // Stage from included relation
    const stageRel = (data as any).stage as
      | { id: number; name: string }
      | null
      | undefined;
    this.stageName = stageRel?.name ?? null;
    this.stageCwId = stageRel?.id ?? null;

    // Status from included relation
    const statusRel = (data as any).status as
      | { id: number; name: string }
      | null
      | undefined;
    this.statusName = statusRel?.name ?? null;
    this.statusCwId = data.statusId ?? null;

    // Priority and campaign are not stored in new schema
    this.priorityName = (data as any).priorityName ?? null;
    this.priorityCwId = (data as any).priorityCwId ?? null;
    this.ratingName = (data as any).ratingName ?? null;
    this.ratingCwId = (data as any).ratingCwId ?? null;
    this.interest = data.interest ?? null;
    this.campaignName = (data as any).campaignName ?? null;
    this.campaignCwId = (data as any).campaignCwId ?? null;

    this.source = data.source ?? null;

    const primarySalesRepFromData = (data as any).primarySalesRep as
      | (User & { roles: Role[] })
      | null
      | undefined;
    const secondarySalesRepFromData = (data as any).secondarySalesRep as
      | (User & { roles: Role[] })
      | null
      | undefined;

    this._primarySalesRep =
      opts?.primarySalesRep ??
      (primarySalesRepFromData
        ? new UserController(primarySalesRepFromData)
        : null);
    this._secondarySalesRep =
      opts?.secondarySalesRep ??
      (secondarySalesRepFromData
        ? new UserController(secondarySalesRepFromData)
        : null);

    // Sales reps — identifier strings are persisted; names are enriched from included user relations when available.
    this.primarySalesRepIdentifier = (data as any).primarySalesRepIdentifier ?? data.primarySalesRepId ?? null;
    this.primarySalesRepName = this._primarySalesRep?.name ?? (data as any).primarySalesRepName ?? null;
    this.primarySalesRepCwId = (data as any).primarySalesRepCwId ?? null;
    this.secondarySalesRepIdentifier = (data as any).secondarySalesRepIdentifier ?? data.secondarySalesRepId ?? null;
    this.secondarySalesRepName = this._secondarySalesRep?.name ?? (data as any).secondarySalesRepName ?? null;
    this.secondarySalesRepCwId = (data as any).secondarySalesRepCwId ?? null;

    // Company (companyId is the CW company Int ID = Company.id)
    this.companyCwId = (data as any).companyCwId ?? data.companyId ?? null;
    this.companyName = (data as any).companyName ?? data.company?.name ?? null;

    // Contact
    this.contactCwId = (data as any).contactCwId ?? data.contactId ?? null;
    const contactRel = (data as any).contact as
      | { firstName: string; lastName: string }
      | null
      | undefined;
    this.contactName = (data as any).contactName ?? (contactRel
      ? formatOpportunityContactName(contactRel.firstName, contactRel.lastName)
      : null);

    // Site
    this.siteCwId = (data as any).siteCwId ?? data.siteId ?? null;
    this.siteName = (data as any).siteName ?? (data as any).site?.name ?? null;

    this.customerPO = data.customerPO ?? null;
    this.totalSalesTax = (data as any).totalSalesTax ?? 0;
    this.probability = data.probability ?? 0;

    // Location / department from included relations
    this.locationCwId = (data as any).locationCwId ?? data.locationId ?? null;
    this.locationName = (data as any).locationName ?? (data as any).location?.name ?? null;
    this.departmentCwId = (data as any).departmentCwId ?? data.departmentId ?? null;
    this.departmentName = (data as any).departmentName ?? (data as any).department?.name ?? null;

    this.expectedCloseDate = data.expectedCloseDate ?? null;
    this.pipelineChangeDate = data.pipelineChangeDate ?? null;
    this.dateBecameLead = data.dateBecameLead ?? null;
    this.closedDate = data.closedDate ?? null;
    this.closedFlag = data.closedFlag ?? false;
    this.closedByName = data.closedById ?? null;
    this.closedByCwId = null; // not stored in new schema

    // companyId stored as uid string for CompanyController lookups
    this.companyId = data.company?.uid ?? null;
    this.cwLastUpdated = (data as any).cwLastUpdated ?? data.updatedAt ?? null;
    this.cwDateEntered = data.createdAt ?? null;
    this.productSequence = data.productSequence ?? [];

    const taxCodeRel = (data as any).taxCode as
      | { description: string | null; rate: number | null }
      | null
      | undefined;
    this.taxCodeDescription = taxCodeRel?.description ?? null;
    this.taxCodeRate = taxCodeRel?.rate ?? null;

    this.createdAt = data.createdAt;
    this.updatedAt = data.updatedAt;

    this._company =
      opts?.company ??
      (data.company ? new CompanyController(data.company) : null);

    this._customFields = opts?.customFields ?? null;
    this._activities = opts?.activities ?? null;
  }

  /**
   * Hydrate Custom Fields
   *
   * Lazily fetches the opportunity's custom fields from ConnectWise
   * if they haven't been loaded yet.
   */
  private async _hydrateCustomFields(): Promise<void> {
    if (this._customFields !== null) return;
    const record = await opportunityCw.fetch(this.cwOpportunityId);
    this._customFields = record.customFields ?? [];
  }

  /**
   * Fetch Quote Narrative
   */
  public async fetchQuoteNarrative(): Promise<string | null> {
    await this._hydrateCustomFields();
    return (
      this._customFields
        ?.find(
          (field) => field.id === OpportunityController.QUOTE_NARRATIVE_FIELD_ID
        )
        ?.value?.toString() ?? null
    );
  }

  /**
   * Update Quote Narrative
   */
  public async updateQuoteNarrative(
    quoteNarrative: string | null
  ): Promise<string | null> {
    await this._hydrateCustomFields();

    const nextValue = quoteNarrative ?? "";
    const existingFields = [...(this._customFields ?? [])];
    const existingIndex = existingFields.findIndex(
      (field) => field.id === OpportunityController.QUOTE_NARRATIVE_FIELD_ID
    );

    if (existingIndex >= 0) {
      const existingField = existingFields[existingIndex] as CWCustomField;
      existingFields[existingIndex] = {
        ...existingField,
        value: nextValue,
      };
    } else {
      existingFields.push({
        id: OpportunityController.QUOTE_NARRATIVE_FIELD_ID,
        caption: "Quote Narrative",
        type: "Text",
        entryMethod: "EntryField",
        numberOfDecimals: 0,
        value: nextValue,
        connectWiseId: "",
        rowNum: 0,
        userDefinedFieldRecId: OpportunityController.QUOTE_NARRATIVE_FIELD_ID,
        podId: "",
      });
    }

    const updated = await opportunityCw.update(this.cwOpportunityId, {
      customFields: existingFields,
    } as CWOpportunityUpdate);

    this._customFields = updated.customFields ?? existingFields;

    return (
      this._customFields
        ?.find(
          (field) => field.id === OpportunityController.QUOTE_NARRATIVE_FIELD_ID
        )
        ?.value?.toString() ?? null
    );
  }

  /**
   * Fetch Company
   *
   * Lazily loads the associated CompanyController from the database
   * if not already loaded via the Prisma include.
   *
   * @returns {Promise<CompanyController | null>}
   */
  public async fetchCompany(): Promise<CompanyController | null> {
    if (this._company) {
      await this._company.hydrateData();
      return this._company;
    }
    if (!this.companyId) return null;

    const companyData = await prisma.company.findFirst({
      where: { uid: this.companyId },
      include: { contacts: true, companyAddresses: true },
    });

    if (!companyData) return null;

    this._company = new CompanyController(companyData);
    await this._company.hydrateData();
    return this._company;
  }

  /**
   * Refresh from ConnectWise
   *
   * Fetches the latest opportunity data from CW and updates
   * the local database record and controller state.
   */
  public async refreshFromCW(): Promise<OpportunityController> {
    const record = await prisma.opportunity.findFirst({
      where: { uid: this.id },
      include: {
        company: { include: { contacts: true, companyAddresses: true } },
        contact: true,
        type: true,
        status: true,
        site: true,
        primarySalesRep: { include: { roles: true } },
        secondarySalesRep: { include: { roles: true } },
        stage: true,
        taxCode: true,
      },
    });

    if (!record) {
      throw new GenericError({
        status: 404,
        name: "OpportunityNotFound",
        message: "Opportunity not found",
      });
    }

    return new OpportunityController(record, {
      company: record.company
        ? new CompanyController(record.company)
        : undefined,
      primarySalesRep: record.primarySalesRep
        ? new UserController(record.primarySalesRep)
        : undefined,
      secondarySalesRep: record.secondarySalesRep
        ? new UserController(record.secondarySalesRep)
        : undefined,
    });
  }

  /**
   * Update Opportunity
   *
   * Patches the opportunity in ConnectWise with the provided fields,
   * then syncs the updated data back to the local database.
   *
   * @param data — Partial fields to update on the CW opportunity
   * @returns A fresh OpportunityController with the updated data
   */
  public async updateOpportunity(
    data: CWOpportunityUpdate & { interest?: OpportunityInterest | null }
  ): Promise<OpportunityController> {
    const { interest, ...cwPatch } = data;
    const hasCwPatch = Object.keys(cwPatch).length > 0;
    const cwMapped = hasCwPatch
      ? OpportunityController.mapCwToDb(
        await opportunityCw.update(this.cwOpportunityId, cwPatch)
      )
      : {};

    const mapped =
      interest !== undefined ? { ...cwMapped, interest } : cwMapped;

    const updated = await prisma.opportunity.update({
      where: { uid: this.id },
      data: mapped,
      include: {
        company: { include: { contacts: true, companyAddresses: true } },
        contact: true,
        type: true,
        status: true,
        site: true,
        primarySalesRep: { include: { roles: true } },
        secondarySalesRep: { include: { roles: true } },
        stage: true,
        taxCode: true,
      },
    });

    return new OpportunityController(updated, {
      company: updated.company
        ? new CompanyController(updated.company)
        : undefined,
      primarySalesRep: updated.primarySalesRep
        ? new UserController(updated.primarySalesRep)
        : undefined,
      secondarySalesRep: updated.secondarySalesRep
        ? new UserController(updated.secondarySalesRep)
        : undefined,
    });
  }

  /**
   * Fetch raw CW data
   *
   * Returns the raw ConnectWise opportunity object without updating the DB.
   */
  public async fetchCwData(): Promise<CWOpportunity> {
    throw new GenericError({
      status: 501,
      name: "NotSupported",
      message:
        "Direct CW data fetch is no longer supported — data comes from local DB",
    });
  }

  /**
   * Map CW Opportunity → Prisma create/update payload
   *
   * Static helper used by both the controller and the refresh sync.
   */
  public static mapCwToDb(item: CWOpportunity) {
    return {
      name: item.name,
      notes: item.notes ?? null,

      // typeId references OpportunityType.id (the CW type Int ID)
      typeId: item.type?.id,
      stageId: item.stage?.id ?? null,

      // statusId references OpportunityStatus.id (the CW status Int ID)
      statusId: item.status?.id ?? null,

      interest: mapRatingNameToInterest(item.rating?.name),
      ratingName: item.rating?.name ?? null,
      ratingCwId: item.rating?.id ?? null,

      source: item.source ?? null,
      campaignName: item.campaign?.name ?? null,

      // Sales reps stored as identifier strings (FK to User.cwIdentifier)
      primarySalesRepId: item.primarySalesRep?.identifier ?? null,
      primarySalesRepName: item.primarySalesRep?.name ?? null,
      primarySalesRepIdentifier: item.primarySalesRep?.identifier ?? null,
      secondarySalesRepId: item.secondarySalesRep?.identifier ?? null,
      secondarySalesRepName: item.secondarySalesRep?.name ?? null,
      secondarySalesRepIdentifier: item.secondarySalesRep?.identifier ?? null,

      // Relation IDs — CW IDs match the local DB Int IDs
      companyId: item.company?.id ?? null,
      contactId: item.contact?.id ?? null,
      siteId: item.site?.id ?? null,
      customerPO: item.customerPO ?? null,

      probability: normalizeProbabilityPercent(item.probability?.name),

      locationId: item.location?.id ?? null,
      departmentId: item.department?.id ?? null,

      expectedCloseDate: item.expectedCloseDate
        ? new Date(item.expectedCloseDate)
        : null,
      pipelineChangeDate: item.pipelineChangeDate
        ? new Date(item.pipelineChangeDate)
        : null,
      dateBecameLead: item.dateBecameLead
        ? new Date(item.dateBecameLead)
        : null,
      closedDate: item.closedDate ? new Date(item.closedDate) : null,
      closedFlag: item.closedFlag ?? false,
      closedById: item.closedBy?.identifier ?? item.closedBy?.name ?? null,

      updatedBy: item._info?.updatedBy ?? "",
      eneteredBy: item._info?.enteredBy ?? "",
      cwLastUpdated: item._info?.lastUpdated
        ? new Date(item._info.lastUpdated)
        : null,
    };
  }

  /**
   * Fetch Site
   *
   * Fetches the site details from the local database.
   * Uses the CompanyAddress table.
   *
   * @returns Serialized site object or null
   */
  public async fetchSite() {
    if (this._siteData) return this._siteData;
    if (!this.companyCwId || !this.siteCwId) return null;

    const site = await prisma.companyAddress.findFirst({
      where: { id: this.siteCwId, companyId: this.companyCwId },
    });

    if (!site) return null;

    this._siteData = {
      id: site.id,
      name: site.name,
      address: {
        line1: site.addressLine1,
        line2: site.addressLine2,
        city: site.city,
        state: site.state,
        zip: site.zipCode,
        country: site.country,
      },
      phone: site.phone,
      fax: site.fax,
      defaultFlag: site.defaultFlag,
      inactiveFlag: site.inactiveFlag,
    };
    return this._siteData;
  }

  /**
   * Fetch Contacts
   *
   * Returns the primary contact for this opportunity from the local database.
   * TODO: Expand when OpportunityContact junction table is added.
   */
  public async fetchContacts(opts?: { fresh?: boolean }) {
    // Currently only the primary contact is stored on the opportunity
    if (!this.contactCwId) return [];

    const contact = await prisma.contact.findFirst({
      where: { id: this.contactCwId },
      include: { company: true },
    });

    if (!contact) return [];

    return [
      {
        id: contact.id,
        contact: {
          id: contact.id,
          name: formatOpportunityContactName(contact.firstName, contact.lastName),
        },
        company: contact.company
          ? {
            id: contact.company.id,
            identifier: null,
            name: contact.company.name,
          }
          : null,
        role: null,
        notes: null,
        referralFlag: false,
      },
    ];
  }

  /** Serialize raw contact data into the API response shape. */
  private _serializeContacts(contacts: any[]) {
    return contacts.map((ct: any) => ({
      id: ct.id,
      contact: ct.contact ? { id: ct.contact.id, name: ct.contact.name } : null,
      company: ct.company
        ? {
          id: ct.company.id,
          identifier: ct.company.identifier,
          name: ct.company.name,
        }
        : null,
      role: ct.role ? { id: ct.role.id, name: ct.role.name } : null,
      notes: ct.notes,
      referralFlag: ct.referralFlag,
    }));
  }

  /**
   * Fetch Notes
   *
   * Returns notes for this opportunity.
   * Currently returns the single notes field; TODO: expand when OpportunityNote table is added.
   */
  public async fetchNotes(opts?: { fresh?: boolean }) {
    // The Opportunity model has a single 'notes' field, not a collection
    // Return it as a single-item array for now
    if (!this.notes) return [];

    return [
      {
        id: 1,
        text: this.notes,
        type: null,
        flagged: false,
        dateEntered: this.cwLastUpdated,
        enteredBy: null,
      },
    ];
  }

  /** Serialize raw note data into the API response shape. */
  private async _serializeNotes(notes: any[]) {
    const identifiers = notes
      .map((n: any) => n.enteredBy as string)
      .filter(Boolean);
    const memberMap = await resolveMembers(identifiers);

    return notes.map((n: any) => ({
      id: n.id,
      text: n.text,
      type: n.type ? { id: n.type.id, name: n.type.name } : null,
      flagged: n.flagged,
      dateEntered: n._info?.lastUpdated ? new Date(n._info.lastUpdated) : null,
      enteredBy: memberMap.get(n.enteredBy) ?? {
        id: null,
        identifier: n.enteredBy,
        name: n.enteredBy,
        cwMemberId: null,
      },
    }));
  }

  /**
   * Fetch Single Note
   *
   * Fetches a single note by its ID.
   * TODO: Implement when OpportunityNote table is added.
   */
  public async fetchNote(noteId: number) {
    // Currently not supported - notes are stored as a single text field
    throw new GenericError({
      message:
        "Individual note fetch not supported - notes are synced via dalpuri",
      name: "NotSupported",
      status: 501,
    });
  }

  /**
   * Fetch Activities
   *
   * Returns activities for this opportunity.
   * TODO: Implement when Activity table is synced via dalpuri.
   */
  public async fetchActivities(): Promise<ActivityController[]> {
    if (this._activities) return this._activities;

    // Activities are not yet synced to local DB - return empty for now
    this._activities = [];
    return this._activities;
  }

  /**
   * Fetch Products
   *
   * Fetches products (forecast/revenue items) for this opportunity from
   * the local ProductData table synced via dalpuri.
   *
   * @param opts.fresh - Ignored (kept for API compatibility)
   */
  public async fetchProducts(opts?: {
    fresh?: boolean;
  }): Promise<ForecastProductController[]> {
    // Intentionally ignored; retained for API compatibility with legacy callers.
    void opts;

    const rows = await prisma.productData.findMany({
      where: { opportunityId: this.cwOpportunityId },
      include: {
        catalogItem: {
          select: {
            id: true,
            identifier: true,
            name: true,
            customerDescription: true,
            onHand: true,
            classId: true,
          },
        },
      },
    });

    let ordered = rows;
    if (this.productSequence.length > 0) {
      const byId = new Map(rows.map((row) => [row.id, row]));
      const sequenced = this.productSequence
        .map((id) => byId.get(id))
        .filter((row): row is NonNullable<typeof row> => row !== undefined);
      const sequencedIds = new Set(this.productSequence);
      const unsequenced = rows
        .filter((row) => !sequencedIds.has(row.id))
        .sort(
          (a, b) =>
            (a.sequenceNumber ?? Number.MAX_SAFE_INTEGER) -
            (b.sequenceNumber ?? Number.MAX_SAFE_INTEGER)
        );
      ordered = [...sequenced, ...unsequenced];
    } else {
      ordered = [...rows].sort(
        (a, b) =>
          (a.sequenceNumber ?? Number.MAX_SAFE_INTEGER) -
          (b.sequenceNumber ?? Number.MAX_SAFE_INTEGER)
      );
    }

    return ordered.map((row) => {
      const quantity = Math.max(0, row.qty ?? 0);
      const revenue = Number(((row.unitPrice ?? 0) * quantity).toFixed(2));
      const cost = Number(((row.unitCost ?? 0) * quantity).toFixed(2));
      const margin = Number((revenue - cost).toFixed(2));
      const percentage =
        revenue > 0 ? Number(((margin / revenue) * 100).toFixed(2)) : 0;

      const cwForecastLike: CWForecastItem = {
        id: row.id,
        forecastDescription:
          row.shortDescription?.trim() ||
          row.catalogItem?.name ||
          row.catalogItem?.identifier ||
          `Product ${row.id}`,
        opportunity: {
          id: this.cwOpportunityId,
          name: this.name,
        },
        quantity,
        status: {
          id: 0,
          name: row.cancelFlag ? "Cancelled" : "Active",
        },
        catalogItem: {
          id: row.catalogItem.id,
          identifier: row.catalogItem.identifier ?? row.catalogItem.name,
        },
        productDescription:
          row.shortDescription?.trim() ||
          row.catalogItem?.name ||
          row.catalogItem?.identifier ||
          "Line Item",
        customerDescription: row.catalogItem.customerDescription ?? undefined,
        productClass: row.catalogItem.classId === "S" ? "Service" : (row.recurringFlag ? "Service" : "Product"),
        revenue,
        cost,
        margin,
        percentage,
        includeFlag: !row.cancelFlag,
        quoteWerksQuantity: 0,
        forecastType: row.recurringFlag ? "Recurring" : "Product",
        linkFlag: false,
        recurringRevenue: row.recurringRevenue ?? 0,
        recurringCost: row.recurringCost ?? 0,
        cycles: 0,
        recurringFlag: row.recurringFlag,
        sequenceNumber: row.sequenceNumber ?? 0,
        subNumber: 0,
        taxableFlag: row.taxableFlag,
        procurementNotes: row.procurementNotes ?? null,
        productNarrative: row.productNarrative ?? null,
        customFields: undefined,
        _info: {
          lastUpdated: row.updatedAt.toISOString(),
          updatedBy: row.updatedById ?? "",
        },
      };

      const ctrl = new ForecastProductController(cwForecastLike);

      ctrl.applyCancellationData({
        cancelledFlag: row.cancelFlag,
        quantityCancelled: row.cancelQty ?? 0,
        cancelledReason: row.cancelReason ?? undefined,
        cancelledDate: row.cancelledAt?.toISOString(),
      });

      if (typeof row.catalogItem.onHand === "number") {
        ctrl.applyInventoryData({ onHand: row.catalogItem.onHand });
      }

      ctrl.applyPricingData({
        unitPrice: row.unitPrice ?? 0,
        listPrice: row.listPrice ?? 0,
        discount: row.discount ?? 0,
      });

      ctrl.description = row.description ?? null;

      return ctrl;
    });
  }

  /**
   * Generate Quote PDF
   *
   * Builds a customer-facing quote PDF using the opportunity, company, site,
   * and product data available to this controller.
   */
  public async generateQuote(opts?: {
    lineItemPricing?: boolean;
    includeQuoteNarrative?: boolean;
    includeItemNarratives?: boolean;
    separateRecurringServices?: boolean;
    showPreview?: boolean; // INTERNAL ONLY
    logoPath?: string;
    metadata?: QuoteMetadata;
  }): Promise<Buffer> {
    const options = {
      lineItemPricing: opts?.lineItemPricing ?? true,
      includeQuoteNarrative: opts?.includeQuoteNarrative ?? true,
      includeItemNarratives: opts?.includeItemNarratives ?? true,
      separateRecurringServices: opts?.separateRecurringServices ?? true,
      showPreview: opts?.showPreview ?? false,
      logoPath: opts?.logoPath,
    };

    const products = await this.fetchProducts();
    const activeProducts = products.filter(
      (item) => item.includeFlag && item.cancellationType !== "full"
    );

    if (activeProducts.length === 0) {
      throw new GenericError({
        status: 400,
        name: "QuoteGenerationError",
        message: "Cannot generate a quote with no included line items",
      });
    }

    const company = await this.fetchCompany();
    const companyJson = company?.toJson({
      includeAddress: true,
      includePrimaryContact: true,
      includeAllContacts: false,
    });
    const site = await this.fetchSite();

    const siteAddress = [
      site?.address?.line1,
      site?.address?.line2,
      [site?.address?.city, site?.address?.state, site?.address?.zip]
        .filter(Boolean)
        .join(" "),
    ].filter(Boolean) as string[];

    const companyAddress = [
      companyJson?.cw_Data?.address?.line1,
      companyJson?.cw_Data?.address?.line2,
      [
        companyJson?.cw_Data?.address?.city,
        companyJson?.cw_Data?.address?.state,
        companyJson?.cw_Data?.address?.zip,
      ]
        .filter(Boolean)
        .join(" "),
    ].filter(Boolean) as string[];

    const addressLines = siteAddress.length > 0 ? siteAddress : companyAddress;

    const lineItems = activeProducts.map((item) => {
      const isLabor = item.productClass === "Service";
      const quantity = item.effectiveQuantity > 0 ? item.effectiveQuantity : 1;
      const lineTotal = Number.isFinite(item.revenue) ? item.revenue : 0;
      const unitPrice = isLabor ? lineTotal : lineTotal / quantity;

      const itemNarrative = item.productNarrative || null;

      const shouldIncludeNarrative =
        options.includeItemNarratives && !!itemNarrative;

      return {
        qty: isLabor ? 1 : quantity,
        description:
          item.description || item.customerDescription || item.productDescription || "Line Item",
        unitPrice,
        narrative: shouldIncludeNarrative ? itemNarrative : undefined,
        isRecurring: options.separateRecurringServices
          ? item.catalogItemIdentifier?.startsWith("RSV") ?? false
          : false,
      };
    });

    const taxableSubTotal = activeProducts.reduce((sum, item) => {
      if (!item.taxableFlag) return sum;

      const isLabor = item.productClass === "Service";
      const quantity = item.effectiveQuantity > 0 ? item.effectiveQuantity : 1;
      const lineTotal = Number.isFinite(item.revenue) ? item.revenue : 0;
      const unitPrice = isLabor ? lineTotal : lineTotal / quantity;

      return sum + (isLabor ? 1 : quantity) * unitPrice;
    }, 0);

    const quoteDescription = this.name;

    const primaryContactFullName = [
      companyJson?.cw_Data?.primaryContact?.firstName,
      companyJson?.cw_Data?.primaryContact?.lastName,
    ]
      .filter(Boolean)
      .join(" ")
      .trim();
    const customerName =
      this.contactName ||
      primaryContactFullName ||
      this.companyName ||
      "Customer";

    const normalizedTaxRate = this.taxCodeRate ?? 0;
    const taxDescription = this.taxCodeDescription ?? "Sales Tax";
    const taxLabel =
      normalizedTaxRate > 0
        ? `Tax (${taxDescription} ${(normalizedTaxRate * 100).toFixed(2)}%)`
        : `Tax (${taxDescription})`;

    await this._hydrateCustomFields();

    const quoteNarrativeField = options.includeQuoteNarrative
      ? this._customFields?.find((f) => f.id === 35)?.value?.toString() ||
      undefined
      : undefined;

    // Fall back to the customerDescription of a QUO-Narrative product
    const quoNarrativeProduct = !quoteNarrativeField
      ? activeProducts.find((p) => p.catalogItemIdentifier === "QUO-Narrative")
      : undefined;
    const quoteNarrative =
      quoteNarrativeField ??
      quoNarrativeProduct?.customerDescription ??
      undefined;

    const companyLine = this.companyName ?? company?.name ?? "Customer Company";

    // Only show attention if it differs from the customer name
    const attention =
      this.contactName && this.contactName !== customerName
        ? this.contactName
        : undefined;

    // Only show company if it's meaningfully different from the customer name
    // (catches "Patterson, Diane" vs "Diane Patterson" style duplicates)
    const normalise = (s: string) =>
      s
        .toLowerCase()
        .replace(/[,.\s]+/g, " ")
        .trim()
        .split(" ")
        .sort()
        .join(" ");
    const showCompany = normalise(companyLine) !== normalise(customerName);

    const quotePayload = {
      customer: {
        name: customerName,
        company: showCompany ? companyLine : undefined,
        attention,
        address:
          addressLines.length > 0 ? addressLines : ["Address unavailable"],
      },
      contact: {
        email: companyJson?.cw_Data?.primaryContact?.email ?? undefined,
        phone: companyJson?.cw_Data?.primaryContact?.phone ?? undefined,
      },
      salesRep: await this._resolveSalesRep(),
      quote: {
        quoteNumber: this.cwOpportunityId.toString(),
        date: new Date().toLocaleDateString("en-US", {
          year: "numeric",
          month: "long",
          day: "numeric",
          timeZone: "America/New_York",
        }),
        description: quoteDescription,
      },
      lineItems,
      taxableSubtotal: taxableSubTotal,
      quoteNarrative,
      tax: {
        rate: normalizedTaxRate,
        label: taxLabel,
      },
      isPreview: options.showPreview,
      showLineItemPricing: options.lineItemPricing,
      separateRecurringServices: options.separateRecurringServices,
      metadata: opts?.metadata,
    };

    try {
      return await generateQuotePdf(quotePayload, {}, options.logoPath);
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      if (message.includes("urlResolver.resolve")) {
        console.warn(
          "[generateQuote] Logo resolution failed, retrying without logo",
          { error: message }
        );
        return await generateQuotePdf(quotePayload, {}, "");
      }

      throw err;
    }
  }

  /**
   * Commit Quote
   *
   * Generates a non-preview quote PDF and stores it in the GeneratedQuotes
   * table with a full data snapshot for exact reproduction, regeneration
   * metadata, and creator attribution.
   */
  public async commitQuote(
    opts: {
      lineItemPricing?: boolean;
      includeQuoteNarrative?: boolean;
      includeItemNarratives?: boolean;
      separateRecurringServices?: boolean;
      logoPath?: string;
    } = {},
    user: UserController
  ) {
    const quoteOptions = {
      lineItemPricing: opts?.lineItemPricing ?? true,
      includeQuoteNarrative: opts?.includeQuoteNarrative ?? true,
      includeItemNarratives: opts?.includeItemNarratives ?? true,
      separateRecurringServices: opts?.separateRecurringServices ?? true,
      logoPath: opts?.logoPath,
    };

    // ── Fetch all data sources BEFORE generating ──────────────────────
    const products = await this.fetchProducts();
    const company = await this.fetchCompany();
    const companyJson = company?.toJson({
      includeAddress: true,
      includePrimaryContact: true,
      includeAllContacts: false,
    });
    const site = await this.fetchSite();
    const salesRep = await this._resolveSalesRep();
    await this._hydrateCustomFields();

    const quoteNarrativeField = quoteOptions.includeQuoteNarrative
      ? this._customFields?.find((f) => f.id === 35)?.value?.toString() ?? null
      : null;

    // Fall back to the customerDescription of a QUO-Narrative product
    const quoNarrativeProduct = !quoteNarrativeField
      ? products.find((p) => p.catalogItemIdentifier === "QUO-Narrative")
      : undefined;
    const quoteNarrative =
      quoteNarrativeField ?? quoNarrativeProduct?.customerDescription ?? null;

    // ── Pre-generate IDs & timestamps for metadata ───────────────────
    const quoteId = crypto.randomUUID();
    const createdAt = new Date().toISOString();

    // ── Generate the PDF ──────────────────────────────────────────────
    const quoteBuffer = await this.generateQuote({
      ...quoteOptions,
      showPreview: false,
      metadata: {
        quoteId,
        createdById: user.id,
        createdByName: user.name ?? undefined,
        createdByEmail: user.email ?? undefined,
        createdAt,
      },
    });

    const fileTimestamp = createdAt.replace(/[:.]/g, "-");
    const quoteFileName = `OPP-${this.cwOpportunityId}-${fileTimestamp}.pdf`;

    // ── Build the full data snapshot ──────────────────────────────────
    const siteAddress = [
      site?.address?.line1,
      site?.address?.line2,
      [site?.address?.city, site?.address?.state, site?.address?.zip]
        .filter(Boolean)
        .join(" "),
    ].filter(Boolean) as string[];

    const companyAddress = [
      companyJson?.cw_Data?.address?.line1,
      companyJson?.cw_Data?.address?.line2,
      [
        companyJson?.cw_Data?.address?.city,
        companyJson?.cw_Data?.address?.state,
        companyJson?.cw_Data?.address?.zip,
      ]
        .filter(Boolean)
        .join(" "),
    ].filter(Boolean) as string[];

    const primaryContactFullName = [
      companyJson?.cw_Data?.primaryContact?.firstName,
      companyJson?.cw_Data?.primaryContact?.lastName,
    ]
      .filter(Boolean)
      .join(" ")
      .trim();

    const regenData = {
      // Generation options
      options: {
        lineItemPricing: quoteOptions.lineItemPricing,
        includeQuoteNarrative: quoteOptions.includeQuoteNarrative,
        includeItemNarratives: quoteOptions.includeItemNarratives,
        separateRecurringServices: quoteOptions.separateRecurringServices,
      },

      // Opportunity metadata
      opportunity: {
        id: this.id,
        cwOpportunityId: this.cwOpportunityId,
        name: this.name,
        totalSalesTax: this.totalSalesTax,
        contactName: this.contactName,
        companyName: this.companyName,
      },

      // Customer / company / site snapshot
      customer: {
        preparedFor:
          this.contactName ||
          primaryContactFullName ||
          this.companyName ||
          "Customer",
        companyName: this.companyName ?? company?.name ?? null,
        primaryContact: companyJson?.cw_Data?.primaryContact
          ? {
            firstName: companyJson.cw_Data.primaryContact.firstName ?? null,
            lastName: companyJson.cw_Data.primaryContact.lastName ?? null,
            email: companyJson.cw_Data.primaryContact.email ?? null,
            phone: companyJson.cw_Data.primaryContact.phone ?? null,
          }
          : null,
        siteAddress: siteAddress.length > 0 ? siteAddress : null,
        companyAddress: companyAddress.length > 0 ? companyAddress : null,
      },

      // Sales rep snapshot
      salesRep: salesRep ?? null,

      // Quote narrative
      quoteNarrative: quoteNarrative ?? null,

      // Full product snapshot
      products: products.map((p) => ({
        cwForecastId: p.cwForecastId,
        forecastDescription: p.forecastDescription,
        productDescription: p.productDescription,
        customerDescription: p.customerDescription,
        productNarrative: p.productNarrative,
        productClass: p.productClass,
        forecastType: p.forecastType,
        catalogItem: p.catalogItemCwId
          ? { id: p.catalogItemCwId, identifier: p.catalogItemIdentifier }
          : null,
        quantity: p.quantity,
        effectiveQuantity: p.effectiveQuantity,
        revenue: p.revenue,
        cost: p.cost,
        margin: p.margin,
        percentage: p.percentage,
        includeFlag: p.includeFlag,
        taxableFlag: p.taxableFlag,
        recurringFlag: p.recurringFlag,
        recurringRevenue: p.recurringRevenue,
        recurringCost: p.recurringCost,
        sequenceNumber: p.sequenceNumber,
        cancelledFlag: p.cancelledFlag,
        cancellationType: p.cancellationType,
        quantityCancelled: p.quantityCancelled,
        cancelledReason: p.cancelledReason,
        cancelledDate: p.cancelledDate,
      })),

      // Timestamp of when this snapshot was taken
      snapshotTimestamp: new Date().toISOString(),
    };

    const regenParams = {
      opportunityId: this.id,
      cwOpportunityId: this.cwOpportunityId,
    };

    const hasher = new Bun.CryptoHasher("sha256");
    hasher.update(JSON.stringify({ regenData, regenParams }));
    const quoteRegenHash = hasher.digest("hex");

    return generatedQuotes.create({
      id: quoteId,
      quoteRegenData: regenData,
      quoteRegenParams: regenParams,
      quoteRegenHash,
      quoteFile: quoteBuffer,
      quoteFileName,
      opportunityId: this.id,
      createdById: user.id,
    });
  }

  /**
   * Build ForecastProductController[] from raw CW data.
   *
   * Extracted from fetchProducts() so both cached and fresh paths
   * share the same ordering + enrichment logic.
   */
  private async _buildProductControllers(
    forecast: any,
    procProducts: any[]
  ): Promise<ForecastProductController[]> {
    // Build a map of forecastDetailId → procurement product cancellation data
    const cancellationMap = new Map<number, Record<string, unknown>>();
    for (const pp of procProducts) {
      const rawForecastDetailId = (pp as any)?.forecastDetailId;
      const forecastDetailId =
        typeof rawForecastDetailId === "number"
          ? rawForecastDetailId
          : Number(rawForecastDetailId);

      if (Number.isFinite(forecastDetailId) && forecastDetailId > 0) {
        cancellationMap.set(forecastDetailId, pp);
      }
    }

    // Procurement-only view: only include forecast items that have a
    // matching procurement record (via forecastDetailId).
    const forecastItems = (forecast.forecastItems ?? []).filter((fi: any) =>
      cancellationMap.has(fi.id)
    );

    // Apply local ordering if productSequence is set, otherwise fall back
    // to CW sequenceNumber.
    let ordered: typeof forecastItems;

    if (this.productSequence.length > 0) {
      const itemById = new Map(forecastItems.map((fi: any) => [fi.id, fi]));
      // Items in the specified order first, then any new items not yet sequenced
      const sequenced = this.productSequence
        .map((id) => itemById.get(id))
        .filter((fi: any): fi is NonNullable<typeof fi> => fi !== undefined);
      const sequencedIds = new Set(this.productSequence);
      const unsequenced = forecastItems
        .filter((fi: any) => !sequencedIds.has(fi.id))
        .sort((a: any, b: any) => a.sequenceNumber - b.sequenceNumber);
      ordered = [...sequenced, ...unsequenced];
    } else {
      ordered = [...forecastItems].sort(
        (a: any, b: any) => a.sequenceNumber - b.sequenceNumber
      );
    }

    const controllers: ForecastProductController[] = ordered.map(
      (item: any) => {
        const ctrl = new ForecastProductController(item);
        const procData = cancellationMap.get(item.id);
        if (procData) {
          ctrl.applyCancellationData(procData as any);
          ctrl.applyProcurementCustomFields(procData as any);
        }
        return ctrl;
      }
    );

    // Enrich with internal inventory data from local CatalogItem DB
    const catalogCwIds = controllers
      .map((c) => c.catalogItemCwId)
      .filter((id): id is number => id !== null);

    if (catalogCwIds.length > 0) {
      const catalogItems = await prisma.catalogItem.findMany({
        where: { id: { in: catalogCwIds } },
        select: { id: true, onHand: true },
      });
      const inventoryMap = new Map(catalogItems.map((ci) => [ci.id, ci]));
      for (const ctrl of controllers) {
        const inv = ctrl.catalogItemCwId
          ? inventoryMap.get(ctrl.catalogItemCwId)
          : undefined;
        if (inv) ctrl.applyInventoryData(inv);
      }
    }

    return controllers;
  }

  // ---------------------------------------------------------------------------
  // Opportunity Activity / Workflow Methods
  // ---------------------------------------------------------------------------

  /**
   * Update Product
   *
   * Updates an existing product/line item on this opportunity via PATCH.
   *
   * @param forecastItemId - The CW forecast item ID to update
   * @param data - Key/value pairs to patch
   */
  public async updateProduct(
    forecastItemId: number,
    data: Record<string, unknown>
  ): Promise<ForecastProductController> {
    try {
      const updated = await opportunityCw.updateProduct(
        this.cwOpportunityId,
        forecastItemId,
        data
      );
      // No cache invalidation needed
      return new ForecastProductController(updated);
    } catch (err: any) {
      console.error(
        `[updateProduct] Failed to patch forecast item ${forecastItemId} on opportunity ${this.cwOpportunityId}`,
        JSON.stringify(
          {
            data,
            status: err?.response?.status,
            statusText: err?.response?.statusText,
            responseData: err?.response?.data,
            message: err?.message,
          },
          null,
          2
        )
      );
      throw err;
    }
  }

  /**
   * Resequence Products
   *
   * Stores the desired display order of forecast item IDs locally in
   * the database. No CW API calls are made — CW item IDs are stable
   * and ordering is applied when `fetchProducts()` is called.
   *
   * @param orderedIds - Forecast item IDs in the desired display order
   */
  public async resequenceProducts(
    orderedIds: number[]
  ): Promise<ForecastProductController[]> {
    // Validate all IDs exist in the local ProductData table (the IDs the UI works with).
    // Fall back to productSequence for items that were just added and haven't been
    // synced to productData yet — appendProductSequenceIds writes them immediately.
    const existingRows = await prisma.productData.findMany({
      where: { opportunityId: this.cwOpportunityId },
      select: { id: true },
    });
    const existingIds = new Set(existingRows.map((r) => r.id));
    const sequenceIds = new Set(this.productSequence);
    for (const id of orderedIds) {
      if (!existingIds.has(id) && !sequenceIds.has(id)) {
        throw new GenericError({
          status: 404,
          name: "ForecastItemNotFound",
          message: `Forecast item ${id} not found on opportunity ${this.cwOpportunityId}`,
        });
      }
    }

    // Persist the sequence locally
    await prisma.opportunity.update({
      where: { uid: this.id },
      data: { productSequence: orderedIds },
    });
    this.productSequence = orderedIds;

    // Invalidate cached products since ordering changed
    // No cache invalidation needed

    // Return items in the new order
    return this.fetchProducts();
  }

  /**
   * Append Product Sequence IDs
   *
   * Adds newly created forecast item IDs to the end of the local
   * productSequence array, preserving existing order and avoiding duplicates.
   */
  private async appendProductSequenceIds(ids: number[]): Promise<void> {
    const normalizedIds = ids.filter(
      (id): id is number => Number.isInteger(id) && id > 0
    );
    if (normalizedIds.length === 0) return;

    const current = await prisma.opportunity.findUnique({
      where: { uid: this.id },
      select: { productSequence: true },
    });

    const existing = current?.productSequence ?? [];
    const existingSet = new Set(existing);
    const idsToAppend = normalizedIds.filter((id) => !existingSet.has(id));
    if (idsToAppend.length === 0) {
      this.productSequence = existing;
      return;
    }

    const updatedSequence = [...existing, ...idsToAppend];

    await prisma.opportunity.update({
      where: { uid: this.id },
      data: { productSequence: updatedSequence },
    });

    this.productSequence = updatedSequence;
  }

  /**
   * Add Products
   *
   * Adds one or more products/line items to this opportunity via the
   * ConnectWise forecast endpoint. The caller passes only the fields
   * the user is permitted to set (already filtered by field-level
   * permission gating in the route handler).
   *
   * Accepts a single item or an array of items.
   */
  public async addProducts(
    data: CWForecastItemCreate | CWForecastItemCreate[]
  ): Promise<ForecastProductController[]> {
    try {
      const created = await opportunityCw.createProducts(
        this.cwOpportunityId,
        data
      );
      await this.appendProductSequenceIds(created.map((item) => item.id));
      // No cache invalidation needed
      return created.map((item) => new ForecastProductController(item));
    } catch (err: any) {
      console.error(
        `[addProducts] Failed to create forecast item(s) on opportunity ${this.cwOpportunityId}`,
        JSON.stringify(
          {
            data,
            status: err?.response?.status,
            statusText: err?.response?.statusText,
            responseData: err?.response?.data,
            message: err?.message,
          },
          null,
          2
        )
      );
      throw new GenericError({
        status: err?.response?.status ?? 500,
        name: "AddProductFailed",
        message:
          err?.response?.data?.message ??
          "Failed to add product(s) to opportunity",
        cause: err?.message,
      });
    }
  }

  /**
   * Add Procurement Products
   *
   * Creates one or more procurement products linked to this opportunity.
   * Use this when payloads include procurement-only fields such as customFields.
   */
  public async addProcurementProducts(
    data: CWProcurementProductCreate | CWProcurementProductCreate[]
  ): Promise<CWProcurementProduct[]> {
    try {
      const items = Array.isArray(data) ? data : [data];
      const normalized = items.map((item) => ({
        ...item,
        opportunity: { id: this.cwOpportunityId },
      }));

      const created = await opportunityCw.createProcurementProducts(normalized);
      await this.appendProductSequenceIds(
        created
          .map((item) => item.forecastDetailId)
          .filter((id): id is number => typeof id === "number")
      );
      // No cache invalidation needed
      return created;
    } catch (err: any) {
      console.error(
        `[addProcurementProducts] Failed to create procurement product(s) on opportunity ${this.cwOpportunityId}`,
        JSON.stringify(
          {
            data,
            status: err?.response?.status,
            statusText: err?.response?.statusText,
            responseData: err?.response?.data,
            message: err?.message,
          },
          null,
          2
        )
      );
      throw new GenericError({
        status: err?.response?.status ?? 500,
        name: "AddProcurementProductFailed",
        message:
          err?.response?.data?.message ??
          "Failed to add procurement product(s) to opportunity",
        cause: err?.message,
      });
    }
  }

  /**
   * Delete Product
   *
   * Removes a forecast item from this opportunity in ConnectWise,
   * removes the item ID from the local productSequence, and
   * invalidates the products cache.
   *
   * @param forecastItemId - The CW forecast item ID to delete
   */
  public async deleteProduct(forecastItemId: number): Promise<void> {
    await opportunityCw.deleteProduct(this.cwOpportunityId, forecastItemId);

    // Remove the deleted item from the local ProductData table
    await prisma.productData.deleteMany({
      where: { id: forecastItemId, opportunityId: this.cwOpportunityId },
    });

    // Remove the deleted item from the local product sequence
    if (this.productSequence.includes(forecastItemId)) {
      const updatedSequence = this.productSequence.filter(
        (id) => id !== forecastItemId
      );

      await prisma.opportunity.update({
        where: { uid: this.id },
        data: { productSequence: updatedSequence },
      });

      this.productSequence = updatedSequence;
    }

    return;
  }

  /**
   * Fetch Procurement Product By Forecast Item
   *
   * Returns the linked procurement product for a forecast item ID,
   * or null when no procurement record exists.
   */
  public async fetchProcurementProductByForecastItem(
    forecastItemId: number
  ): Promise<CWProcurementProduct | null> {
    return opportunityCw.fetchProcurementProductByForecastDetail(
      this.cwOpportunityId,
      forecastItemId
    );
  }

  /**
   * Update Procurement Product By Forecast Item
   *
   * Finds the linked procurement product for a forecast item and updates it.
   * Returns null when no linked procurement product exists.
   */
  public async updateProcurementProductByForecastItem(
    forecastItemId: number,
    data: Record<string, unknown>
  ): Promise<CWProcurementProduct | null> {
    const linked = await this.fetchProcurementProductByForecastItem(
      forecastItemId
    );
    if (!linked?.id) return null;

    const updated = await opportunityCw.updateProcurementProduct(
      linked.id,
      data
    );
    // No cache invalidation needed
    return updated;
  }

  /**
   * Set Product Cancellation
   *
   * Updates cancellation fields on the procurement product linked to a
   * forecast item. A quantity of 0 is treated as uncancelled.
   */
  public async setProductCancellation(
    forecastItemId: number,
    opts: { quantityCancelled: number; cancellationReason?: string | null }
  ): Promise<CWProcurementProduct> {
    const linked = await this.fetchProcurementProductByForecastItem(
      forecastItemId
    );

    if (!linked?.id) {
      throw new GenericError({
        status: 404,
        name: "ProcurementProductNotFound",
        message:
          "No linked procurement product found for the specified forecast item",
      });
    }

    const quantityCancelled = Math.max(0, Math.trunc(opts.quantityCancelled));
    const cancelledFlag = quantityCancelled > 0;

    const updated = await this.updateProcurementProductByForecastItem(
      forecastItemId,
      {
        quantityCancelled,
        cancelledFlag,
        cancelledReason: cancelledFlag ? opts.cancellationReason ?? null : null,
      }
    );

    if (!updated) {
      throw new GenericError({
        status: 404,
        name: "ProcurementProductNotFound",
        message:
          "No linked procurement product found for the specified forecast item",
      });
    }

    return updated;
  }

  /**
   * Add Note
   *
   * Creates a new note on this opportunity in ConnectWise.
   *
   * @param note - The note text to add
   * @param user - The user identifier adding the note
   * @param opts - Optional flags
   */
  public async addNote(
    note: string,
    user: string,
    opts?: { flagged?: boolean }
  ): Promise<CWOpportunityNote> {
    const created = await opportunityCw.createNote(this.cwOpportunityId, {
      text: note,
      flagged: opts?.flagged ?? false,
    });
    // No cache invalidation needed
    return created;
  }

  /**
   * Update Note
   *
   * Updates an existing note on this opportunity in ConnectWise.
   *
   * @param noteId - The CW note ID to update
   * @param data - The fields to update
   */
  public async updateNote(
    noteId: number,
    data: { text?: string; flagged?: boolean }
  ): Promise<CWOpportunityNote> {
    const updated = await opportunityCw.updateNote(
      this.cwOpportunityId,
      noteId,
      data
    );
    // No cache invalidation needed
    return updated;
  }

  /**
   * Delete Note
   *
   * Deletes a note from this opportunity in ConnectWise.
   *
   * @param noteId - The CW note ID to delete
   */
  public async deleteNote(noteId: number): Promise<void> {
    await opportunityCw.deleteNote(this.cwOpportunityId, noteId);
    // No cache invalidation needed
  }

  /**
   * To JSON
   *
   * Serializes the opportunity into a safe, API-friendly object.
   */
  public toJson(): Record<string, any> {
    const siteAddress = this._siteData?.address;
    const defaultAddr = this._company?.getDefaultAddress();
    const companyAddress = defaultAddr
      ? {
        line1: defaultAddr.addressLine1,
        line2: defaultAddr.addressLine2,
        city: defaultAddr.city,
        state: defaultAddr.state,
        zip: defaultAddr.zipCode,
        country: defaultAddr.country ?? null,
      }
      : null;

    return {
      id: this.id,
      cwOpportunityId: this.cwOpportunityId,
      name: this.name,
      description: this.notes,
      type: this.typeCwId ? { id: this.typeCwId, name: this.typeName } : null,
      stage: this.stageCwId
        ? { id: this.stageCwId, name: this.stageName }
        : null,
      status: this.statusCwId
        ? { id: this.statusCwId, name: this.statusName }
        : null,
      priority: this.priorityCwId
        ? { id: this.priorityCwId, name: this.priorityName }
        : null,
      interest: this.interest,
      rating: this.interest ? { name: this.interest } : null,
      source: this.source,
      campaign: this.campaignCwId
        ? { id: this.campaignCwId, name: this.campaignName }
        : null,
      primarySalesRep:
        this.primarySalesRepIdentifier || this._primarySalesRep
          ? {
            id: this.primarySalesRepCwId,
            identifier: this.primarySalesRepIdentifier,
            name:
              this._primarySalesRep?.name ??
              this.primarySalesRepName ??
              this.primarySalesRepIdentifier,
            ...(this._primarySalesRep
              ? { user: this._primarySalesRep.toJson({ safeReturn: true }) }
              : {}),
          }
          : null,
      secondarySalesRep:
        this.secondarySalesRepIdentifier || this._secondarySalesRep
          ? {
            id: this.secondarySalesRepCwId,
            identifier: this.secondarySalesRepIdentifier,
            name:
              this._secondarySalesRep?.name ??
              this.secondarySalesRepName ??
              this.secondarySalesRepIdentifier,
            ...(this._secondarySalesRep
              ? {
                user: this._secondarySalesRep.toJson({
                  safeReturn: true,
                }),
              }
              : {}),
          }
          : null,
      company: this._company
        ? this._company.toJson({
          includeAllContacts: true,
          includeAddress: true,
          includePrimaryContact: false,
        })
        : this.companyCwId
          ? { id: this.companyCwId, name: this.companyName }
          : null,
      contact: this.contactCwId
        ? { id: this.contactCwId, name: this.contactName }
        : null,
      site: this._siteData
        ? this._siteData
        : this.siteCwId
          ? { id: this.siteCwId, name: this.siteName }
          : null,
      customerPO: this.customerPO,
      totalSalesTax: this.totalSalesTax,
      expectedSalesTaxRate:
        this.taxCodeRate !== null ? this.taxCodeRate * 100 : null,
      taxCodeDescription: this.taxCodeDescription,
      probability: this.probability != null ? { percent: this.probability } : null,
      location: this.locationCwId
        ? { id: this.locationCwId, name: this.locationName }
        : null,
      department: this.departmentCwId
        ? { id: this.departmentCwId, name: this.departmentName }
        : null,
      expectedCloseDate: this.expectedCloseDate,
      pipelineChangeDate: this.pipelineChangeDate,
      dateBecameLead: this.dateBecameLead,
      closedDate: this.closedDate,
      closedFlag: this.closedFlag,
      closedBy: this.closedByName
        ? { id: this.closedByCwId, name: this.closedByName }
        : null,
      companyId: this.companyId,
      cwLastUpdated: this.cwLastUpdated,
      productSequence: this.productSequence,
      createdAt: this.createdAt,
      updatedAt: this.updatedAt,
      customFields: this._customFields ?? [],
      activities: this._activities?.map((a) => a.toJson()) ?? [],
    };
  }
}
