import { and, asc, count, desc, eq, ilike, inArray, isNull, or, type SQL } from 'drizzle-orm';
import {
  crmCustomerContacts,
  crmCustomers,
  crmEnquiries,
  crmEnquiryCustomers,
  crmEnquiryFollowups,
  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 type {
  EnquiryActivityRecord,
  EnquiryAssignableUserLookup,
  EnquiryBranchOption,
  EnquiryAssignmentMutationPayload,
  EnquiryCustomerRelationItem,
  EnquiryFilters,
  EnquiryFollowupMutationPayload,
  EnquiryFollowupRecord,
  EnquiryListItem,
  EnquiryMutationPayload,
  EnquiryOption,
  EnquiryProjectPartyListItem,
  EnquiryProjectPartyMutationPayload,
  EnquiryProjectPartyRecord,
  EnquiryPipelineStage,
  EnquiryRecord,
  EnquiryReferenceData
} from '../api/types';
import {
  buildProjectPartyKey,
  PROJECT_PARTY_OPTION_CATEGORY,
  resolveProjectPartyRole
} from '@/features/crm/shared/project-party';

const ENQUIRY_OPTION_CATEGORIES = {
  status: 'crm_enquiry_status',
  productType: 'crm_product_type',
  priority: 'crm_priority',
  leadChannel: 'crm_lead_channel',
  followupType: 'crm_followup_type',
  projectPartyRole: PROJECT_PARTY_OPTION_CATEGORY
} as const;

const ASSIGNABLE_BUSINESS_ROLES = new Set(['sales_manager', 'sales', 'sales_support']);
const SALES_OWNED_SCOPE_ROLES = new Set(['sales', 'sales_support']);
const ENQUIRY_FULL_ACCESS_ROLES = new Set([
  'marketing',
  'sales_manager',
  'department_manager',
  'top_manager'
]);
const SALES_CREATED_ENQUIRY_ROLES = new Set([
  'sales',
  'sales_support',
  'sales_manager',
  'department_manager',
  'top_manager'
]);
const CLOSED_LOST_STATUS_CODES = new Set(['closed_lost', 'cancelled']);
const CLOSED_WON_QUOTATION_STATUS_CODES = new Set(['accepted']);
const CLOSED_LOST_QUOTATION_STATUS_CODES = new Set(['lost', 'rejected']);

export interface EnquiryAccessContext {
  organizationId: string;
  userId: string;
  membershipRole: string;
  businessRole: string;
}

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

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

function mapEnquiryRecord(
  row: typeof crmEnquiries.$inferSelect,
  options?: { assignedToName?: string | null; assignedByName?: string | null }
): EnquiryRecord {
  return {
    id: row.id,
    organizationId: row.organizationId,
    branchId: row.branchId,
    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,
    priority: row.priority,
    leadChannel: row.leadChannel,
    estimatedValue: row.estimatedValue,
    chancePercent: row.chancePercent,
    expectedCloseDate: row.expectedCloseDate?.toISOString() ?? null,
    competitor: row.competitor,
    source: row.source,
    notes: row.notes,
    isHotProject: row.isHotProject,
    isActive: row.isActive,
    pipelineStage: row.pipelineStage as EnquiryPipelineStage,
    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 hasEnquiryFullAccess(context: EnquiryAccessContext) {
  return (
    context.membershipRole === 'admin' || ENQUIRY_FULL_ACCESS_ROLES.has(context.businessRole)
  );
}

function canAccessEnquiryRow(
  row: typeof crmEnquiries.$inferSelect,
  context: EnquiryAccessContext
) {
  if (hasEnquiryFullAccess(context)) {
    return true;
  }

  if (SALES_OWNED_SCOPE_ROLES.has(context.businessRole)) {
    return row.createdBy === context.userId || row.assignedToUserId === context.userId;
  }

  return true;
}

function buildAccessScopedFilters(context: EnquiryAccessContext): SQL[] {
  if (hasEnquiryFullAccess(context)) {
    return [];
  }

  if (SALES_OWNED_SCOPE_ROLES.has(context.businessRole)) {
    return [
      or(eq(crmEnquiries.createdBy, context.userId), eq(crmEnquiries.assignedToUserId, context.userId))!
    ];
  }

  return [];
}

function mapFollowupRecord(row: typeof crmEnquiryFollowups.$inferSelect): EnquiryFollowupRecord {
  return {
    id: row.id,
    organizationId: row.organizationId,
    enquiryId: row.enquiryId,
    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 crmEnquiryCustomers.$inferSelect,
  options: { role: { id: string; code: string; label: string } }
): EnquiryProjectPartyRecord {
  return {
    id: row.id,
    organizationId: row.organizationId,
    enquiryId: row.enquiryId,
    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(crmEnquiries.updatedAt);
  }

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

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

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

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

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

    return rule.desc ? desc(column) : asc(column);
  } catch {
    return desc(crmEnquiries.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<EnquiryAssignableUserLookup[]> {
  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<EnquiryAssignableUserLookup>((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
    })
    .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);
  }

  return membership;
}

async function assertEnquiryBelongsToOrganization(
  id: string,
  organizationId: string,
  accessContext?: EnquiryAccessContext
) {
  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);
  }

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

  return enquiry;
}

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

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

  return followup;
}

async function validateEnquiryPayload(organizationId: string, payload: EnquiryMutationPayload) {
  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);
  }

  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,
  enquiryId: string,
  payload: EnquiryFollowupMutationPayload,
  accessContext?: EnquiryAccessContext
) {
  const enquiry = await assertEnquiryBelongsToOrganization(
    enquiryId,
    organizationId,
    accessContext
  );

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

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

function buildEnquiryFilters(context: EnquiryAccessContext, filters: EnquiryFilters): SQL[] {
  const pipelineStages = splitFilterValue(filters.pipelineStage);
  const statuses = splitFilterValue(filters.status);
  const productTypes = splitFilterValue(filters.productType);
  const priorities = splitFilterValue(filters.priority);
  const branches = splitFilterValue(filters.branch);
  const customers = splitFilterValue(filters.customer);

  return [
    eq(crmEnquiries.organizationId, context.organizationId),
    isNull(crmEnquiries.deletedAt),
    ...buildAccessScopedFilters(context),
    ...(pipelineStages.length ? [inArray(crmEnquiries.pipelineStage, pipelineStages)] : []),
    ...(statuses.length ? [inArray(crmEnquiries.status, statuses)] : []),
    ...(productTypes.length ? [inArray(crmEnquiries.productType, productTypes)] : []),
    ...(priorities.length ? [inArray(crmEnquiries.priority, priorities)] : []),
    ...(branches.length ? [inArray(crmEnquiries.branchId, branches)] : []),
    ...(customers.length ? [inArray(crmEnquiries.customerId, customers)] : []),
    ...(filters.search
      ? [
          or(
            ilike(crmEnquiries.code, `%${filters.search}%`),
            ilike(crmEnquiries.title, `%${filters.search}%`),
            ilike(crmEnquiries.projectName, `%${filters.search}%`),
            ilike(crmEnquiries.competitor, `%${filters.search}%`)
          )!
        ]
      : [])
  ];
}

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

async function resolvePipelineStageForCreate(
  organizationId: string,
  businessRole: string,
  statusId: string
): Promise<EnquiryPipelineStage> {
  const statusCode = await resolveOptionCodeById(
    organizationId,
    ENQUIRY_OPTION_CATEGORIES.status,
    statusId
  );

  if (statusCode && CLOSED_LOST_STATUS_CODES.has(statusCode)) {
    return 'closed_lost';
  }

  return derivePipelineStageFromRole(businessRole);
}

async function resolvePipelineStageForUpdate(
  organizationId: string,
  current: typeof crmEnquiries.$inferSelect,
  payload: EnquiryMutationPayload
): Promise<EnquiryPipelineStage> {
  const statusCode = await resolveOptionCodeById(
    organizationId,
    ENQUIRY_OPTION_CATEGORIES.status,
    payload.status
  );

  if (statusCode && CLOSED_LOST_STATUS_CODES.has(statusCode)) {
    return 'closed_lost';
  }

  if (current.pipelineStage === 'closed_won') {
    return 'closed_won';
  }

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

  return 'lead';
}

export async function getEnquiryReferenceData(
  organizationId: string
): Promise<EnquiryReferenceData> {
  const [
    branches,
    statuses,
    productTypes,
    priorities,
    leadChannels,
    followupTypes,
    projectPartyRoles,
    customers,
    contacts,
    assignableUsers
  ] = await Promise.all([
    getUserBranches(),
    getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.status, { 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.projectPartyRole, { 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)),
    listAssignableUsers(organizationId)
  ]);

  return {
    branches: branches.map(mapBranch),
    statuses: statuses.map(mapOption),
    productTypes: productTypes.map(mapOption),
    priorities: priorities.map(mapOption),
    leadChannels: leadChannels.map(mapOption),
    followupTypes: followupTypes.map(mapOption),
    projectPartyRoles: projectPartyRoles.map(mapOption),
    customers: customers.map((customer) => ({
      id: customer.id,
      code: customer.code,
      name: customer.name,
      branchId: customer.branchId
    })),
    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 listEnquiries(
  context: EnquiryAccessContext,
  filters: EnquiryFilters
): Promise<{ items: EnquiryListItem[]; totalItems: number }> {
  const page = filters.page ?? 1;
  const limit = filters.limit ?? 10;
  const whereFilters = buildEnquiryFilters(context, filters);
  const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
  const offset = (page - 1) * limit;

  const [totalResult] = await db.select({ value: count() }).from(crmEnquiries).where(where);
  const rows = await db
    .select()
    .from(crmEnquiries)
    .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 enquiryIds = 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))
      : [],
    enquiryIds.length
      ? db
          .select({
            enquiryId: crmEnquiryFollowups.enquiryId,
            value: count()
          })
          .from(crmEnquiryFollowups)
          .where(
            and(
              eq(crmEnquiryFollowups.organizationId, context.organizationId),
              inArray(crmEnquiryFollowups.enquiryId, enquiryIds),
              isNull(crmEnquiryFollowups.deletedAt)
            )
          )
          .groupBy(crmEnquiryFollowups.enquiryId)
      : [],
    enquiryIds.length
      ? db
          .select({
            enquiryId: crmQuotations.enquiryId,
            value: count()
          })
          .from(crmQuotations)
          .where(
            and(
              eq(crmQuotations.organizationId, context.organizationId),
              inArray(crmQuotations.enquiryId, enquiryIds),
              isNull(crmQuotations.deletedAt)
            )
          )
          .groupBy(crmQuotations.enquiryId)
      : [],
    enquiryIds.length
      ? db
          .select({
            enquiryId: crmEnquiryFollowups.enquiryId,
            nextFollowupDate: crmEnquiryFollowups.nextFollowupDate
          })
          .from(crmEnquiryFollowups)
          .where(
            and(
              eq(crmEnquiryFollowups.organizationId, context.organizationId),
              inArray(crmEnquiryFollowups.enquiryId, enquiryIds),
              isNull(crmEnquiryFollowups.deletedAt)
            )
          )
          .orderBy(asc(crmEnquiryFollowups.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.enquiryId, item.value]));
  const quotationCountMap = new Map(
    quotationCounts
      .filter((item) => item.enquiryId !== null)
      .map((item) => [item.enquiryId as string, item.value])
  );
  const nextFollowupMap = new Map<string, string | null>();

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

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

  return {
    items: rows.map((row) => ({
      ...mapEnquiryRecord(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 getEnquiryDetail(
  id: string,
  organizationId: string,
  accessContext?: EnquiryAccessContext
): Promise<EnquiryRecord> {
  const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
  const relatedUserIds = [enquiry.assignedToUserId, enquiry.assignedBy].filter(Boolean) as string[];
  const userRows = relatedUserIds.length
    ? await db
        .select({ id: users.id, name: users.name })
        .from(users)
        .where(inArray(users.id, relatedUserIds))
    : [];
  const userMap = new Map(userRows.map((row) => [row.id, row.name]));

  return mapEnquiryRecord(enquiry, {
    assignedToName: enquiry.assignedToUserId
      ? (userMap.get(enquiry.assignedToUserId) ?? null)
      : null,
    assignedByName: enquiry.assignedBy ? (userMap.get(enquiry.assignedBy) ?? null) : null
  });
}

async function buildPreparedProjectParties(
  organizationId: string,
  billingCustomerId: string,
  projectParties: EnquiryProjectPartyMutationPayload[] = []
) {
  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 syncEnquiryProjectParties(
  tx: Pick<typeof db, 'insert' | 'update'>,
  organizationId: string,
  enquiryId: string,
  billingCustomerId: string,
  payload: EnquiryProjectPartyMutationPayload[]
) {
  const now = new Date();
  const preparedParties = await buildPreparedProjectParties(
    organizationId,
    billingCustomerId,
    payload
  );

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

  if (!preparedParties.length) {
    return;
  }

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

export async function listEnquiryProjectParties(
  id: string,
  organizationId: string,
  accessContext?: EnquiryAccessContext
): Promise<EnquiryProjectPartyListItem[]> {
  await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
  const [relations, customers, roleOptions] = await Promise.all([
    db
      .select()
      .from(crmEnquiryCustomers)
      .where(
        and(
          eq(crmEnquiryCustomers.enquiryId, id),
          eq(crmEnquiryCustomers.organizationId, organizationId),
          isNull(crmEnquiryCustomers.deletedAt)
        )
      )
      .orderBy(asc(crmEnquiryCustomers.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 EnquiryProjectPartyListItem;
    })
    .filter((item): item is EnquiryProjectPartyListItem => item !== null);
}

export async function getEnquiryActivity(
  id: string,
  organizationId: string,
  accessContext?: EnquiryAccessContext
): Promise<EnquiryActivityRecord[]> {
  await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
  const auditLogs = await listAuditLogs({
    organizationId,
    entityId: id,
    limit: 30
  });
  const filteredLogs = auditLogs.filter(
    (log) =>
      (log.entityType === 'crm_enquiry' && log.entityId === id) ||
      (log.entityType === 'crm_enquiry_followup' &&
        ((log.afterData as { enquiryId?: string } | null)?.enquiryId === id ||
          (log.beforeData as { enquiryId?: string } | null)?.enquiryId === 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
  }));
}

export async function createEnquiry(
  organizationId: string,
  userId: string,
  businessRole: string,
  payload: EnquiryMutationPayload
) {
  await validateEnquiryPayload(organizationId, payload);
  const pipelineStage = await resolvePipelineStageForCreate(
    organizationId,
    businessRole,
    payload.status
  );

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

  return db.transaction(async (tx) => {
    const [created] = await tx
      .insert(crmEnquiries)
      .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,
        competitor: payload.competitor?.trim() || null,
        source: payload.source?.trim() || null,
        notes: payload.notes?.trim() || null,
        isHotProject: payload.isHotProject ?? false,
        isActive: payload.isActive ?? true,
        pipelineStage,
        createdBy: userId,
        updatedBy: userId
      })
      .returning();

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

    return created;
  });
}

export async function updateEnquiry(
  id: string,
  organizationId: string,
  userId: string,
  payload: EnquiryMutationPayload,
  accessContext?: EnquiryAccessContext
) {
  await validateEnquiryPayload(organizationId, payload);
  const current = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
  const pipelineStage = await resolvePipelineStageForUpdate(organizationId, current, payload);

  return db.transaction(async (tx) => {
    const [updated] = await tx
      .update(crmEnquiries)
      .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,
        competitor: payload.competitor?.trim() || null,
        source: payload.source?.trim() || null,
        notes: payload.notes?.trim() || null,
        isHotProject: payload.isHotProject ?? false,
        isActive: payload.isActive ?? true,
        pipelineStage,
        updatedBy: userId,
        updatedAt: new Date()
      })
      .where(eq(crmEnquiries.id, id))
      .returning();

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

    return updated;
  });
}

export async function softDeleteEnquiry(
  id: string,
  organizationId: string,
  userId: string,
  accessContext?: EnquiryAccessContext
) {
  await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
  const now = new Date();

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

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

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

  return updated;
}

async function updateEnquiryAssignment(
  id: string,
  organizationId: string,
  userId: string,
  payload: EnquiryAssignmentMutationPayload,
  mode: 'assign' | 'reassign'
) {
  const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId);

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

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

  await assertAssignableUserBelongsToOrganization(payload.assignedToUserId, organizationId);

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

  return updated;
}

export async function assignEnquiryToUser(
  id: string,
  organizationId: string,
  userId: string,
  payload: EnquiryAssignmentMutationPayload
) {
  return updateEnquiryAssignment(id, organizationId, userId, payload, 'assign');
}

export async function reassignEnquiryToUser(
  id: string,
  organizationId: string,
  userId: string,
  payload: EnquiryAssignmentMutationPayload
) {
  return updateEnquiryAssignment(id, organizationId, userId, payload, 'reassign');
}

export async function listEnquiryFollowups(
  id: string,
  organizationId: string,
  accessContext?: EnquiryAccessContext
) {
  await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
  const rows = await db
    .select()
    .from(crmEnquiryFollowups)
    .where(
      and(
        eq(crmEnquiryFollowups.enquiryId, id),
        eq(crmEnquiryFollowups.organizationId, organizationId),
        isNull(crmEnquiryFollowups.deletedAt)
      )
    )
    .orderBy(desc(crmEnquiryFollowups.followupDate));

  return rows.map(mapFollowupRecord);
}

export async function createEnquiryFollowup(
  enquiryId: string,
  organizationId: string,
  userId: string,
  payload: EnquiryFollowupMutationPayload,
  accessContext?: EnquiryAccessContext
) {
  await validateFollowupPayload(organizationId, enquiryId, payload, accessContext);

  const [created] = await db
    .insert(crmEnquiryFollowups)
    .values({
      id: crypto.randomUUID(),
      organizationId,
      enquiryId,
      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 updateEnquiryFollowup(
  enquiryId: string,
  followupId: string,
  organizationId: string,
  userId: string,
  payload: EnquiryFollowupMutationPayload,
  accessContext?: EnquiryAccessContext
) {
  await validateFollowupPayload(organizationId, enquiryId, payload, accessContext);
  await assertFollowupBelongsToEnquiry(followupId, enquiryId, organizationId);

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

  return updated;
}

export async function softDeleteEnquiryFollowup(
  enquiryId: string,
  followupId: string,
  organizationId: string,
  userId: string,
  accessContext?: EnquiryAccessContext
) {
  await assertEnquiryBelongsToOrganization(enquiryId, organizationId, accessContext);
  await assertFollowupBelongsToEnquiry(followupId, enquiryId, organizationId);

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

  return updated;
}

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

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

export async function syncEnquiryPipelineStageFromQuotationStatus(
  enquiryId: string,
  organizationId: string,
  quotationStatusCode: string | null
) {
  if (!quotationStatusCode) {
    return;
  }

  const nextStage: EnquiryPipelineStage | null = CLOSED_WON_QUOTATION_STATUS_CODES.has(
    quotationStatusCode
  )
    ? 'closed_won'
    : CLOSED_LOST_QUOTATION_STATUS_CODES.has(quotationStatusCode)
      ? 'closed_lost'
      : null;

  if (!nextStage) {
    return;
  }

  await db
    .update(crmEnquiries)
    .set({
      pipelineStage: nextStage,
      updatedAt: new Date()
    })
    .where(
      and(
        eq(crmEnquiries.id, enquiryId),
        eq(crmEnquiries.organizationId, organizationId),
        isNull(crmEnquiries.deletedAt)
      )
    );
}
