import { and, asc, count, desc, eq, ilike, inArray, isNull, or, sql, type SQL } from 'drizzle-orm';
import {
  crmCustomerContacts,
  crmCustomers,
  crmOpportunities,
  crmOpportunityAttachments,
  crmOpportunityCustomers,
  crmOpportunityFollowups,
  crmQuotations,
  memberships,
  users
} from '@/db/schema';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access';
import { auditAction, 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 { buildOrganizationStorageKey, getStorageProvider } from '@/features/foundation/storage/service';
import type {
  OpportunityActivityRecord,
  OpportunityAssignableUserLookup,
  OpportunityAttachmentRecord,
  OpportunityBranchOption,
  OpportunityAssignmentMutationPayload,
  OpportunityCustomerRelationItem,
  OpportunityFilters,
  OpportunityFollowupMutationPayload,
  OpportunityFollowupRecord,
  OpportunityListItem,
  OpportunityMarkCancelledPayload,
  OpportunityMarkLostPayload,
  OpportunityMarkNoQuotationPayload,
  OpportunityMarkWonPayload,
  OpportunityMutationPayload,
  OpportunityOption,
  OpportunityProjectPartyListItem,
  OpportunityProjectPartyMutationPayload,
  OpportunityProjectPartyRecord,
  OpportunityPipelineStage,
  OpportunityReopenPayload,
  OpportunityRecord,
  OpportunityReferenceData
} from '../api/types';
import { resolveHotProjectState } from '@/features/crm/shared/hot-project';
import {
  buildProjectPartyKey,
  PROJECT_PARTY_OPTION_CATEGORY,
  resolveProjectPartyRole
} from '@/features/crm/shared/project-party';
import { PERMISSIONS } from '@/lib/auth/rbac';

const ENQUIRY_OPTION_CATEGORIES = {
  status: 'crm_opportunity_stage',
  productType: 'crm_product_type',
  priority: 'crm_priority',
  leadChannel: 'crm_lead_channel',
  followupType: 'crm_followup_type',
  outcomeStatus: 'crm_opportunity_outcome_status',
  lostReason: 'crm_opportunity_lost_reason',
  cancelReason: 'crm_opportunity_cancel_reason',
  noQuotationReason: 'crm_opportunity_no_quotation_reason',
  projectPartyRole: PROJECT_PARTY_OPTION_CATEGORY
} as const;

const ASSIGNABLE_BUSINESS_ROLES = new Set(['sales_manager', 'sales', 'sales_support']);
const SALES_CREATED_ENQUIRY_ROLES = new Set([
  'sales',
  'sales_support',
  'sales_manager',
  'department_manager',
  'top_manager'
]);
const PURCHASE_ORDER_ATTACHMENT_CATEGORY = 'purchase_order';
const ALLOWED_PO_CONTENT_TYPES = new Set([
  'application/pdf',
  'image/jpeg',
  'image/png',
  'image/webp',
  'application/vnd.ms-excel',
  'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
]);

export interface OpportunityAccessContext {
  organizationId: string;
  userId: string;
  membershipRole: string;
  businessRole: string;
  permissions?: string[];
  branchScopeIds: string[];
  productTypeScopeIds: string[];
  ownershipScope: string;
  branchScopeMode: string;
  productScopeMode: string;
}

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

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

function mapOpportunityRecord(
  row: typeof crmOpportunities.$inferSelect,
  options?: {
    assignedToName?: string | null;
    assignedByName?: string | null;
    closedByName?: string | null;
    lostReason?: { code: string; label: string } | null;
    canViewCommercialData?: boolean;
  }
): OpportunityRecord {
  return {
    id: row.id,
    organizationId: row.organizationId,
    branchId: row.branchId,
    leadId: row.leadId ?? null,
    code: row.code,
    customerId: row.customerId,
    contactId: row.contactId,
    title: row.title,
    description: row.description,
    requirement: row.requirement,
    projectName: row.projectName,
    projectLocation: row.projectLocation,
    productType: row.productType,
    status: row.status,
    stage: row.status,
    outcomeStatus: (row.outcomeStatus ?? 'open') as OpportunityRecord['outcomeStatus'],
    priority: row.priority,
    leadChannel: row.leadChannel,
    estimatedValue: row.estimatedValue,
    chancePercent: row.chancePercent,
    expectedCloseDate: row.expectedCloseDate?.toISOString() ?? null,
    projectCloseDate: row.projectCloseDate?.toISOString() ?? null,
    deliveryDate: row.deliveryDate?.toISOString() ?? null,
    competitor: row.competitor,
    source: row.source,
    notes: row.notes,
    isHotProject: row.isHotProject,
    hotProjectAutoSuggested: row.hotProjectAutoSuggested,
    hotProjectManuallyOverridden: row.hotProjectManuallyOverridden,
    isActive: row.isActive,
    pipelineStage: row.pipelineStage as OpportunityPipelineStage,
    closedAt: row.closedAt?.toISOString() ?? null,
    closedWonAt: row.closedWonAt?.toISOString() ?? null,
    closedLostAt: row.closedLostAt?.toISOString() ?? null,
    closedByUserId: row.closedByUserId,
    closedByName: options?.closedByName ?? null,
    poNumber: row.poNumber,
    poDate: row.poDate?.toISOString() ?? null,
    poAmount: options?.canViewCommercialData === false ? null : row.poAmount,
    poCurrency: row.poCurrency,
    lostReason: row.lostReason,
    lostReasonCode: options?.lostReason?.code ?? null,
    lostReasonLabel: options?.lostReason?.label ?? null,
    lostDetail: row.lostDetail,
    lostCompetitor: row.lostCompetitor,
    lostRemark: row.lostRemark,
    cancelReason: row.cancelReason,
    noQuotationReason: row.noQuotationReason,
    assignedToUserId: row.assignedToUserId,
    assignedToName: options?.assignedToName ?? null,
    assignedAt: row.assignedAt?.toISOString() ?? null,
    assignedBy: row.assignedBy,
    assignedByName: options?.assignedByName ?? null,
    assignmentRemark: row.assignmentRemark,
    createdAt: row.createdAt.toISOString(),
    updatedAt: row.updatedAt.toISOString(),
    deletedAt: row.deletedAt?.toISOString() ?? null,
    createdBy: row.createdBy,
    updatedBy: row.updatedBy
  };
}

function mapAttachmentRecord(row: typeof crmOpportunityAttachments.$inferSelect): OpportunityAttachmentRecord {
  return {
    id: row.id,
    organizationId: row.organizationId,
    opportunityId: row.opportunityId,
    category: row.category,
    fileName: row.fileName,
    originalFileName: row.originalFileName,
    storageProvider: row.storageProvider,
    storageKey: row.storageKey,
    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 canViewOutcomeCommercialData(context?: OpportunityAccessContext) {
  if (!context) {
    return true;
  }

  return (
    context.membershipRole === 'admin' ||
    (context.permissions ?? []).includes(PERMISSIONS.crmQuotationPricingRead)
  );
}

function hasOpportunityFullAccess(context: OpportunityAccessContext) {
  return context.membershipRole === 'admin' || context.ownershipScope !== 'own';
}

function canAccessOpportunityRow(
  row: typeof crmOpportunities.$inferSelect,
  context: OpportunityAccessContext
) {
  if (!hasBranchScopeAccess(context, row.branchId)) {
    return false;
  }

  if (!hasProductScopeAccess(context, row.productType)) {
    return false;
  }

  if (hasOpportunityFullAccess(context)) {
    return true;
  }

  if (context.ownershipScope === 'own') {
    return row.createdBy === context.userId || row.assignedToUserId === context.userId;
  }

  return true;
}

function buildAccessScopedFilters(context: OpportunityAccessContext): SQL[] {
  const filters: SQL[] = [];

  if (context.branchScopeMode === 'assigned' && context.branchScopeIds.length > 0) {
    filters.push(inArray(crmOpportunities.branchId, context.branchScopeIds));
  }

  if (context.productScopeMode === 'assigned' && context.productTypeScopeIds.length > 0) {
    filters.push(inArray(crmOpportunities.productType, context.productTypeScopeIds));
  }

  if (!hasOpportunityFullAccess(context) && context.ownershipScope === 'own') {
    filters.push(
      or(eq(crmOpportunities.createdBy, context.userId), eq(crmOpportunities.assignedToUserId, context.userId))!
    );
  }

  return filters;
}

function mapFollowupRecord(row: typeof crmOpportunityFollowups.$inferSelect): OpportunityFollowupRecord {
  return {
    id: row.id,
    organizationId: row.organizationId,
    opportunityId: row.opportunityId,
    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 mapProjectPartyRecord(
  row: typeof crmOpportunityCustomers.$inferSelect,
  options: { role: { id: string; code: string; label: string } }
): OpportunityProjectPartyRecord {
  return {
    id: row.id,
    organizationId: row.organizationId,
    opportunityId: row.opportunityId,
    customerId: row.customerId,
    role: options.role.id,
    roleCode: options.role.code,
    roleLabel: options.role.label,
    remark: row.remark,
    createdAt: row.createdAt.toISOString(),
    updatedAt: row.updatedAt.toISOString(),
    deletedAt: row.deletedAt?.toISOString() ?? null
  };
}

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

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

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

    const sortMap = {
      code: crmOpportunities.code,
      title: crmOpportunities.title,
      status: crmOpportunities.status,
      priority: crmOpportunities.priority,
      productType: crmOpportunities.productType,
      branch: crmOpportunities.branchId,
      customer: crmOpportunities.customerId,
      updatedAt: crmOpportunities.updatedAt
    } as const;

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

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

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

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

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

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);
  }
}

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;
}

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 listAssignableUsers(organizationId: string): Promise<OpportunityAssignableUserLookup[]> {
  const rows = await db
    .select({
      userId: memberships.userId,
      membershipRole: memberships.role,
      businessRole: memberships.businessRole,
      name: users.name
    })
    .from(memberships)
    .innerJoin(users, eq(memberships.userId, users.id))
    .where(eq(memberships.organizationId, organizationId))
    .orderBy(asc(users.name));

  const uniqueRows = [...new Map(rows.map((row) => [row.userId, row])).values()];

  return uniqueRows
    .filter(
      (row) => row.membershipRole === 'admin' || ASSIGNABLE_BUSINESS_ROLES.has(row.businessRole)
    )
    .map<OpportunityAssignableUserLookup>((row) => ({
      id: row.userId,
      name: row.name,
      membershipRole: row.membershipRole === 'admin' ? 'admin' : 'user',
      businessRole: row.businessRole
    }));
}

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

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

  if (membership.role !== 'admin' && !ASSIGNABLE_BUSINESS_ROLES.has(membership.businessRole)) {
    throw new AuthError('Assignee must be a sales owner', 400);
  }

  return membership;
}

export async function assertOpportunityBelongsToOrganization(
  id: string,
  organizationId: string,
  accessContext?: OpportunityAccessContext
) {
  const [opportunity] = await db
    .select()
    .from(crmOpportunities)
    .where(
      and(
        eq(crmOpportunities.id, id),
        eq(crmOpportunities.organizationId, organizationId),
        isNull(crmOpportunities.deletedAt)
      )
    )
    .limit(1);

  if (!opportunity) {
    throw new AuthError('Opportunity not found', 404);
  }

  if (accessContext && !canAccessOpportunityRow(opportunity, accessContext)) {
    throw new AuthError('Forbidden', 403);
  }

  return opportunity;
}

function isClosedOpportunity(opportunity: typeof crmOpportunities.$inferSelect) {
  return (opportunity.outcomeStatus ?? 'open') !== 'open';
}

function assertOpportunityOpenForEditing(opportunity: typeof crmOpportunities.$inferSelect) {
  if (isClosedOpportunity(opportunity)) {
    throw new AuthError('Closed opportunities must be reopened before editing', 400);
  }
}

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

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

  return followup;
}

async function validateOpportunityPayload(
  organizationId: string,
  payload: OpportunityMutationPayload,
  accessContext?: OpportunityAccessContext
) {
  await assertCustomerBelongsToOrganization(payload.customerId, organizationId);

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

  await assertMasterOptionValue(organizationId, ENQUIRY_OPTION_CATEGORIES.status, payload.status);
  await assertMasterOptionValue(
    organizationId,
    ENQUIRY_OPTION_CATEGORIES.productType,
    payload.productType
  );
  await assertMasterOptionValue(
    organizationId,
    ENQUIRY_OPTION_CATEGORIES.priority,
    payload.priority
  );
  await assertMasterOptionValue(
    organizationId,
    ENQUIRY_OPTION_CATEGORIES.leadChannel,
    payload.leadChannel ?? null
  );

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

  if (accessContext && !hasBranchScopeAccess(accessContext, payload.branchId)) {
    throw new AuthError('Forbidden branch scope', 403);
  }

  if (accessContext && !hasProductScopeAccess(accessContext, payload.productType)) {
    throw new AuthError('Forbidden product scope', 403);
  }

  if (payload.assignedToUserId) {
    await assertAssignableUserBelongsToOrganization(payload.assignedToUserId, organizationId);
  }

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

async function validateFollowupPayload(
  organizationId: string,
  opportunityId: string,
  payload: OpportunityFollowupMutationPayload,
  accessContext?: OpportunityAccessContext
) {
  const opportunity = await assertOpportunityBelongsToOrganization(
    opportunityId,
    organizationId,
    accessContext
  );
  assertOpportunityOpenForEditing(opportunity);

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

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

function buildOpportunityFilters(context: OpportunityAccessContext, filters: OpportunityFilters): SQL[] {
  const pipelineStages = splitFilterValue(filters.pipelineStage);
  const statuses = splitFilterValue(filters.status);
  const stages = splitFilterValue(filters.stage);
  const outcomeStatuses = splitFilterValue(filters.outcomeStatus);
  const productTypes = splitFilterValue(filters.productType);
  const priorities = splitFilterValue(filters.priority);
  const branches = splitFilterValue(filters.branch);
  const customers = splitFilterValue(filters.customer);
  const lostReasons = splitFilterValue(filters.lostReason);

  return [
    eq(crmOpportunities.organizationId, context.organizationId),
    isNull(crmOpportunities.deletedAt),
    ...buildAccessScopedFilters(context),
    ...(pipelineStages.length ? [inArray(crmOpportunities.pipelineStage, pipelineStages)] : []),
    ...(statuses.length ? [inArray(crmOpportunities.status, statuses)] : []),
    ...(stages.length ? [inArray(crmOpportunities.status, stages)] : []),
    ...(outcomeStatuses.length ? [inArray(crmOpportunities.outcomeStatus, outcomeStatuses)] : []),
    ...(productTypes.length ? [inArray(crmOpportunities.productType, productTypes)] : []),
    ...(priorities.length ? [inArray(crmOpportunities.priority, priorities)] : []),
    ...(branches.length ? [inArray(crmOpportunities.branchId, branches)] : []),
    ...(customers.length ? [inArray(crmOpportunities.customerId, customers)] : []),
    ...(lostReasons.length ? [inArray(crmOpportunities.lostReason, lostReasons)] : []),
    ...(filters.closedDateFrom ? [sql`${crmOpportunities.closedAt} >= ${new Date(filters.closedDateFrom)}`] : []),
    ...(filters.closedDateTo ? [sql`${crmOpportunities.closedAt} <= ${new Date(filters.closedDateTo)}`] : []),
    ...(filters.search
      ? [
          or(
            ilike(crmOpportunities.code, `%${filters.search}%`),
            ilike(crmOpportunities.title, `%${filters.search}%`),
            ilike(crmOpportunities.projectName, `%${filters.search}%`),
            ilike(crmOpportunities.competitor, `%${filters.search}%`)
          )!
        ]
      : [])
  ];
}

function derivePipelineStageFromRole(businessRole: string): OpportunityPipelineStage {
  return SALES_CREATED_ENQUIRY_ROLES.has(businessRole) ? 'opportunity' : 'lead';
}

async function resolvePipelineStageForCreate(
  businessRole: string,
): Promise<OpportunityPipelineStage> {
  return derivePipelineStageFromRole(businessRole);
}

async function resolvePipelineStageForUpdate(
  current: typeof crmOpportunities.$inferSelect,
): Promise<OpportunityPipelineStage> {
  if (current.pipelineStage === 'closed_won' || current.pipelineStage === 'closed_lost') {
    return current.pipelineStage as OpportunityPipelineStage;
  }

  if (current.pipelineStage === 'opportunity' || current.assignedToUserId) {
    return 'opportunity';
  }

  return 'lead';
}

export async function getOpportunityReferenceData(
  organizationId: string
): Promise<OpportunityReferenceData> {
  const [
    branches,
    stages,
    outcomeStatuses,
    productTypes,
    priorities,
    leadChannels,
    followupTypes,
    lostReasons,
    cancelReasons,
    noQuotationReasons,
    projectPartyRoles,
    customers,
    contacts,
    assignableUsers
  ] = await Promise.all([
    getUserBranches(),
    getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.status, { organizationId }),
    getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.outcomeStatus, { organizationId }),
    getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.productType, { organizationId }),
    getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.priority, { organizationId }),
    getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.leadChannel, { organizationId }),
    getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.followupType, { organizationId }),
    getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.lostReason, { organizationId }),
    getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.cancelReason, { organizationId }),
    getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.noQuotationReason, { organizationId }),
    getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.projectPartyRole, { organizationId }),
    db
      .select({
        id: crmCustomers.id,
        code: crmCustomers.code,
        name: crmCustomers.name,
        branchId: crmCustomers.branchId,
        ownerUserId: crmCustomers.ownerUserId
      })
      .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)),
    listAssignableUsers(organizationId)
  ]);
  const ownerIds = [...new Set(customers.map((customer) => customer.ownerUserId).filter(Boolean) as string[])];
  const ownerRows = ownerIds.length
    ? await db
        .select({ id: users.id, name: users.name })
        .from(users)
        .where(inArray(users.id, ownerIds))
    : [];
  const ownerMap = new Map(ownerRows.map((row) => [row.id, row.name]));

  return {
    branches: branches.map(mapBranch),
    statuses: stages.map(mapOption),
    stages: stages.map(mapOption),
    outcomeStatuses: outcomeStatuses.map(mapOption),
    productTypes: productTypes.map(mapOption),
    priorities: priorities.map(mapOption),
    leadChannels: leadChannels.map(mapOption),
    followupTypes: followupTypes.map(mapOption),
    lostReasons: lostReasons.map(mapOption),
    cancelReasons: cancelReasons.map(mapOption),
    noQuotationReasons: noQuotationReasons.map(mapOption),
    projectPartyRoles: projectPartyRoles.map(mapOption),
    customers: customers.map((customer) => ({
      id: customer.id,
      code: customer.code,
      name: customer.name,
      branchId: customer.branchId,
      ownerUserId: customer.ownerUserId ?? null,
      ownerName: customer.ownerUserId ? (ownerMap.get(customer.ownerUserId) ?? null) : null
    })),
    contacts: contacts.map((contact) => ({
      id: contact.id,
      customerId: contact.customerId,
      name: contact.name,
      email: contact.email,
      mobile: contact.mobile,
      isPrimary: contact.isPrimary
    })),
    assignableUsers
  };
}

export async function listOpportunities(
  context: OpportunityAccessContext,
  filters: OpportunityFilters
): Promise<{ items: OpportunityListItem[]; totalItems: number }> {
  const page = filters.page ?? 1;
  const limit = filters.limit ?? 10;
  const whereFilters = buildOpportunityFilters(context, filters);
  const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
  const offset = (page - 1) * limit;

  const [totalResult] = await db.select({ value: count() }).from(crmOpportunities).where(where);
  const rows = await db
    .select()
    .from(crmOpportunities)
    .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 assignedUserIds = [
    ...new Set(rows.map((row) => row.assignedToUserId).filter(Boolean) as string[])
  ];
  const createdByUserIds = [...new Set(rows.map((row) => row.createdBy).filter(Boolean) as string[])];
  const opportunityIds = rows.map((row) => row.id);

  const [customers, contacts, assignedUsers, createdByUsers, followupCounts, quotationCounts, nextFollowups] = await Promise.all([
    customerIds.length
      ? db
          .select()
          .from(crmCustomers)
          .where(
            and(
              eq(crmCustomers.organizationId, context.organizationId),
              inArray(crmCustomers.id, customerIds)
            )
          )
      : [],
    contactIds.length
      ? db
          .select()
          .from(crmCustomerContacts)
          .where(
            and(
              eq(crmCustomerContacts.organizationId, context.organizationId),
              inArray(crmCustomerContacts.id, contactIds)
            )
          )
      : [],
    assignedUserIds.length
      ? db
          .select({ id: users.id, name: users.name })
          .from(users)
          .where(inArray(users.id, assignedUserIds))
      : [],
    createdByUserIds.length
      ? db
          .select({ id: users.id, name: users.name })
          .from(users)
          .where(inArray(users.id, createdByUserIds))
      : [],
    opportunityIds.length
      ? db
          .select({
            opportunityId: crmOpportunityFollowups.opportunityId,
            value: count()
          })
          .from(crmOpportunityFollowups)
          .where(
            and(
              eq(crmOpportunityFollowups.organizationId, context.organizationId),
              inArray(crmOpportunityFollowups.opportunityId, opportunityIds),
              isNull(crmOpportunityFollowups.deletedAt)
            )
          )
          .groupBy(crmOpportunityFollowups.opportunityId)
      : [],
    opportunityIds.length
      ? db
          .select({
            opportunityId: crmQuotations.opportunityId,
            value: count()
          })
          .from(crmQuotations)
          .where(
            and(
              eq(crmQuotations.organizationId, context.organizationId),
              inArray(crmQuotations.opportunityId, opportunityIds),
              isNull(crmQuotations.deletedAt)
            )
          )
          .groupBy(crmQuotations.opportunityId)
      : [],
    opportunityIds.length
      ? db
          .select({
            opportunityId: crmOpportunityFollowups.opportunityId,
            nextFollowupDate: crmOpportunityFollowups.nextFollowupDate
          })
          .from(crmOpportunityFollowups)
          .where(
            and(
              eq(crmOpportunityFollowups.organizationId, context.organizationId),
              inArray(crmOpportunityFollowups.opportunityId, opportunityIds),
              isNull(crmOpportunityFollowups.deletedAt)
            )
          )
          .orderBy(asc(crmOpportunityFollowups.nextFollowupDate))
      : []
  ]);

  const customerMap = new Map(customers.map((customer) => [customer.id, customer.name]));
  const contactMap = new Map(contacts.map((contact) => [contact.id, contact.name]));
  const assignedUserMap = new Map(assignedUsers.map((user) => [user.id, user.name]));
  const createdByUserMap = new Map(createdByUsers.map((user) => [user.id, user.name]));
  const followupCountMap = new Map(followupCounts.map((item) => [item.opportunityId, item.value]));
  const quotationCountMap = new Map(
    quotationCounts
      .filter((item) => item.opportunityId !== null)
      .map((item) => [item.opportunityId as string, item.value])
  );
  const nextFollowupMap = new Map<string, string | null>();

  for (const item of nextFollowups) {
    if (!item.nextFollowupDate) {
      continue;
    }

    if (!nextFollowupMap.has(item.opportunityId)) {
      nextFollowupMap.set(item.opportunityId, item.nextFollowupDate.toISOString());
    }
  }

  return {
    items: rows.map((row) => ({
      ...mapOpportunityRecord(row, {
        assignedToName: row.assignedToUserId
          ? (assignedUserMap.get(row.assignedToUserId) ?? null)
          : null
      }),
      customerName: customerMap.get(row.customerId) ?? 'Unknown customer',
      contactName: row.contactId ? (contactMap.get(row.contactId) ?? null) : null,
      followupCount: followupCountMap.get(row.id) ?? 0,
      quotationCount: quotationCountMap.get(row.id) ?? 0,
      nextFollowupDate: nextFollowupMap.get(row.id) ?? null,
      createdByName: createdByUserMap.get(row.createdBy) ?? null
    })),
    totalItems: totalResult?.value ?? 0
  };
}

export async function getOpportunityDetail(
  id: string,
  organizationId: string,
  accessContext?: OpportunityAccessContext
): Promise<OpportunityRecord> {
  const opportunity = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);
  const relatedUserIds = [
    opportunity.assignedToUserId,
    opportunity.assignedBy,
    opportunity.closedByUserId
  ].filter(Boolean) as string[];
  const [userRows, lostReasonOptions] = await Promise.all([
    relatedUserIds.length
      ? db.select({ id: users.id, name: users.name }).from(users).where(inArray(users.id, relatedUserIds))
      : [],
    getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.lostReason, { organizationId })
  ]);
  const userMap = new Map(userRows.map((row) => [row.id, row.name]));
  const lostReasonOption =
    lostReasonOptions.find((option) => option.id === opportunity.lostReason || option.code === opportunity.lostReason) ??
    null;

  return mapOpportunityRecord(opportunity, {
    assignedToName: opportunity.assignedToUserId
      ? (userMap.get(opportunity.assignedToUserId) ?? null)
      : null,
    assignedByName: opportunity.assignedBy ? (userMap.get(opportunity.assignedBy) ?? null) : null,
    closedByName: opportunity.closedByUserId ? (userMap.get(opportunity.closedByUserId) ?? null) : null,
    lostReason: lostReasonOption
      ? { code: lostReasonOption.code, label: lostReasonOption.label }
      : null,
    canViewCommercialData: canViewOutcomeCommercialData(accessContext)
  });
}

async function buildPreparedProjectParties(
  organizationId: string,
  billingCustomerId: string,
  projectParties: OpportunityProjectPartyMutationPayload[] = []
) {
  const roleOptions = await getActiveOptionsByCategory(ENQUIRY_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; 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,
        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,
      remark: null
    });
  }

  return [...prepared.values()];
}

async function syncOpportunityProjectParties(
  tx: Pick<typeof db, 'insert' | 'update'>,
  organizationId: string,
  opportunityId: string,
  billingCustomerId: string,
  payload: OpportunityProjectPartyMutationPayload[]
) {
  const now = new Date();
  const preparedParties = await buildPreparedProjectParties(
    organizationId,
    billingCustomerId,
    payload
  );

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

  if (!preparedParties.length) {
    return;
  }

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

export async function listOpportunityProjectParties(
  id: string,
  organizationId: string,
  accessContext?: OpportunityAccessContext
): Promise<OpportunityProjectPartyListItem[]> {
  await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);
  const [relations, customers, roleOptions] = await Promise.all([
    db
      .select()
      .from(crmOpportunityCustomers)
      .where(
        and(
          eq(crmOpportunityCustomers.opportunityId, id),
          eq(crmOpportunityCustomers.organizationId, organizationId),
          isNull(crmOpportunityCustomers.deletedAt)
        )
      )
      .orderBy(asc(crmOpportunityCustomers.createdAt)),
    db
      .select()
      .from(crmCustomers)
      .where(and(eq(crmCustomers.organizationId, organizationId), isNull(crmCustomers.deletedAt))),
    getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.projectPartyRole, { organizationId })
  ]);

  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 {
        ...mapProjectPartyRecord(row, { role: resolvedRole }),
        customerName: customerMap.get(row.customerId)?.name ?? 'Unknown customer',
        customerCode: customerMap.get(row.customerId)?.code ?? '-'
      } satisfies OpportunityProjectPartyListItem;
    })
    .filter((item): item is OpportunityProjectPartyListItem => item !== null);
}

export async function getOpportunityActivity(
  id: string,
  organizationId: string,
  accessContext?: OpportunityAccessContext
): Promise<OpportunityActivityRecord[]> {
  await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);
  const auditLogs = await listAuditLogs({
    organizationId,
    entityId: id,
    limit: 30
  });
  const filteredLogs = auditLogs.filter(
    (log) =>
      (log.entityType === 'crm_opportunity' && log.entityId === id) ||
      (log.entityType === 'crm_opportunity_followup' &&
        ((log.afterData as { opportunityId?: string } | null)?.opportunityId === id ||
          (log.beforeData as { opportunityId?: string } | null)?.opportunityId === id))
  );
  const userIds = [...new Set(filteredLogs.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 filteredLogs.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,
    beforeData: log.beforeData,
    afterData: log.afterData,
    requestId: log.requestId
  }));
}

export async function createOpportunity(
  organizationId: string,
  userId: string,
  accessContext: OpportunityAccessContext,
  payload: OpportunityMutationPayload
) {
  await validateOpportunityPayload(organizationId, payload, accessContext);
  const hotProjectState = resolveHotProjectState(payload);
  const pipelineStage = await resolvePipelineStageForCreate(accessContext.businessRole);

  const documentCode = await generateNextDocumentCode({
    organizationId,
    documentType: 'opportunity',
    branchId: payload.branchId ?? ''
  });

  return db.transaction(async (tx) => {
    const [created] = await tx
      .insert(crmOpportunities)
      .values({
        id: crypto.randomUUID(),
        organizationId,
        branchId: payload.branchId ?? null,
        code: documentCode.code,
        customerId: payload.customerId,
        contactId: payload.contactId ?? null,
        title: payload.title.trim(),
        description: payload.description?.trim() || null,
        requirement: payload.requirement?.trim() || null,
        projectName: payload.projectName?.trim() || null,
        projectLocation: payload.projectLocation?.trim() || null,
        productType: payload.productType,
        status: payload.status,
        priority: payload.priority,
        leadChannel: payload.leadChannel ?? null,
        estimatedValue: payload.estimatedValue ?? null,
        chancePercent: payload.chancePercent ?? null,
        expectedCloseDate: payload.expectedCloseDate ? new Date(payload.expectedCloseDate) : null,
        projectCloseDate: payload.projectCloseDate ? new Date(payload.projectCloseDate) : null,
        deliveryDate: payload.deliveryDate ? new Date(payload.deliveryDate) : null,
        competitor: payload.competitor?.trim() || null,
        source: payload.source?.trim() || null,
        notes: payload.notes?.trim() || null,
        isHotProject: hotProjectState.isHotProject,
        hotProjectAutoSuggested: hotProjectState.hotProjectAutoSuggested,
        hotProjectManuallyOverridden: hotProjectState.hotProjectManuallyOverridden,
        isActive: payload.isActive ?? true,
        pipelineStage,
        assignedToUserId: payload.assignedToUserId ?? null,
        assignedAt: payload.assignedToUserId ? new Date() : null,
        assignedBy: payload.assignedToUserId ? userId : null,
        assignmentRemark: null,
        createdBy: userId,
        updatedBy: userId
      })
      .returning();

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

    return created;
  });
}

export async function updateOpportunity(
  id: string,
  organizationId: string,
  userId: string,
  payload: OpportunityMutationPayload,
  accessContext?: OpportunityAccessContext
) {
  await validateOpportunityPayload(organizationId, payload, accessContext);
  const hotProjectState = resolveHotProjectState(payload);
  const current = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);
  assertOpportunityOpenForEditing(current);
  const pipelineStage = await resolvePipelineStageForUpdate(current);

  return db.transaction(async (tx) => {
    const [updated] = await tx
      .update(crmOpportunities)
      .set({
        branchId: payload.branchId ?? null,
        customerId: payload.customerId,
        contactId: payload.contactId ?? null,
        title: payload.title.trim(),
        description: payload.description?.trim() || null,
        requirement: payload.requirement?.trim() || null,
        projectName: payload.projectName?.trim() || null,
        projectLocation: payload.projectLocation?.trim() || null,
        productType: payload.productType,
        status: payload.status,
        priority: payload.priority,
        leadChannel: payload.leadChannel ?? null,
        estimatedValue: payload.estimatedValue ?? null,
        chancePercent: payload.chancePercent ?? null,
        expectedCloseDate: payload.expectedCloseDate ? new Date(payload.expectedCloseDate) : null,
        projectCloseDate: payload.projectCloseDate ? new Date(payload.projectCloseDate) : null,
        deliveryDate: payload.deliveryDate ? new Date(payload.deliveryDate) : null,
        competitor: payload.competitor?.trim() || null,
        source: payload.source?.trim() || null,
        notes: payload.notes?.trim() || null,
        isHotProject: hotProjectState.isHotProject,
        hotProjectAutoSuggested: hotProjectState.hotProjectAutoSuggested,
        hotProjectManuallyOverridden: hotProjectState.hotProjectManuallyOverridden,
        isActive: payload.isActive ?? true,
        pipelineStage,
        updatedBy: userId,
        updatedAt: new Date()
      })
      .where(eq(crmOpportunities.id, id))
      .returning();

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

    return updated;
  });
}

export async function softDeleteOpportunity(
  id: string,
  organizationId: string,
  userId: string,
  accessContext?: OpportunityAccessContext
) {
  await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);
  const now = new Date();

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

  await db
    .update(crmOpportunityFollowups)
    .set({
      deletedAt: now,
      updatedAt: now,
      updatedBy: userId
    })
    .where(
      and(
        eq(crmOpportunityFollowups.opportunityId, id),
        eq(crmOpportunityFollowups.organizationId, organizationId)
      )
    );

  await db
    .update(crmOpportunityCustomers)
    .set({
      deletedAt: now,
      updatedAt: now
    })
    .where(
      and(
        eq(crmOpportunityCustomers.opportunityId, id),
        eq(crmOpportunityCustomers.organizationId, organizationId),
        isNull(crmOpportunityCustomers.deletedAt)
      )
    );

  return updated;
}

async function updateOpportunityAssignment(
  id: string,
  organizationId: string,
  userId: string,
  payload: OpportunityAssignmentMutationPayload,
  mode: 'assign' | 'reassign',
  accessContext?: OpportunityAccessContext
) {
  const opportunity = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);

  if (mode === 'assign' && opportunity.assignedToUserId) {
    throw new AuthError('Opportunity is already assigned. Use reassign instead.', 400);
  }

  if (mode === 'reassign' && !opportunity.assignedToUserId) {
    throw new AuthError('Opportunity is not assigned yet. Use assign instead.', 400);
  }

  await assertAssignableUserBelongsToOrganization(payload.assignedToUserId, organizationId);

  const [updated] = await db
    .update(crmOpportunities)
    .set({
      pipelineStage: 'opportunity',
      assignedToUserId: payload.assignedToUserId,
      assignedAt: new Date(),
      assignedBy: userId,
      assignmentRemark: payload.remark?.trim() || null,
      updatedAt: new Date(),
      updatedBy: userId
    })
    .where(eq(crmOpportunities.id, id))
    .returning();

  return updated;
}

export async function assignOpportunityToUser(
  id: string,
  organizationId: string,
  userId: string,
  payload: OpportunityAssignmentMutationPayload,
  accessContext?: OpportunityAccessContext
) {
  return updateOpportunityAssignment(id, organizationId, userId, payload, 'assign', accessContext);
}

export async function reassignOpportunityToUser(
  id: string,
  organizationId: string,
  userId: string,
  payload: OpportunityAssignmentMutationPayload,
  accessContext?: OpportunityAccessContext
) {
  return updateOpportunityAssignment(id, organizationId, userId, payload, 'reassign', accessContext);
}

export async function listOpportunityFollowups(
  id: string,
  organizationId: string,
  accessContext?: OpportunityAccessContext
) {
  await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);
  const rows = await db
    .select()
    .from(crmOpportunityFollowups)
    .where(
      and(
        eq(crmOpportunityFollowups.opportunityId, id),
        eq(crmOpportunityFollowups.organizationId, organizationId),
        isNull(crmOpportunityFollowups.deletedAt)
      )
    )
    .orderBy(desc(crmOpportunityFollowups.followupDate));

  return rows.map(mapFollowupRecord);
}

export async function createOpportunityFollowup(
  opportunityId: string,
  organizationId: string,
  userId: string,
  payload: OpportunityFollowupMutationPayload,
  accessContext?: OpportunityAccessContext
) {
  await validateFollowupPayload(organizationId, opportunityId, payload, accessContext);

  const [created] = await db
    .insert(crmOpportunityFollowups)
    .values({
      id: crypto.randomUUID(),
      organizationId,
      opportunityId,
      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 updateOpportunityFollowup(
  opportunityId: string,
  followupId: string,
  organizationId: string,
  userId: string,
  payload: OpportunityFollowupMutationPayload,
  accessContext?: OpportunityAccessContext
) {
  await validateFollowupPayload(organizationId, opportunityId, payload, accessContext);
  await assertFollowupBelongsToOpportunity(followupId, opportunityId, organizationId);

  const [updated] = await db
    .update(crmOpportunityFollowups)
    .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(crmOpportunityFollowups.id, followupId))
    .returning();

  return updated;
}

export async function softDeleteOpportunityFollowup(
  opportunityId: string,
  followupId: string,
  organizationId: string,
  userId: string,
  accessContext?: OpportunityAccessContext
) {
  await assertOpportunityBelongsToOrganization(opportunityId, organizationId, accessContext);
  await assertFollowupBelongsToOpportunity(followupId, opportunityId, organizationId);

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

  return updated;
}

function ensureOpportunityStageForOutcome(
  opportunity: typeof crmOpportunities.$inferSelect,
  expectedStage: OpportunityPipelineStage
) {
  if (opportunity.pipelineStage !== expectedStage) {
    throw new AuthError(`Opportunity must be in ${expectedStage} stage`, 400);
  }
}

export async function markOpportunityAsWon(
  id: string,
  organizationId: string,
  userId: string,
  payload: OpportunityMarkWonPayload,
  accessContext?: OpportunityAccessContext
) {
  const opportunity = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);
  ensureOpportunityStageForOutcome(opportunity, 'opportunity');

  if (!payload.poNumber.trim()) {
    throw new AuthError('PO number is required', 400);
  }

  const now = new Date();
  const [updated] = await db
    .update(crmOpportunities)
    .set({
      pipelineStage: 'closed_won',
      closedWonAt: now,
      closedLostAt: null,
      closedByUserId: userId,
      poNumber: payload.poNumber.trim(),
      poDate: new Date(payload.poDate),
      poAmount: payload.poAmount ?? null,
      poCurrency: payload.poCurrency?.trim() || null,
      lostReason: null,
      lostCompetitor: null,
      lostRemark: null,
      updatedAt: now,
      updatedBy: userId
    })
    .where(eq(crmOpportunities.id, id))
    .returning();

  await auditAction({
    organizationId,
    branchId: updated.branchId,
    userId,
    entityType: 'crm_opportunity',
    entityId: id,
    action: 'mark_won',
    afterData: {
      poNumber: updated.poNumber,
      poAmount: updated.poAmount,
      poCurrency: updated.poCurrency,
      remark: payload.remark?.trim() || null,
      closedBy: userId,
      closedAt: updated.closedWonAt?.toISOString() ?? null
    }
  });

  return updated;
}

export async function markOpportunityAsLost(
  id: string,
  organizationId: string,
  userId: string,
  payload: OpportunityMarkLostPayload,
  accessContext?: OpportunityAccessContext
) {
  const opportunity = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);
  ensureOpportunityStageForOutcome(opportunity, 'opportunity');
  await assertMasterOptionValue(organizationId, ENQUIRY_OPTION_CATEGORIES.lostReason, payload.lostReason);

  const lostReasonCode =
    (await resolveOptionCodeById(organizationId, ENQUIRY_OPTION_CATEGORIES.lostReason, payload.lostReason)) ??
    payload.lostReason;
  const now = new Date();
  const [updated] = await db
    .update(crmOpportunities)
    .set({
      pipelineStage: 'closed_lost',
      closedWonAt: null,
      closedLostAt: now,
      closedByUserId: userId,
      poNumber: null,
      poDate: null,
      poAmount: null,
      poCurrency: null,
      lostReason: payload.lostReason,
      lostCompetitor: payload.lostCompetitor?.trim() || null,
      lostRemark: payload.lostRemark?.trim() || null,
      updatedAt: now,
      updatedBy: userId
    })
    .where(eq(crmOpportunities.id, id))
    .returning();

  await auditAction({
    organizationId,
    branchId: updated.branchId,
    userId,
    entityType: 'crm_opportunity',
    entityId: id,
    action: 'mark_lost',
    afterData: {
      lostReason: lostReasonCode,
      lostCompetitor: updated.lostCompetitor,
      closedBy: userId,
      closedAt: updated.closedLostAt?.toISOString() ?? null
    }
  });

  return updated;
}

export async function reopenLostOpportunity(
  id: string,
  organizationId: string,
  userId: string,
  payload: OpportunityReopenPayload,
  accessContext?: OpportunityAccessContext
) {
  const opportunity = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);

  if (opportunity.pipelineStage === 'closed_won') {
    throw new AuthError('Won opportunities cannot be reopened', 400);
  }

  ensureOpportunityStageForOutcome(opportunity, 'closed_lost');
  const now = new Date();
  const [updated] = await db
    .update(crmOpportunities)
    .set({
      pipelineStage: 'opportunity',
      closedLostAt: null,
      closedByUserId: null,
      lostReason: null,
      lostCompetitor: null,
      lostRemark: null,
      updatedAt: now,
      updatedBy: userId
    })
    .where(eq(crmOpportunities.id, id))
    .returning();

  await auditAction({
    organizationId,
    branchId: updated.branchId,
    userId,
    entityType: 'crm_opportunity',
    entityId: id,
    action: 'reopen',
    afterData: {
      reopenReason: payload.reopenReason.trim(),
      previousStage: opportunity.pipelineStage,
      nextStage: updated.pipelineStage
    }
  });

  return updated;
}

export async function listOpportunityAttachments(
  opportunityId: string,
  organizationId: string,
  accessContext?: OpportunityAccessContext
) {
  await assertOpportunityBelongsToOrganization(opportunityId, organizationId, accessContext);
  const rows = await db
    .select()
    .from(crmOpportunityAttachments)
    .where(
      and(
        eq(crmOpportunityAttachments.organizationId, organizationId),
        eq(crmOpportunityAttachments.opportunityId, opportunityId),
        eq(crmOpportunityAttachments.category, PURCHASE_ORDER_ATTACHMENT_CATEGORY),
        isNull(crmOpportunityAttachments.deletedAt)
      )
    )
    .orderBy(desc(crmOpportunityAttachments.uploadedAt));

  return rows.map(mapAttachmentRecord);
}

export async function getOpportunityAttachment(
  opportunityId: string,
  attachmentId: string,
  organizationId: string,
  accessContext?: OpportunityAccessContext
) {
  await assertOpportunityBelongsToOrganization(opportunityId, organizationId, accessContext);
  const [attachment] = await db
    .select()
    .from(crmOpportunityAttachments)
    .where(
      and(
        eq(crmOpportunityAttachments.id, attachmentId),
        eq(crmOpportunityAttachments.organizationId, organizationId),
        eq(crmOpportunityAttachments.opportunityId, opportunityId),
        isNull(crmOpportunityAttachments.deletedAt)
      )
    )
    .limit(1);

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

  return attachment;
}

export async function uploadPurchaseOrderAttachment(input: {
  opportunityId: string;
  organizationId: string;
  userId: string;
  file: {
    name: string;
    type: string;
    size: number;
    buffer: Buffer;
  };
  description?: string | null;
  accessContext?: OpportunityAccessContext;
}) {
  const opportunity = await assertOpportunityBelongsToOrganization(
    input.opportunityId,
    input.organizationId,
    input.accessContext
  );

  if (opportunity.pipelineStage !== 'closed_won') {
    throw new AuthError('PO attachment is available only for won opportunities', 400);
  }

  if (!ALLOWED_PO_CONTENT_TYPES.has(input.file.type)) {
    throw new AuthError('Unsupported PO attachment file type', 400);
  }

  const storageProvider = getStorageProvider();
  const safeFileName = input.file.name.replace(/[^a-zA-Z0-9._-]/g, '-');
  const storageKey = buildOrganizationStorageKey(input.organizationId, [
    'opportunities',
    input.opportunityId,
    'purchase-orders',
    `${Date.now()}-${safeFileName}`
  ]);
  const stored = await storageProvider.putObject({
    key: storageKey,
    body: input.file.buffer,
    contentType: input.file.type,
    fileName: safeFileName
  });
  const [created] = await db
    .insert(crmOpportunityAttachments)
    .values({
      id: crypto.randomUUID(),
      organizationId: input.organizationId,
      opportunityId: input.opportunityId,
      category: PURCHASE_ORDER_ATTACHMENT_CATEGORY,
      fileName: safeFileName,
      originalFileName: input.file.name,
      storageProvider: stored.provider,
      storageKey: stored.key,
      fileSize: stored.size,
      fileType: input.file.type,
      description: input.description?.trim() || null,
      uploadedBy: input.userId
    })
    .returning();

  await auditAction({
    organizationId: input.organizationId,
    branchId: opportunity.branchId,
    userId: input.userId,
    entityType: 'crm_opportunity',
    entityId: input.opportunityId,
    action: 'upload_po',
    afterData: {
      attachmentId: created.id,
      poNumber: opportunity.poNumber,
      fileName: created.fileName,
      fileType: created.fileType
    }
  });

  return created;
}

export async function listCustomerOpportunityRelations(
  customerId: string,
  organizationId: string,
  accessContext?: OpportunityAccessContext
): Promise<OpportunityCustomerRelationItem[]> {
  const rows = await db
    .select()
    .from(crmOpportunities)
    .where(
      and(
        eq(crmOpportunities.customerId, customerId),
        eq(crmOpportunities.organizationId, organizationId),
        isNull(crmOpportunities.deletedAt)
      )
    )
    .orderBy(desc(crmOpportunities.updatedAt))
    .limit(10);

  return rows
    .filter((row) => !accessContext || canAccessOpportunityRow(row, accessContext))
    .map((row) => ({
      id: row.id,
      code: row.code,
      title: row.title,
      status: row.status,
      productType: row.productType,
      updatedAt: row.updatedAt.toISOString()
    }));
}

export async function syncOpportunityPipelineStageFromQuotationStatus(
  opportunityId: string,
  organizationId: string,
  quotationStatusCode: string | null
) {
  const [opportunity] = await db
    .select()
    .from(crmOpportunities)
    .where(
      and(
        eq(crmOpportunities.id, opportunityId),
        eq(crmOpportunities.organizationId, organizationId),
        isNull(crmOpportunities.deletedAt)
      )
    )
    .limit(1);

  if (!opportunity) {
    return;
  }

  if ((opportunity.outcomeStatus ?? 'open') !== 'open') {
    return;
  }

  const nextStage = quotationStatusCode ? 'quotation_created' : opportunity.status;
  if (nextStage === opportunity.status && opportunity.pipelineStage === 'opportunity') {
    return;
  }

  await db
    .update(crmOpportunities)
    .set({
      status: nextStage,
      pipelineStage: 'opportunity',
      updatedAt: new Date()
    })
    .where(eq(crmOpportunities.id, opportunityId));
}
