import { and, asc, count, desc, eq, ilike, inArray, isNull, or, type SQL } from 'drizzle-orm';
import {
  crmDocumentArtifacts,
  crmCustomerContacts,
  crmCustomers,
  crmEnquiries,
  crmQuotationAttachments,
  crmQuotationCustomers,
  crmQuotationFollowups,
  crmQuotationItems,
  crmQuotationTopicItems,
  crmQuotationTopics,
  crmQuotations,
  memberships,
  users
} from '@/db/schema';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import { listAuditLogs } from '@/features/foundation/audit-log/service';
import { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service';
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
import { syncEnquiryPipelineStageFromQuotationStatus } from '@/features/crm/enquiries/server/service';
import type {
  QuotationActivityRecord,
  QuotationAttachmentMutationPayload,
  QuotationAttachmentRecord,
  QuotationApprovedArtifactSummary,
  QuotationBranchOption,
  QuotationContactLookup,
  QuotationCustomerListItem,
  QuotationCustomerLookup,
  QuotationCustomerMutationPayload,
  QuotationCustomerRecord,
  QuotationEnquiryLookup,
  QuotationFilters,
  QuotationFollowupMutationPayload,
  QuotationFollowupRecord,
  QuotationItemMutationPayload,
  QuotationItemRecord,
  QuotationListItem,
  QuotationMutationPayload,
  QuotationOption,
  QuotationRecord,
  QuotationReferenceData,
  QuotationRelationItem,
  QuotationSalesmanLookup,
  QuotationTopicItemRecord,
  QuotationTopicMutationPayload,
  QuotationTopicRecord
} from '../api/types';
import {
  buildProjectPartyKey,
  PROJECT_PARTY_OPTION_CATEGORY,
  resolveProjectPartyRole
} from '@/features/crm/shared/project-party';

const QUOTATION_OPTION_CATEGORIES = {
  status: 'crm_quotation_status',
  quotationType: 'crm_quotation_type',
  currency: 'crm_currency',
  discountType: 'crm_discount_type',
  unit: 'crm_unit',
  sentVia: 'crm_sent_via',
  projectPartyRole: PROJECT_PARTY_OPTION_CATEGORY,
  topicType: 'crm_quotation_topic_type',
  followupType: 'crm_followup_type',
  productType: 'crm_product_type'
} as const;

const REVISION_ALLOWED_STATUS_CODES = new Set(['accepted', 'rejected', 'sent', 'approved']);

function mapOption(
  option: Awaited<ReturnType<typeof getActiveOptionsByCategory>>[number]
): QuotationOption {
  return {
    id: option.id,
    code: option.code,
    label: option.label,
    value: option.value
  };
}

function mapBranch(
  branch: Awaited<ReturnType<typeof getUserBranches>>[number]
): QuotationBranchOption {
  return {
    id: branch.id,
    code: branch.code,
    name: branch.name,
    value: branch.value
  };
}

function mapApprovedArtifactSummary(
  row: typeof crmDocumentArtifacts.$inferSelect,
  options?: { generatedByName?: string | null; lockedByName?: string | null }
): QuotationApprovedArtifactSummary {
  return {
    id: row.id,
    fileName: row.fileName,
    contentType: row.contentType,
    fileSize: row.fileSize,
    checksum: row.checksum,
    status: row.status,
    generatedAt: row.generatedAt.toISOString(),
    generatedBy: row.generatedBy,
    generatedByName: options?.generatedByName ?? null,
    lockedAt: row.lockedAt?.toISOString() ?? null,
    lockedBy: row.lockedBy,
    lockedByName: options?.lockedByName ?? null
  };
}

function mapQuotationRecord(
  row: typeof crmQuotations.$inferSelect,
  options?: { approvedArtifact?: QuotationApprovedArtifactSummary | null }
): QuotationRecord {
  return {
    id: row.id,
    organizationId: row.organizationId,
    branchId: row.branchId,
    code: row.code,
    enquiryId: row.enquiryId,
    customerId: row.customerId,
    contactId: row.contactId,
    quotationDate: row.quotationDate.toISOString(),
    validUntil: row.validUntil?.toISOString() ?? null,
    quotationType: row.quotationType,
    projectName: row.projectName,
    projectLocation: row.projectLocation,
    attention: row.attention,
    reference: row.reference,
    notes: row.notes,
    status: row.status,
    revision: row.revision,
    parentQuotationId: row.parentQuotationId,
    revisionRemark: row.revisionRemark,
    currency: row.currency,
    exchangeRate: row.exchangeRate,
    subtotal: row.subtotal,
    discount: row.discount,
    discountType: row.discountType,
    taxRate: row.taxRate,
    taxAmount: row.taxAmount,
    totalAmount: row.totalAmount,
    chancePercent: row.chancePercent,
    isHotProject: row.isHotProject,
    competitor: row.competitor,
    salesmanId: row.salesmanId,
    isSent: row.isSent,
    sentAt: row.sentAt?.toISOString() ?? null,
    sentVia: row.sentVia,
    approvedAt: row.approvedAt?.toISOString() ?? null,
    approvedArtifactId: row.approvedArtifactId,
    approvedPdfUrl: row.approvedPdfUrl,
    approvedSnapshot: row.approvedSnapshot ?? null,
    approvedTemplateVersionId: row.approvedTemplateVersionId,
    approvedArtifact: options?.approvedArtifact ?? null,
    hasLegacyApprovedPdf:
      !row.approvedArtifactId &&
      typeof row.approvedPdfUrl === 'string' &&
      row.approvedPdfUrl.startsWith('/generated/'),
    acceptedAt: row.acceptedAt?.toISOString() ?? null,
    rejectedAt: row.rejectedAt?.toISOString() ?? null,
    rejectionReason: row.rejectionReason,
    isActive: row.isActive,
    createdAt: row.createdAt.toISOString(),
    updatedAt: row.updatedAt.toISOString(),
    deletedAt: row.deletedAt?.toISOString() ?? null,
    createdBy: row.createdBy,
    updatedBy: row.updatedBy
  };
}

function mapQuotationItemRecord(row: typeof crmQuotationItems.$inferSelect): QuotationItemRecord {
  return {
    id: row.id,
    organizationId: row.organizationId,
    quotationId: row.quotationId,
    itemNumber: row.itemNumber,
    productType: row.productType,
    description: row.description,
    quantity: row.quantity,
    unit: row.unit,
    unitPrice: row.unitPrice,
    discount: row.discount,
    discountType: row.discountType,
    taxRate: row.taxRate,
    totalPrice: row.totalPrice,
    notes: row.notes,
    sortOrder: row.sortOrder,
    createdAt: row.createdAt.toISOString(),
    updatedAt: row.updatedAt.toISOString(),
    deletedAt: row.deletedAt?.toISOString() ?? null
  };
}

function mapQuotationCustomerRecord(
  row: typeof crmQuotationCustomers.$inferSelect,
  options: { role: { id: string; code: string; label: string } }
): QuotationCustomerRecord {
  return {
    id: row.id,
    organizationId: row.organizationId,
    quotationId: row.quotationId,
    customerId: row.customerId,
    role: options.role.id,
    roleCode: options.role.code,
    roleLabel: options.role.label,
    isPrimary: row.isPrimary,
    remark: row.remark,
    createdAt: row.createdAt.toISOString(),
    updatedAt: row.updatedAt.toISOString(),
    deletedAt: row.deletedAt?.toISOString() ?? null
  };
}

function mapQuotationTopicItemRecord(
  row: typeof crmQuotationTopicItems.$inferSelect
): QuotationTopicItemRecord {
  return {
    id: row.id,
    organizationId: row.organizationId,
    topicId: row.topicId,
    content: row.content,
    sortOrder: row.sortOrder,
    createdAt: row.createdAt.toISOString(),
    updatedAt: row.updatedAt.toISOString(),
    deletedAt: row.deletedAt?.toISOString() ?? null
  };
}

function mapQuotationFollowupRecord(
  row: typeof crmQuotationFollowups.$inferSelect
): QuotationFollowupRecord {
  return {
    id: row.id,
    organizationId: row.organizationId,
    quotationId: row.quotationId,
    followupDate: row.followupDate.toISOString(),
    followupType: row.followupType,
    contactId: row.contactId,
    outcome: row.outcome,
    notes: row.notes,
    nextFollowupDate: row.nextFollowupDate?.toISOString() ?? null,
    nextAction: row.nextAction,
    createdAt: row.createdAt.toISOString(),
    updatedAt: row.updatedAt.toISOString(),
    deletedAt: row.deletedAt?.toISOString() ?? null,
    createdBy: row.createdBy,
    updatedBy: row.updatedBy
  };
}

function mapQuotationAttachmentRecord(
  row: typeof crmQuotationAttachments.$inferSelect
): QuotationAttachmentRecord {
  return {
    id: row.id,
    organizationId: row.organizationId,
    quotationId: row.quotationId,
    fileName: row.fileName,
    originalFileName: row.originalFileName,
    filePath: row.filePath,
    fileSize: row.fileSize,
    fileType: row.fileType,
    description: row.description,
    uploadedAt: row.uploadedAt.toISOString(),
    uploadedBy: row.uploadedBy,
    createdAt: row.createdAt.toISOString(),
    updatedAt: row.updatedAt.toISOString(),
    deletedAt: row.deletedAt?.toISOString() ?? null
  };
}

function parseSort(sort?: string) {
  if (!sort) {
    return desc(crmQuotations.updatedAt);
  }

  try {
    const parsed = JSON.parse(sort) as Array<{ id: string; desc?: boolean }>;
    const [rule] = parsed;

    if (!rule) {
      return desc(crmQuotations.updatedAt);
    }

    const sortMap = {
      code: crmQuotations.code,
      quotationDate: crmQuotations.quotationDate,
      status: crmQuotations.status,
      quotationType: crmQuotations.quotationType,
      branch: crmQuotations.branchId,
      customer: crmQuotations.customerId,
      totalAmount: crmQuotations.totalAmount,
      updatedAt: crmQuotations.updatedAt
    } as const;

    const column = sortMap[rule.id as keyof typeof sortMap];

    if (!column) {
      return desc(crmQuotations.updatedAt);
    }

    return rule.desc ? desc(column) : asc(column);
  } catch {
    return desc(crmQuotations.updatedAt);
  }
}

function splitFilterValue(value?: string) {
  return value
    ? value
        .split(/[.,]/)
        .map((item) => item.trim())
        .filter(Boolean)
    : [];
}

function toRevisionLabel(revision: number) {
  return `R${String(revision).padStart(2, '0')}`;
}

function calculateDiscountAmount(baseAmount: number, discount = 0, discountType?: string | null) {
  if (!discount) {
    return 0;
  }

  if (discountType === 'percent' || discountType === 'percentage') {
    return (baseAmount * discount) / 100;
  }

  return discount;
}

function calculateLineTotal(payload: QuotationItemMutationPayload) {
  const baseAmount = payload.quantity * payload.unitPrice;
  const discountAmount = calculateDiscountAmount(
    baseAmount,
    payload.discount ?? 0,
    payload.discountType ?? null
  );

  return Math.max(baseAmount - discountAmount, 0);
}

async function resolveValidOptionIds(category: string, organizationId: string) {
  const options = await getActiveOptionsByCategory(category, { organizationId });
  return new Set(options.map((option) => option.id));
}

async function resolveOptionCodeById(
  organizationId: string,
  category: string,
  optionId?: string | null
) {
  if (!optionId) {
    return null;
  }

  const options = await getActiveOptionsByCategory(category, { organizationId });
  return options.find((option) => option.id === optionId)?.code ?? null;
}

async function resolveOptionIdByCode(organizationId: string, category: string, code: string) {
  const options = await getActiveOptionsByCategory(category, { organizationId });
  return options.find((option) => option.code === code)?.id ?? null;
}

async function assertMasterOptionValue(
  organizationId: string,
  category: string,
  optionId?: string | null
) {
  if (!optionId) {
    return;
  }

  const validIds = await resolveValidOptionIds(category, organizationId);

  if (!validIds.has(optionId)) {
    throw new AuthError(`Invalid option for ${category}`, 400);
  }
}

export async function assertCustomerBelongsToOrganization(id: string, organizationId: string) {
  const [customer] = await db
    .select()
    .from(crmCustomers)
    .where(
      and(
        eq(crmCustomers.id, id),
        eq(crmCustomers.organizationId, organizationId),
        isNull(crmCustomers.deletedAt)
      )
    )
    .limit(1);

  if (!customer) {
    throw new AuthError('Customer not found', 404);
  }

  return customer;
}

export async function assertContactBelongsToOrganization(
  contactId: string,
  organizationId: string,
  customerId?: string | null
) {
  const [contact] = await db
    .select()
    .from(crmCustomerContacts)
    .where(
      and(
        eq(crmCustomerContacts.id, contactId),
        eq(crmCustomerContacts.organizationId, organizationId),
        isNull(crmCustomerContacts.deletedAt),
        ...(customerId ? [eq(crmCustomerContacts.customerId, customerId)] : [])
      )
    )
    .limit(1);

  if (!contact) {
    throw new AuthError('Contact not found', 404);
  }

  return contact;
}

async function assertEnquiryBelongsToOrganization(id: string, organizationId: string) {
  const [enquiry] = await db
    .select()
    .from(crmEnquiries)
    .where(
      and(
        eq(crmEnquiries.id, id),
        eq(crmEnquiries.organizationId, organizationId),
        isNull(crmEnquiries.deletedAt)
      )
    )
    .limit(1);

  if (!enquiry) {
    throw new AuthError('Enquiry not found', 404);
  }

  return enquiry;
}

async function assertQuotationBelongsToOrganization(id: string, organizationId: string) {
  const [quotation] = await db
    .select()
    .from(crmQuotations)
    .where(
      and(
        eq(crmQuotations.id, id),
        eq(crmQuotations.organizationId, organizationId),
        isNull(crmQuotations.deletedAt)
      )
    )
    .limit(1);

  if (!quotation) {
    throw new AuthError('Quotation not found', 404);
  }

  return quotation;
}

async function assertQuotationItemBelongsToQuotation(
  quotationId: string,
  itemId: string,
  organizationId: string
) {
  const [item] = await db
    .select()
    .from(crmQuotationItems)
    .where(
      and(
        eq(crmQuotationItems.id, itemId),
        eq(crmQuotationItems.quotationId, quotationId),
        eq(crmQuotationItems.organizationId, organizationId),
        isNull(crmQuotationItems.deletedAt)
      )
    )
    .limit(1);

  if (!item) {
    throw new AuthError('Quotation item not found', 404);
  }

  return item;
}

async function assertQuotationCustomerBelongsToQuotation(
  quotationId: string,
  relationId: string,
  organizationId: string
) {
  const [relation] = await db
    .select()
    .from(crmQuotationCustomers)
    .where(
      and(
        eq(crmQuotationCustomers.id, relationId),
        eq(crmQuotationCustomers.quotationId, quotationId),
        eq(crmQuotationCustomers.organizationId, organizationId),
        isNull(crmQuotationCustomers.deletedAt)
      )
    )
    .limit(1);

  if (!relation) {
    throw new AuthError('Quotation customer relation not found', 404);
  }

  return relation;
}

async function assertQuotationTopicBelongsToQuotation(
  quotationId: string,
  topicId: string,
  organizationId: string
) {
  const [topic] = await db
    .select()
    .from(crmQuotationTopics)
    .where(
      and(
        eq(crmQuotationTopics.id, topicId),
        eq(crmQuotationTopics.quotationId, quotationId),
        eq(crmQuotationTopics.organizationId, organizationId),
        isNull(crmQuotationTopics.deletedAt)
      )
    )
    .limit(1);

  if (!topic) {
    throw new AuthError('Quotation topic not found', 404);
  }

  return topic;
}

async function assertQuotationFollowupBelongsToQuotation(
  quotationId: string,
  followupId: string,
  organizationId: string
) {
  const [followup] = await db
    .select()
    .from(crmQuotationFollowups)
    .where(
      and(
        eq(crmQuotationFollowups.id, followupId),
        eq(crmQuotationFollowups.quotationId, quotationId),
        eq(crmQuotationFollowups.organizationId, organizationId),
        isNull(crmQuotationFollowups.deletedAt)
      )
    )
    .limit(1);

  if (!followup) {
    throw new AuthError('Quotation follow-up not found', 404);
  }

  return followup;
}

async function assertQuotationAttachmentBelongsToQuotation(
  quotationId: string,
  attachmentId: string,
  organizationId: string
) {
  const [attachment] = await db
    .select()
    .from(crmQuotationAttachments)
    .where(
      and(
        eq(crmQuotationAttachments.id, attachmentId),
        eq(crmQuotationAttachments.quotationId, quotationId),
        eq(crmQuotationAttachments.organizationId, organizationId),
        isNull(crmQuotationAttachments.deletedAt)
      )
    )
    .limit(1);

  if (!attachment) {
    throw new AuthError('Quotation attachment not found', 404);
  }

  return attachment;
}

async function assertSalesmanBelongsToOrganization(userId: string, organizationId: string) {
  const [membership] = await db
    .select({ userId: memberships.userId })
    .from(memberships)
    .where(and(eq(memberships.organizationId, organizationId), eq(memberships.userId, userId)))
    .limit(1);

  if (!membership) {
    throw new AuthError('Salesman not found in organization', 404);
  }
}

async function validateQuotationPayload(organizationId: string, payload: QuotationMutationPayload) {
  await assertCustomerBelongsToOrganization(payload.customerId, organizationId);

  if (payload.contactId) {
    await assertContactBelongsToOrganization(payload.contactId, organizationId, payload.customerId);
  }

  if (payload.enquiryId) {
    const enquiry = await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId);

    if (enquiry.customerId !== payload.customerId) {
      throw new AuthError('Selected enquiry belongs to a different customer', 400);
    }
  }

  if (payload.branchId) {
    await validateBranchAccess(payload.branchId);
  }

  if (payload.salesmanId) {
    await assertSalesmanBelongsToOrganization(payload.salesmanId, organizationId);
  }

  await assertMasterOptionValue(organizationId, QUOTATION_OPTION_CATEGORIES.status, payload.status);
  await assertMasterOptionValue(
    organizationId,
    QUOTATION_OPTION_CATEGORIES.quotationType,
    payload.quotationType
  );
  await assertMasterOptionValue(
    organizationId,
    QUOTATION_OPTION_CATEGORIES.currency,
    payload.currency
  );
  await assertMasterOptionValue(
    organizationId,
    QUOTATION_OPTION_CATEGORIES.discountType,
    payload.discountType ?? null
  );
  await assertMasterOptionValue(
    organizationId,
    QUOTATION_OPTION_CATEGORIES.sentVia,
    payload.sentVia ?? null
  );

  for (const projectParty of payload.projectParties ?? []) {
    await assertCustomerBelongsToOrganization(projectParty.customerId, organizationId);
    await assertMasterOptionValue(
      organizationId,
      QUOTATION_OPTION_CATEGORIES.projectPartyRole,
      projectParty.role
    );
  }
}

async function validateQuotationItemPayload(
  organizationId: string,
  payload: QuotationItemMutationPayload
) {
  await assertMasterOptionValue(
    organizationId,
    QUOTATION_OPTION_CATEGORIES.productType,
    payload.productType
  );
  await assertMasterOptionValue(
    organizationId,
    QUOTATION_OPTION_CATEGORIES.discountType,
    payload.discountType ?? null
  );
  await assertMasterOptionValue(
    organizationId,
    QUOTATION_OPTION_CATEGORIES.unit,
    payload.unit ?? null
  );
}

async function validateQuotationCustomerPayload(
  organizationId: string,
  payload: QuotationCustomerMutationPayload
) {
  await assertCustomerBelongsToOrganization(payload.customerId, organizationId);
  await assertMasterOptionValue(
    organizationId,
    QUOTATION_OPTION_CATEGORIES.projectPartyRole,
    payload.role
  );
}

async function buildPreparedQuotationProjectParties(
  organizationId: string,
  billingCustomerId: string,
  projectParties: QuotationCustomerMutationPayload[] = []
) {
  const roleOptions = await getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.projectPartyRole, {
    organizationId
  });
  const billingRole = roleOptions.find((option) => option.code === 'billing_customer');

  if (!billingRole) {
    throw new AuthError('Billing customer project-party role is not configured', 409);
  }

  const prepared = new Map<
    string,
    {
      customerId: string;
      roleId: string;
      roleCode: string;
      isPrimary: boolean;
      remark: string | null;
    }
  >();

  for (const item of projectParties) {
    const resolvedRole = resolveProjectPartyRole(roleOptions, item.role);

    if (!resolvedRole) {
      throw new AuthError('Invalid project party role', 400);
    }

    const key = buildProjectPartyKey(item.customerId, resolvedRole.code);

    if (!prepared.has(key)) {
      prepared.set(key, {
        customerId: item.customerId,
        roleId: resolvedRole.id,
        roleCode: resolvedRole.code,
        isPrimary: resolvedRole.code === 'billing_customer' || !!item.isPrimary,
        remark: item.remark?.trim() || null
      });
    }
  }

  const billingKey = buildProjectPartyKey(billingCustomerId, billingRole.code);

  if (!prepared.has(billingKey)) {
    prepared.set(billingKey, {
      customerId: billingCustomerId,
      roleId: billingRole.id,
      roleCode: billingRole.code,
      isPrimary: true,
      remark: null
    });
  }

  return [...prepared.values()].map((item) => ({
    ...item,
    isPrimary: item.roleCode === 'billing_customer'
  }));
}

async function syncQuotationProjectParties(
  tx: Pick<typeof db, 'insert' | 'update'>,
  organizationId: string,
  quotationId: string,
  billingCustomerId: string,
  payload: QuotationCustomerMutationPayload[]
) {
  const now = new Date();
  const preparedParties = await buildPreparedQuotationProjectParties(
    organizationId,
    billingCustomerId,
    payload
  );

  await tx
    .update(crmQuotationCustomers)
    .set({ deletedAt: now, updatedAt: now })
    .where(
      and(
        eq(crmQuotationCustomers.quotationId, quotationId),
        eq(crmQuotationCustomers.organizationId, organizationId),
        isNull(crmQuotationCustomers.deletedAt)
      )
    );

  if (!preparedParties.length) {
    return;
  }

  await tx.insert(crmQuotationCustomers).values(
    preparedParties.map((item) => ({
      id: crypto.randomUUID(),
      organizationId,
      quotationId,
      customerId: item.customerId,
      role: item.roleId,
      isPrimary: item.isPrimary,
      remark: item.remark
    }))
  );
}

async function assertQuotationProjectPartyNotDuplicated(
  quotationId: string,
  organizationId: string,
  payload: QuotationCustomerMutationPayload,
  excludeRelationId?: string
) {
  const [relations, roleOptions] = await Promise.all([
    db
      .select()
      .from(crmQuotationCustomers)
      .where(
        and(
          eq(crmQuotationCustomers.quotationId, quotationId),
          eq(crmQuotationCustomers.organizationId, organizationId),
          isNull(crmQuotationCustomers.deletedAt)
        )
      ),
    getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.projectPartyRole, { organizationId })
  ]);
  const incomingRole = resolveProjectPartyRole(roleOptions, payload.role);

  if (!incomingRole) {
    throw new AuthError('Invalid project party role', 400);
  }

  const duplicate = relations.find((relation) => {
    if (excludeRelationId && relation.id === excludeRelationId) {
      return false;
    }

    const currentRole = resolveProjectPartyRole(roleOptions, relation.role);

    return relation.customerId === payload.customerId && currentRole?.code === incomingRole.code;
  });

  if (duplicate) {
    throw new AuthError('This project party already exists for the quotation', 400);
  }
}

async function validateQuotationTopicPayload(
  organizationId: string,
  payload: QuotationTopicMutationPayload
) {
  await assertMasterOptionValue(
    organizationId,
    QUOTATION_OPTION_CATEGORIES.topicType,
    payload.topicType
  );
}

async function validateQuotationFollowupPayload(
  quotationId: string,
  organizationId: string,
  payload: QuotationFollowupMutationPayload
) {
  const quotation = await assertQuotationBelongsToOrganization(quotationId, organizationId);

  await assertMasterOptionValue(
    organizationId,
    QUOTATION_OPTION_CATEGORIES.followupType,
    payload.followupType
  );

  if (payload.contactId) {
    await assertContactBelongsToOrganization(
      payload.contactId,
      organizationId,
      quotation.customerId
    );
  }
}

function buildQuotationFilters(organizationId: string, filters: QuotationFilters): SQL[] {
  const statuses = splitFilterValue(filters.status);
  const quotationTypes = splitFilterValue(filters.quotationType);
  const branches = splitFilterValue(filters.branch);
  const customers = splitFilterValue(filters.customer);
  const enquiries = splitFilterValue(filters.enquiry);

  return [
    eq(crmQuotations.organizationId, organizationId),
    isNull(crmQuotations.deletedAt),
    ...(statuses.length ? [inArray(crmQuotations.status, statuses)] : []),
    ...(quotationTypes.length ? [inArray(crmQuotations.quotationType, quotationTypes)] : []),
    ...(branches.length ? [inArray(crmQuotations.branchId, branches)] : []),
    ...(customers.length ? [inArray(crmQuotations.customerId, customers)] : []),
    ...(enquiries.length ? [inArray(crmQuotations.enquiryId, enquiries)] : []),
    ...(filters.hot === 'true' ? [eq(crmQuotations.isHotProject, true)] : []),
    ...(filters.hot === 'false' ? [eq(crmQuotations.isHotProject, false)] : []),
    ...(filters.search
      ? [
          or(
            ilike(crmQuotations.code, `%${filters.search}%`),
            ilike(crmQuotations.projectName, `%${filters.search}%`),
            ilike(crmQuotations.projectLocation, `%${filters.search}%`),
            ilike(crmQuotations.reference, `%${filters.search}%`),
            ilike(crmQuotations.competitor, `%${filters.search}%`)
          )!
        ]
      : [])
  ];
}

async function refreshQuotationTotals(
  quotationId: string,
  organizationId: string,
  userId?: string
) {
  const quotation = await assertQuotationBelongsToOrganization(quotationId, organizationId);
  const items = await listQuotationItems(quotationId, organizationId);
  const subtotal = items.reduce((sum, item) => sum + item.totalPrice, 0);
  const headerDiscountAmount = calculateDiscountAmount(
    subtotal,
    quotation.discount,
    quotation.discountType
  );
  const discountedSubtotal = Math.max(subtotal - headerDiscountAmount, 0);
  const taxAmount = discountedSubtotal * (quotation.taxRate / 100);
  const totalAmount = discountedSubtotal + taxAmount;

  const [updated] = await db
    .update(crmQuotations)
    .set({
      subtotal,
      taxAmount,
      totalAmount,
      updatedAt: new Date(),
      ...(userId ? { updatedBy: userId } : {})
    })
    .where(eq(crmQuotations.id, quotationId))
    .returning();

  return updated;
}

async function listQuotationTopicItemsByTopicIds(topicIds: string[], organizationId: string) {
  if (!topicIds.length) {
    return [];
  }

  return db
    .select()
    .from(crmQuotationTopicItems)
    .where(
      and(
        eq(crmQuotationTopicItems.organizationId, organizationId),
        inArray(crmQuotationTopicItems.topicId, topicIds),
        isNull(crmQuotationTopicItems.deletedAt)
      )
    )
    .orderBy(asc(crmQuotationTopicItems.sortOrder), asc(crmQuotationTopicItems.createdAt));
}

export async function getQuotationReferenceData(
  organizationId: string
): Promise<QuotationReferenceData> {
  const [
    branches,
    statuses,
    quotationTypes,
    currencies,
    discountTypes,
    units,
    sentVias,
    projectPartyRoles,
    topicTypes,
    followupTypes,
    productTypes,
    customers,
    contacts,
    enquiries,
    salesmen
  ] = await Promise.all([
    getUserBranches(),
    getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.status, { organizationId }),
    getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.quotationType, { organizationId }),
    getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.currency, { organizationId }),
    getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.discountType, { organizationId }),
    getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.unit, { organizationId }),
    getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.sentVia, { organizationId }),
    getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.projectPartyRole, { organizationId }),
    getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.topicType, { organizationId }),
    getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.followupType, { organizationId }),
    getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.productType, { organizationId }),
    db
      .select()
      .from(crmCustomers)
      .where(and(eq(crmCustomers.organizationId, organizationId), isNull(crmCustomers.deletedAt)))
      .orderBy(asc(crmCustomers.name)),
    db
      .select()
      .from(crmCustomerContacts)
      .where(
        and(
          eq(crmCustomerContacts.organizationId, organizationId),
          isNull(crmCustomerContacts.deletedAt)
        )
      )
      .orderBy(desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name)),
    db
      .select()
      .from(crmEnquiries)
      .where(and(eq(crmEnquiries.organizationId, organizationId), isNull(crmEnquiries.deletedAt)))
      .orderBy(desc(crmEnquiries.updatedAt)),
    db
      .select({ id: users.id, name: users.name })
      .from(memberships)
      .innerJoin(users, eq(memberships.userId, users.id))
      .where(eq(memberships.organizationId, organizationId))
      .orderBy(asc(users.name))
  ]);

  return {
    branches: branches.map(mapBranch),
    statuses: statuses.map(mapOption),
    quotationTypes: quotationTypes.map(mapOption),
    currencies: currencies.map(mapOption),
    discountTypes: discountTypes.map(mapOption),
    units: units.map(mapOption),
    sentVias: sentVias.map(mapOption),
    projectPartyRoles: projectPartyRoles.map(mapOption),
    topicTypes: topicTypes.map(mapOption),
    followupTypes: followupTypes.map(mapOption),
    productTypes: productTypes.map(mapOption),
    customers: customers.map<QuotationCustomerLookup>((customer) => ({
      id: customer.id,
      code: customer.code,
      name: customer.name,
      branchId: customer.branchId
    })),
    contacts: contacts.map<QuotationContactLookup>((contact) => ({
      id: contact.id,
      customerId: contact.customerId,
      name: contact.name,
      email: contact.email,
      mobile: contact.mobile,
      isPrimary: contact.isPrimary
    })),
    enquiries: enquiries.map<QuotationEnquiryLookup>((enquiry) => ({
      id: enquiry.id,
      code: enquiry.code,
      customerId: enquiry.customerId,
      contactId: enquiry.contactId,
      title: enquiry.title,
      projectName: enquiry.projectName,
      projectLocation: enquiry.projectLocation,
      branchId: enquiry.branchId
    })),
    salesmen: salesmen.map<QuotationSalesmanLookup>((salesman) => ({
      id: salesman.id,
      name: salesman.name
    }))
  };
}

export async function listQuotations(
  organizationId: string,
  filters: QuotationFilters
): Promise<{ items: QuotationListItem[]; totalItems: number }> {
  const page = filters.page ?? 1;
  const limit = filters.limit ?? 10;
  const whereFilters = buildQuotationFilters(organizationId, filters);
  const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
  const offset = (page - 1) * limit;

  const [totalResult] = await db.select({ value: count() }).from(crmQuotations).where(where);
  const rows = await db
    .select()
    .from(crmQuotations)
    .where(where)
    .orderBy(parseSort(filters.sort))
    .limit(limit)
    .offset(offset);

  const customerIds = [...new Set(rows.map((row) => row.customerId))];
  const contactIds = [...new Set(rows.map((row) => row.contactId).filter(Boolean))] as string[];
  const enquiryIds = [...new Set(rows.map((row) => row.enquiryId).filter(Boolean))] as string[];
  const quotationIds = rows.map((row) => row.id);

  const [customers, contacts, enquiries, itemCounts] = await Promise.all([
    customerIds.length
      ? db.select().from(crmCustomers).where(inArray(crmCustomers.id, customerIds))
      : [],
    contactIds.length
      ? db.select().from(crmCustomerContacts).where(inArray(crmCustomerContacts.id, contactIds))
      : [],
    enquiryIds.length
      ? db.select().from(crmEnquiries).where(inArray(crmEnquiries.id, enquiryIds))
      : [],
    quotationIds.length
      ? db
          .select({ quotationId: crmQuotationItems.quotationId, value: count() })
          .from(crmQuotationItems)
          .where(
            and(
              eq(crmQuotationItems.organizationId, organizationId),
              inArray(crmQuotationItems.quotationId, quotationIds),
              isNull(crmQuotationItems.deletedAt)
            )
          )
          .groupBy(crmQuotationItems.quotationId)
      : []
  ]);

  const customerMap = new Map(customers.map((item) => [item.id, item]));
  const contactMap = new Map(contacts.map((item) => [item.id, item]));
  const enquiryMap = new Map(enquiries.map((item) => [item.id, item]));
  const itemCountMap = new Map(itemCounts.map((item) => [item.quotationId, item.value]));

  return {
    items: rows.map((row) => ({
      ...mapQuotationRecord(row),
      customerName: customerMap.get(row.customerId)?.name ?? 'Unknown customer',
      contactName: row.contactId ? (contactMap.get(row.contactId)?.name ?? null) : null,
      enquiryCode: row.enquiryId ? (enquiryMap.get(row.enquiryId)?.code ?? null) : null,
      itemCount: itemCountMap.get(row.id) ?? 0,
      revisionLabel: toRevisionLabel(row.revision)
    })),
    totalItems: totalResult?.value ?? 0
  };
}

export async function getQuotationDetail(
  id: string,
  organizationId: string
): Promise<QuotationRecord> {
  const quotation = await assertQuotationBelongsToOrganization(id, organizationId);
  const approvedArtifactId =
    quotation.approvedArtifactId ??
    (quotation.approvedPdfUrl?.startsWith('artifact:')
      ? quotation.approvedPdfUrl.replace('artifact:', '')
      : null);

  if (!approvedArtifactId) {
    return mapQuotationRecord(quotation);
  }

  const [artifact] = await db
    .select()
    .from(crmDocumentArtifacts)
    .where(
      and(
        eq(crmDocumentArtifacts.id, approvedArtifactId),
        eq(crmDocumentArtifacts.organizationId, organizationId),
        isNull(crmDocumentArtifacts.deletedAt)
      )
    )
    .limit(1);

  if (!artifact) {
    return mapQuotationRecord(quotation);
  }

  const relatedUserIds = [artifact.generatedBy, artifact.lockedBy].filter(Boolean) as string[];
  const actorRows = relatedUserIds.length
    ? await db
        .select({ id: users.id, name: users.name })
        .from(users)
        .where(inArray(users.id, relatedUserIds))
    : [];
  const actorMap = new Map(actorRows.map((row) => [row.id, row.name]));

  return mapQuotationRecord(quotation, {
    approvedArtifact: mapApprovedArtifactSummary(artifact, {
      generatedByName: actorMap.get(artifact.generatedBy) ?? null,
      lockedByName: artifact.lockedBy ? (actorMap.get(artifact.lockedBy) ?? null) : null
    })
  });
}

export async function getQuotationActivity(
  id: string,
  organizationId: string
): Promise<QuotationActivityRecord[]> {
  const auditLogs = await listAuditLogs({
    organizationId,
    entityId: id,
    limit: 50
  });
  const filtered = auditLogs.filter(
    (log) =>
      log.entityType === 'crm_quotation' ||
      log.entityType === 'crm_quotation_item' ||
      log.entityType === 'crm_quotation_customer' ||
      log.entityType === 'crm_quotation_topic' ||
      log.entityType === 'crm_quotation_followup' ||
      log.entityType === 'crm_quotation_attachment' ||
      log.entityType === 'crm_document_artifact'
  );
  const userIds = [...new Set(filtered.map((log) => log.userId))];
  const actorRows = userIds.length
    ? await db
        .select({ id: users.id, name: users.name })
        .from(users)
        .where(inArray(users.id, userIds))
    : [];
  const actorMap = new Map(actorRows.map((row) => [row.id, row.name]));

  return filtered.map((log) => ({
    id: log.id,
    action: log.action,
    entityType: log.entityType,
    entityId: log.entityId,
    createdAt: log.createdAt,
    userId: log.userId,
    actorName: actorMap.get(log.userId) ?? null
  }));
}

export async function createQuotation(
  organizationId: string,
  userId: string,
  payload: QuotationMutationPayload
) {
  await validateQuotationPayload(organizationId, payload);
  const quotationStatusCode = await resolveOptionCodeById(
    organizationId,
    QUOTATION_OPTION_CATEGORIES.status,
    payload.status
  );

  const enquiry = payload.enquiryId
    ? await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId)
    : null;
  const documentCode = await generateNextDocumentCode({
    organizationId,
    documentType: 'quotation',
    branchId: payload.branchId ?? enquiry?.branchId ?? ''
  });

  return db.transaction(async (tx) => {
    const [created] = await tx
      .insert(crmQuotations)
      .values({
        id: crypto.randomUUID(),
        organizationId,
        branchId: payload.branchId ?? enquiry?.branchId ?? null,
        code: documentCode.code,
        enquiryId: payload.enquiryId ?? null,
        customerId: payload.customerId,
        contactId: payload.contactId ?? enquiry?.contactId ?? null,
        quotationDate: new Date(payload.quotationDate),
        validUntil: payload.validUntil ? new Date(payload.validUntil) : null,
        quotationType: payload.quotationType,
        projectName: payload.projectName?.trim() || enquiry?.projectName || null,
        projectLocation: payload.projectLocation?.trim() || enquiry?.projectLocation || null,
        attention: payload.attention?.trim() || null,
        reference: payload.reference?.trim() || null,
        notes: payload.notes?.trim() || null,
        status: payload.status,
        revision: 0,
        revisionRemark: payload.revisionRemark?.trim() || null,
        currency: payload.currency,
        exchangeRate: payload.exchangeRate ?? 1,
        discount: payload.discount ?? 0,
        discountType: payload.discountType ?? null,
        taxRate: payload.taxRate ?? 0,
        chancePercent: payload.chancePercent ?? enquiry?.chancePercent ?? null,
        isHotProject: payload.isHotProject ?? enquiry?.isHotProject ?? false,
        competitor: payload.competitor?.trim() || enquiry?.competitor || null,
        salesmanId: payload.salesmanId ?? null,
        isSent: false,
        sentVia: payload.sentVia ?? null,
        approvedAt: null,
        approvedArtifactId: null,
        approvedPdfUrl: null,
        approvedSnapshot: null,
        approvedTemplateVersionId: null,
        isActive: payload.isActive ?? true,
        createdBy: userId,
        updatedBy: userId
      })
      .returning();

    await syncQuotationProjectParties(
      tx,
      organizationId,
      created.id,
      payload.customerId,
      payload.projectParties ?? []
    );

    if (created.enquiryId) {
      await syncEnquiryPipelineStageFromQuotationStatus(
        created.enquiryId,
        organizationId,
        quotationStatusCode
      );
    }

    return created;
  });
}

export async function updateQuotation(
  id: string,
  organizationId: string,
  userId: string,
  payload: QuotationMutationPayload
) {
  await validateQuotationPayload(organizationId, payload);
  const quotationStatusCode = await resolveOptionCodeById(
    organizationId,
    QUOTATION_OPTION_CATEGORIES.status,
    payload.status
  );
  const existing = await assertQuotationBelongsToOrganization(id, organizationId);
  const enquiry = payload.enquiryId
    ? await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId)
    : null;

  const updated = await db.transaction(async (tx) => {
    const [row] = await tx
      .update(crmQuotations)
      .set({
        branchId: payload.branchId ?? enquiry?.branchId ?? null,
        enquiryId: payload.enquiryId ?? null,
        customerId: payload.customerId,
        contactId: payload.contactId ?? enquiry?.contactId ?? null,
        quotationDate: new Date(payload.quotationDate),
        validUntil: payload.validUntil ? new Date(payload.validUntil) : null,
        quotationType: payload.quotationType,
        projectName: payload.projectName?.trim() || enquiry?.projectName || null,
        projectLocation: payload.projectLocation?.trim() || enquiry?.projectLocation || null,
        attention: payload.attention?.trim() || null,
        reference: payload.reference?.trim() || null,
        notes: payload.notes?.trim() || null,
        status: payload.status,
        revisionRemark: payload.revisionRemark?.trim() || existing.revisionRemark || null,
        currency: payload.currency,
        exchangeRate: payload.exchangeRate ?? 1,
        discount: payload.discount ?? 0,
        discountType: payload.discountType ?? null,
        taxRate: payload.taxRate ?? 0,
        chancePercent: payload.chancePercent ?? enquiry?.chancePercent ?? null,
        isHotProject: payload.isHotProject ?? false,
        competitor: payload.competitor?.trim() || enquiry?.competitor || null,
        salesmanId: payload.salesmanId ?? null,
        sentVia: payload.sentVia ?? null,
        isActive: payload.isActive ?? true,
        updatedAt: new Date(),
        updatedBy: userId
      })
      .where(eq(crmQuotations.id, id))
      .returning();

    await syncQuotationProjectParties(
      tx,
      organizationId,
      id,
      payload.customerId,
      payload.projectParties ?? []
    );

    if (row.enquiryId) {
      await syncEnquiryPipelineStageFromQuotationStatus(
        row.enquiryId,
        organizationId,
        quotationStatusCode
      );
    }

    return row;
  });

  await refreshQuotationTotals(id, organizationId, userId);
  return updated;
}

export async function softDeleteQuotation(id: string, organizationId: string, userId: string) {
  await assertQuotationBelongsToOrganization(id, organizationId);
  const now = new Date();

  const [updated] = await db
    .update(crmQuotations)
    .set({
      deletedAt: now,
      updatedAt: now,
      updatedBy: userId,
      isActive: false
    })
    .where(eq(crmQuotations.id, id))
    .returning();

  await Promise.all([
    db
      .update(crmQuotationItems)
      .set({ deletedAt: now, updatedAt: now })
      .where(
        and(
          eq(crmQuotationItems.quotationId, id),
          eq(crmQuotationItems.organizationId, organizationId)
        )
      ),
    db
      .update(crmQuotationCustomers)
      .set({ deletedAt: now, updatedAt: now })
      .where(
        and(
          eq(crmQuotationCustomers.quotationId, id),
          eq(crmQuotationCustomers.organizationId, organizationId)
        )
      ),
    db
      .update(crmQuotationTopics)
      .set({ deletedAt: now, updatedAt: now })
      .where(
        and(
          eq(crmQuotationTopics.quotationId, id),
          eq(crmQuotationTopics.organizationId, organizationId)
        )
      ),
    db
      .update(crmQuotationFollowups)
      .set({ deletedAt: now, updatedAt: now, updatedBy: userId })
      .where(
        and(
          eq(crmQuotationFollowups.quotationId, id),
          eq(crmQuotationFollowups.organizationId, organizationId)
        )
      ),
    db
      .update(crmQuotationAttachments)
      .set({ deletedAt: now, updatedAt: now })
      .where(
        and(
          eq(crmQuotationAttachments.quotationId, id),
          eq(crmQuotationAttachments.organizationId, organizationId)
        )
      )
  ]);

  return updated;
}

export async function listQuotationItems(id: string, organizationId: string) {
  await assertQuotationBelongsToOrganization(id, organizationId);
  const rows = await db
    .select()
    .from(crmQuotationItems)
    .where(
      and(
        eq(crmQuotationItems.quotationId, id),
        eq(crmQuotationItems.organizationId, organizationId),
        isNull(crmQuotationItems.deletedAt)
      )
    )
    .orderBy(asc(crmQuotationItems.sortOrder), asc(crmQuotationItems.itemNumber));

  return rows.map(mapQuotationItemRecord);
}

export async function createQuotationItem(
  quotationId: string,
  organizationId: string,
  userId: string,
  payload: QuotationItemMutationPayload
) {
  await assertQuotationBelongsToOrganization(quotationId, organizationId);
  await validateQuotationItemPayload(organizationId, payload);
  const existingItems = await listQuotationItems(quotationId, organizationId);

  const [created] = await db
    .insert(crmQuotationItems)
    .values({
      id: crypto.randomUUID(),
      organizationId,
      quotationId,
      itemNumber: existingItems.length + 1,
      productType: payload.productType,
      description: payload.description.trim(),
      quantity: payload.quantity,
      unit: payload.unit?.trim() || null,
      unitPrice: payload.unitPrice,
      discount: payload.discount ?? 0,
      discountType: payload.discountType ?? null,
      taxRate: payload.taxRate ?? 0,
      totalPrice: calculateLineTotal(payload),
      notes: payload.notes?.trim() || null,
      sortOrder: payload.sortOrder ?? existingItems.length
    })
    .returning();

  await refreshQuotationTotals(quotationId, organizationId, userId);
  return created;
}

export async function updateQuotationItem(
  quotationId: string,
  itemId: string,
  organizationId: string,
  userId: string,
  payload: QuotationItemMutationPayload
) {
  await validateQuotationItemPayload(organizationId, payload);
  await assertQuotationItemBelongsToQuotation(quotationId, itemId, organizationId);

  const [updated] = await db
    .update(crmQuotationItems)
    .set({
      productType: payload.productType,
      description: payload.description.trim(),
      quantity: payload.quantity,
      unit: payload.unit?.trim() || null,
      unitPrice: payload.unitPrice,
      discount: payload.discount ?? 0,
      discountType: payload.discountType ?? null,
      taxRate: payload.taxRate ?? 0,
      totalPrice: calculateLineTotal(payload),
      notes: payload.notes?.trim() || null,
      sortOrder: payload.sortOrder ?? 0,
      updatedAt: new Date()
    })
    .where(eq(crmQuotationItems.id, itemId))
    .returning();

  await refreshQuotationTotals(quotationId, organizationId, userId);
  return updated;
}

export async function softDeleteQuotationItem(
  quotationId: string,
  itemId: string,
  organizationId: string,
  userId: string
) {
  await assertQuotationItemBelongsToQuotation(quotationId, itemId, organizationId);

  const [updated] = await db
    .update(crmQuotationItems)
    .set({
      deletedAt: new Date(),
      updatedAt: new Date()
    })
    .where(eq(crmQuotationItems.id, itemId))
    .returning();

  await refreshQuotationTotals(quotationId, organizationId, userId);
  return updated;
}

export async function listQuotationCustomers(id: string, organizationId: string) {
  await assertQuotationBelongsToOrganization(id, organizationId);
  const [relations, roleOptions] = await Promise.all([
    db
      .select()
      .from(crmQuotationCustomers)
      .where(
        and(
          eq(crmQuotationCustomers.quotationId, id),
          eq(crmQuotationCustomers.organizationId, organizationId),
          isNull(crmQuotationCustomers.deletedAt)
        )
      )
      .orderBy(desc(crmQuotationCustomers.isPrimary), asc(crmQuotationCustomers.createdAt)),
    getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.projectPartyRole, { organizationId })
  ]);
  const customerIds = [...new Set(relations.map((row) => row.customerId))];
  const customers = customerIds.length
    ? await db.select().from(crmCustomers).where(inArray(crmCustomers.id, customerIds))
    : [];
  const customerMap = new Map(customers.map((item) => [item.id, item]));

  return relations
    .map((row) => {
      const resolvedRole = resolveProjectPartyRole(roleOptions, row.role);

      if (!resolvedRole) {
        return null;
      }

      return {
        ...mapQuotationCustomerRecord(row, { role: resolvedRole }),
        customerName: customerMap.get(row.customerId)?.name ?? 'Unknown customer',
        customerCode: customerMap.get(row.customerId)?.code ?? '-'
      } satisfies QuotationCustomerListItem;
    })
    .filter((item): item is QuotationCustomerListItem => item !== null);
}

export async function createQuotationCustomer(
  quotationId: string,
  organizationId: string,
  payload: QuotationCustomerMutationPayload
) {
  await assertQuotationBelongsToOrganization(quotationId, organizationId);
  await validateQuotationCustomerPayload(organizationId, payload);
  await assertQuotationProjectPartyNotDuplicated(quotationId, organizationId, payload);

  return db.transaction(async (tx) => {
    const roleOptions = await getActiveOptionsByCategory(
      QUOTATION_OPTION_CATEGORIES.projectPartyRole,
      { organizationId }
    );
    const resolvedRole = resolveProjectPartyRole(roleOptions, payload.role);

    if (!resolvedRole) {
      throw new AuthError('Invalid project party role', 400);
    }

    const isPrimary = resolvedRole.code === 'billing_customer';

    if (isPrimary) {
      await tx
        .update(crmQuotationCustomers)
        .set({ isPrimary: false, updatedAt: new Date() })
        .where(
          and(
            eq(crmQuotationCustomers.quotationId, quotationId),
            eq(crmQuotationCustomers.organizationId, organizationId),
            isNull(crmQuotationCustomers.deletedAt)
          )
        );
    }

    const [created] = await tx
      .insert(crmQuotationCustomers)
      .values({
        id: crypto.randomUUID(),
        organizationId,
        quotationId,
        customerId: payload.customerId,
        role: resolvedRole.id,
        isPrimary,
        remark: payload.remark?.trim() || null
      })
      .returning();

    return created;
  });
}

export async function updateQuotationCustomer(
  quotationId: string,
  relationId: string,
  organizationId: string,
  payload: QuotationCustomerMutationPayload
) {
  await validateQuotationCustomerPayload(organizationId, payload);
  await assertQuotationCustomerBelongsToQuotation(quotationId, relationId, organizationId);
  await assertQuotationProjectPartyNotDuplicated(
    quotationId,
    organizationId,
    payload,
    relationId
  );

  return db.transaction(async (tx) => {
    const roleOptions = await getActiveOptionsByCategory(
      QUOTATION_OPTION_CATEGORIES.projectPartyRole,
      { organizationId }
    );
    const resolvedRole = resolveProjectPartyRole(roleOptions, payload.role);

    if (!resolvedRole) {
      throw new AuthError('Invalid project party role', 400);
    }

    const isPrimary = resolvedRole.code === 'billing_customer';

    if (isPrimary) {
      await tx
        .update(crmQuotationCustomers)
        .set({ isPrimary: false, updatedAt: new Date() })
        .where(
          and(
            eq(crmQuotationCustomers.quotationId, quotationId),
            eq(crmQuotationCustomers.organizationId, organizationId),
            isNull(crmQuotationCustomers.deletedAt)
          )
        );
    }

    const [updated] = await tx
      .update(crmQuotationCustomers)
      .set({
        customerId: payload.customerId,
        role: resolvedRole.id,
        isPrimary,
        remark: payload.remark?.trim() || null,
        updatedAt: new Date()
      })
      .where(eq(crmQuotationCustomers.id, relationId))
      .returning();

    return updated;
  });
}

export async function softDeleteQuotationCustomer(
  quotationId: string,
  relationId: string,
  organizationId: string
) {
  await assertQuotationCustomerBelongsToQuotation(quotationId, relationId, organizationId);

  const [updated] = await db
    .update(crmQuotationCustomers)
    .set({ deletedAt: new Date(), updatedAt: new Date() })
    .where(eq(crmQuotationCustomers.id, relationId))
    .returning();

  return updated;
}

export async function listQuotationTopics(id: string, organizationId: string) {
  await assertQuotationBelongsToOrganization(id, organizationId);
  const topics = await db
    .select()
    .from(crmQuotationTopics)
    .where(
      and(
        eq(crmQuotationTopics.quotationId, id),
        eq(crmQuotationTopics.organizationId, organizationId),
        isNull(crmQuotationTopics.deletedAt)
      )
    )
    .orderBy(asc(crmQuotationTopics.sortOrder), asc(crmQuotationTopics.createdAt));
  const topicItems = await listQuotationTopicItemsByTopicIds(
    topics.map((topic) => topic.id),
    organizationId
  );
  const topicItemMap = new Map<string, QuotationTopicItemRecord[]>();

  for (const item of topicItems.map(mapQuotationTopicItemRecord)) {
    const items = topicItemMap.get(item.topicId) ?? [];
    items.push(item);
    topicItemMap.set(item.topicId, items);
  }

  return topics.map<QuotationTopicRecord>((topic) => ({
    id: topic.id,
    organizationId: topic.organizationId,
    quotationId: topic.quotationId,
    topicType: topic.topicType,
    title: topic.title,
    sortOrder: topic.sortOrder,
    createdAt: topic.createdAt.toISOString(),
    updatedAt: topic.updatedAt.toISOString(),
    deletedAt: topic.deletedAt?.toISOString() ?? null,
    items: topicItemMap.get(topic.id) ?? []
  }));
}

export async function createQuotationTopic(
  quotationId: string,
  organizationId: string,
  payload: QuotationTopicMutationPayload
) {
  await assertQuotationBelongsToOrganization(quotationId, organizationId);
  await validateQuotationTopicPayload(organizationId, payload);

  return db.transaction(async (tx) => {
    const [createdTopic] = await tx
      .insert(crmQuotationTopics)
      .values({
        id: crypto.randomUUID(),
        organizationId,
        quotationId,
        topicType: payload.topicType,
        title: payload.title.trim(),
        sortOrder: payload.sortOrder ?? 0
      })
      .returning();

    if (payload.items.length) {
      await tx.insert(crmQuotationTopicItems).values(
        payload.items.map((item, index) => ({
          id: crypto.randomUUID(),
          organizationId,
          topicId: createdTopic.id,
          content: item.content.trim(),
          sortOrder: item.sortOrder ?? index
        }))
      );
    }

    return createdTopic;
  });
}

export async function updateQuotationTopic(
  quotationId: string,
  topicId: string,
  organizationId: string,
  payload: QuotationTopicMutationPayload
) {
  await assertQuotationTopicBelongsToQuotation(quotationId, topicId, organizationId);
  await validateQuotationTopicPayload(organizationId, payload);

  return db.transaction(async (tx) => {
    const [updatedTopic] = await tx
      .update(crmQuotationTopics)
      .set({
        topicType: payload.topicType,
        title: payload.title.trim(),
        sortOrder: payload.sortOrder ?? 0,
        updatedAt: new Date()
      })
      .where(eq(crmQuotationTopics.id, topicId))
      .returning();

    await tx
      .update(crmQuotationTopicItems)
      .set({ deletedAt: new Date(), updatedAt: new Date() })
      .where(
        and(
          eq(crmQuotationTopicItems.topicId, topicId),
          eq(crmQuotationTopicItems.organizationId, organizationId),
          isNull(crmQuotationTopicItems.deletedAt)
        )
      );

    if (payload.items.length) {
      await tx.insert(crmQuotationTopicItems).values(
        payload.items.map((item, index) => ({
          id: crypto.randomUUID(),
          organizationId,
          topicId,
          content: item.content.trim(),
          sortOrder: item.sortOrder ?? index
        }))
      );
    }

    return updatedTopic;
  });
}

export async function softDeleteQuotationTopic(
  quotationId: string,
  topicId: string,
  organizationId: string
) {
  await assertQuotationTopicBelongsToQuotation(quotationId, topicId, organizationId);
  const now = new Date();

  const [updated] = await db
    .update(crmQuotationTopics)
    .set({ deletedAt: now, updatedAt: now })
    .where(eq(crmQuotationTopics.id, topicId))
    .returning();

  await db
    .update(crmQuotationTopicItems)
    .set({ deletedAt: now, updatedAt: now })
    .where(
      and(
        eq(crmQuotationTopicItems.topicId, topicId),
        eq(crmQuotationTopicItems.organizationId, organizationId)
      )
    );

  return updated;
}

export async function listQuotationFollowups(id: string, organizationId: string) {
  await assertQuotationBelongsToOrganization(id, organizationId);
  const rows = await db
    .select()
    .from(crmQuotationFollowups)
    .where(
      and(
        eq(crmQuotationFollowups.quotationId, id),
        eq(crmQuotationFollowups.organizationId, organizationId),
        isNull(crmQuotationFollowups.deletedAt)
      )
    )
    .orderBy(desc(crmQuotationFollowups.followupDate), desc(crmQuotationFollowups.createdAt));

  return rows.map(mapQuotationFollowupRecord);
}

export async function createQuotationFollowup(
  quotationId: string,
  organizationId: string,
  userId: string,
  payload: QuotationFollowupMutationPayload
) {
  await validateQuotationFollowupPayload(quotationId, organizationId, payload);

  const [created] = await db
    .insert(crmQuotationFollowups)
    .values({
      id: crypto.randomUUID(),
      organizationId,
      quotationId,
      followupDate: new Date(payload.followupDate),
      followupType: payload.followupType,
      contactId: payload.contactId ?? null,
      outcome: payload.outcome?.trim() || null,
      notes: payload.notes?.trim() || null,
      nextFollowupDate: payload.nextFollowupDate ? new Date(payload.nextFollowupDate) : null,
      nextAction: payload.nextAction?.trim() || null,
      createdBy: userId,
      updatedBy: userId
    })
    .returning();

  return created;
}

export async function updateQuotationFollowup(
  quotationId: string,
  followupId: string,
  organizationId: string,
  userId: string,
  payload: QuotationFollowupMutationPayload
) {
  await assertQuotationFollowupBelongsToQuotation(quotationId, followupId, organizationId);
  await validateQuotationFollowupPayload(quotationId, organizationId, payload);

  const [updated] = await db
    .update(crmQuotationFollowups)
    .set({
      followupDate: new Date(payload.followupDate),
      followupType: payload.followupType,
      contactId: payload.contactId ?? null,
      outcome: payload.outcome?.trim() || null,
      notes: payload.notes?.trim() || null,
      nextFollowupDate: payload.nextFollowupDate ? new Date(payload.nextFollowupDate) : null,
      nextAction: payload.nextAction?.trim() || null,
      updatedAt: new Date(),
      updatedBy: userId
    })
    .where(eq(crmQuotationFollowups.id, followupId))
    .returning();

  return updated;
}

export async function softDeleteQuotationFollowup(
  quotationId: string,
  followupId: string,
  organizationId: string,
  userId: string
) {
  await assertQuotationFollowupBelongsToQuotation(quotationId, followupId, organizationId);

  const [updated] = await db
    .update(crmQuotationFollowups)
    .set({ deletedAt: new Date(), updatedAt: new Date(), updatedBy: userId })
    .where(eq(crmQuotationFollowups.id, followupId))
    .returning();

  return updated;
}

export async function listQuotationAttachments(id: string, organizationId: string) {
  await assertQuotationBelongsToOrganization(id, organizationId);
  const rows = await db
    .select()
    .from(crmQuotationAttachments)
    .where(
      and(
        eq(crmQuotationAttachments.quotationId, id),
        eq(crmQuotationAttachments.organizationId, organizationId),
        isNull(crmQuotationAttachments.deletedAt)
      )
    )
    .orderBy(desc(crmQuotationAttachments.uploadedAt));

  return rows.map(mapQuotationAttachmentRecord);
}

export async function createQuotationAttachment(
  quotationId: string,
  organizationId: string,
  userId: string,
  payload: QuotationAttachmentMutationPayload
) {
  await assertQuotationBelongsToOrganization(quotationId, organizationId);

  const [created] = await db
    .insert(crmQuotationAttachments)
    .values({
      id: crypto.randomUUID(),
      organizationId,
      quotationId,
      fileName: payload.fileName.trim(),
      originalFileName: payload.originalFileName.trim(),
      filePath: payload.filePath.trim(),
      fileSize: payload.fileSize ?? null,
      fileType: payload.fileType ?? null,
      description: payload.description?.trim() || null,
      uploadedBy: userId
    })
    .returning();

  return created;
}

export async function updateQuotationAttachment(
  quotationId: string,
  attachmentId: string,
  organizationId: string,
  payload: QuotationAttachmentMutationPayload
) {
  await assertQuotationAttachmentBelongsToQuotation(quotationId, attachmentId, organizationId);

  const [updated] = await db
    .update(crmQuotationAttachments)
    .set({
      fileName: payload.fileName.trim(),
      originalFileName: payload.originalFileName.trim(),
      filePath: payload.filePath.trim(),
      fileSize: payload.fileSize ?? null,
      fileType: payload.fileType ?? null,
      description: payload.description?.trim() || null,
      updatedAt: new Date()
    })
    .where(eq(crmQuotationAttachments.id, attachmentId))
    .returning();

  return updated;
}

export async function softDeleteQuotationAttachment(
  quotationId: string,
  attachmentId: string,
  organizationId: string
) {
  await assertQuotationAttachmentBelongsToQuotation(quotationId, attachmentId, organizationId);

  const [updated] = await db
    .update(crmQuotationAttachments)
    .set({ deletedAt: new Date(), updatedAt: new Date() })
    .where(eq(crmQuotationAttachments.id, attachmentId))
    .returning();

  return updated;
}

export async function createQuotationRevision(
  quotationId: string,
  organizationId: string,
  userId: string,
  revisionRemark?: string
) {
  const parent = await assertQuotationBelongsToOrganization(quotationId, organizationId);
  const statusCode = await resolveOptionCodeById(
    organizationId,
    QUOTATION_OPTION_CATEGORIES.status,
    parent.status
  );

  if (!statusCode || !REVISION_ALLOWED_STATUS_CODES.has(statusCode)) {
    throw new AuthError(
      'Revision is allowed only for approved, sent, accepted, or rejected quotations',
      400
    );
  }
  const revisedStatusId =
    (await resolveOptionIdByCode(organizationId, QUOTATION_OPTION_CATEGORIES.status, 'revised')) ??
    parent.status;

  const familyRootId = parent.parentQuotationId ?? parent.id;
  const familyRows = await db
    .select()
    .from(crmQuotations)
    .where(
      and(
        eq(crmQuotations.organizationId, organizationId),
        isNull(crmQuotations.deletedAt),
        or(eq(crmQuotations.id, familyRootId), eq(crmQuotations.parentQuotationId, familyRootId))!
      )
    )
    .orderBy(desc(crmQuotations.revision));
  const nextRevision = (familyRows[0]?.revision ?? parent.revision) + 1;
  const currentItems = await listQuotationItems(quotationId, organizationId);
  const currentCustomers = await listQuotationCustomers(quotationId, organizationId);
  const currentTopics = await listQuotationTopics(quotationId, organizationId);
  const documentCode = await generateNextDocumentCode({
    organizationId,
    documentType: 'quotation',
    branchId: parent.branchId ?? ''
  });

  return db.transaction(async (tx) => {
    const [created] = await tx
      .insert(crmQuotations)
      .values({
        id: crypto.randomUUID(),
        organizationId,
        branchId: parent.branchId,
        code: documentCode.code,
        enquiryId: parent.enquiryId,
        customerId: parent.customerId,
        contactId: parent.contactId,
        quotationDate: new Date(),
        validUntil: parent.validUntil,
        quotationType: parent.quotationType,
        projectName: parent.projectName,
        projectLocation: parent.projectLocation,
        attention: parent.attention,
        reference: parent.reference,
        notes: parent.notes,
        status: revisedStatusId,
        revision: nextRevision,
        parentQuotationId: familyRootId,
        revisionRemark: revisionRemark?.trim() || null,
        currency: parent.currency,
        exchangeRate: parent.exchangeRate,
        subtotal: parent.subtotal,
        discount: parent.discount,
        discountType: parent.discountType,
        taxRate: parent.taxRate,
        taxAmount: parent.taxAmount,
        totalAmount: parent.totalAmount,
        chancePercent: parent.chancePercent,
        isHotProject: parent.isHotProject,
        competitor: parent.competitor,
        salesmanId: parent.salesmanId,
        isSent: false,
        sentVia: parent.sentVia,
        approvedAt: null,
        approvedArtifactId: null,
        approvedPdfUrl: null,
        approvedSnapshot: null,
        approvedTemplateVersionId: null,
        isActive: parent.isActive,
        createdBy: userId,
        updatedBy: userId
      })
      .returning();

    if (currentItems.length) {
      await tx.insert(crmQuotationItems).values(
        currentItems.map((item) => ({
          id: crypto.randomUUID(),
          organizationId,
          quotationId: created.id,
          itemNumber: item.itemNumber,
          productType: item.productType,
          description: item.description,
          quantity: item.quantity,
          unit: item.unit,
          unitPrice: item.unitPrice,
          discount: item.discount,
          discountType: item.discountType,
          taxRate: item.taxRate,
          totalPrice: item.totalPrice,
          notes: item.notes,
          sortOrder: item.sortOrder
        }))
      );
    }

    if (currentCustomers.length) {
      await tx.insert(crmQuotationCustomers).values(
        currentCustomers.map((item) => ({
          id: crypto.randomUUID(),
          organizationId,
          quotationId: created.id,
          customerId: item.customerId,
          role: item.role,
          isPrimary: item.isPrimary,
          remark: item.remark
        }))
      );
    }

    for (const topic of currentTopics) {
      const [createdTopic] = await tx
        .insert(crmQuotationTopics)
        .values({
          id: crypto.randomUUID(),
          organizationId,
          quotationId: created.id,
          topicType: topic.topicType,
          title: topic.title,
          sortOrder: topic.sortOrder
        })
        .returning();

      if (topic.items.length) {
        await tx.insert(crmQuotationTopicItems).values(
          topic.items.map((item) => ({
            id: crypto.randomUUID(),
            organizationId,
            topicId: createdTopic.id,
            content: item.content,
            sortOrder: item.sortOrder
          }))
        );
      }
    }

    return created;
  });
}

export async function listQuotationRevisions(id: string, organizationId: string) {
  const quotation = await assertQuotationBelongsToOrganization(id, organizationId);
  const familyRootId = quotation.parentQuotationId ?? quotation.id;
  const rows = await db
    .select()
    .from(crmQuotations)
    .where(
      and(
        eq(crmQuotations.organizationId, organizationId),
        isNull(crmQuotations.deletedAt),
        or(eq(crmQuotations.id, familyRootId), eq(crmQuotations.parentQuotationId, familyRootId))!
      )
    )
    .orderBy(asc(crmQuotations.revision), asc(crmQuotations.createdAt));

  return rows.map<QuotationRelationItem>((row) => ({
    id: row.id,
    code: row.code,
    status: row.status,
    quotationType: row.quotationType,
    totalAmount: row.totalAmount,
    updatedAt: row.updatedAt.toISOString()
  }));
}

export async function listEnquiryQuotationRelations(
  enquiryId: string,
  organizationId: string
): Promise<QuotationRelationItem[]> {
  const rows = await db
    .select()
    .from(crmQuotations)
    .where(
      and(
        eq(crmQuotations.organizationId, organizationId),
        eq(crmQuotations.enquiryId, enquiryId),
        isNull(crmQuotations.deletedAt)
      )
    )
    .orderBy(desc(crmQuotations.updatedAt));

  return rows.map((row) => ({
    id: row.id,
    code: row.code,
    status: row.status,
    quotationType: row.quotationType,
    totalAmount: row.totalAmount,
    updatedAt: row.updatedAt.toISOString()
  }));
}

export async function listCustomerQuotationRelations(
  customerId: string,
  organizationId: string
): Promise<QuotationRelationItem[]> {
  const rows = await db
    .select()
    .from(crmQuotations)
    .where(
      and(
        eq(crmQuotations.organizationId, organizationId),
        eq(crmQuotations.customerId, customerId),
        isNull(crmQuotations.deletedAt)
      )
    )
    .orderBy(desc(crmQuotations.updatedAt));

  return rows.map((row) => ({
    id: row.id,
    code: row.code,
    status: row.status,
    quotationType: row.quotationType,
    totalAmount: row.totalAmount,
    updatedAt: row.updatedAt.toISOString()
  }));
}
