/**
 * Per-port configurable pipeline stages for residential interests.
 *
 * The stage list is stored as a JSON array on
 * `system_settings.residential_pipeline_stages`. When the setting is
 * unset, callers fall back to `DEFAULT_RESIDENTIAL_PIPELINE_STAGES`
 * from the validators module.
 *
 * Each stage carries:
 *   - id (machine - used in the DB pipeline_stage column)
 *   - label (display)
 *   - terminal hint ('won' | 'lost' | null) - drives funnel reports
 *
 * Removal safety: when an admin removes a stage that still has
 * interests at it, `validateStagesAgainstUsage` returns the affected
 * interest ids so the UI can prompt for reassignment before saving.
 */
import { and, eq, inArray } from 'drizzle-orm';

import { db } from '@/lib/db';
import { residentialInterests } from '@/lib/db/schema/residential';
import { systemSettings } from '@/lib/db/schema';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { ConflictError, ValidationError } from '@/lib/errors';

const SETTING_KEY = 'residential_pipeline_stages';

export interface ResidentialStage {
  id: string;
  label: string;
  /** 'won' / 'lost' for funnel-terminal stages, null for in-progress. */
  terminal: 'won' | 'lost' | null;
}

const DEFAULT_STAGES: ResidentialStage[] = [
  { id: 'new', label: 'New', terminal: null },
  { id: 'contacted', label: 'Contacted', terminal: null },
  { id: 'viewing_scheduled', label: 'Viewing scheduled', terminal: null },
  { id: 'offer_made', label: 'Offer made', terminal: null },
  { id: 'offer_accepted', label: 'Offer accepted', terminal: null },
  { id: 'closed_won', label: 'Closed - won', terminal: 'won' },
  { id: 'closed_lost', label: 'Closed - lost', terminal: 'lost' },
];

export async function listStages(portId: string): Promise<ResidentialStage[]> {
  const row = await db.query.systemSettings.findFirst({
    where: and(eq(systemSettings.key, SETTING_KEY), eq(systemSettings.portId, portId)),
  });
  if (!row || !Array.isArray(row.value)) return DEFAULT_STAGES;
  // Defensive: filter out anything that doesn't match the expected shape
  // so a malformed row doesn't crash the whole residential UI.
  const valid = (row.value as unknown[]).filter(
    (s): s is ResidentialStage =>
      !!s &&
      typeof s === 'object' &&
      typeof (s as ResidentialStage).id === 'string' &&
      typeof (s as ResidentialStage).label === 'string',
  );
  return valid.length > 0 ? valid : DEFAULT_STAGES;
}

/**
 * Runtime membership check for a pipeline-stage id against the port's
 * live stage list (per-port custom set, or the defaults when unset).
 * Both the bulk endpoint and the per-row PATCH route mutations through
 * here so they accept admin-customized stages and reject non-existent
 * ones identically — closing the split where bulk hardcoded the 7
 * built-ins while the per-row path wrote arbitrary strings unchecked.
 */
export async function assertValidStage(portId: string, stageId: string): Promise<void> {
  const stages = await listStages(portId);
  if (!stages.some((s) => s.id === stageId)) {
    throw new ValidationError(
      `Pipeline stage '${stageId}' is not configured for this port. Valid stages: ${stages
        .map((s) => s.id)
        .join(', ')}.`,
    );
  }
}

/**
 * Return interest ids currently parked at a stage that is NOT in the
 * proposed new list. Empty array means the swap is safe.
 */
export async function findOrphanInterests(
  portId: string,
  newStageIds: string[],
): Promise<Array<{ id: string; pipelineStage: string }>> {
  const orphans = await db
    .select({ id: residentialInterests.id, pipelineStage: residentialInterests.pipelineStage })
    .from(residentialInterests)
    .where(eq(residentialInterests.portId, portId));
  return orphans.filter((row) => !newStageIds.includes(row.pipelineStage));
}

export interface SaveStagesArgs {
  portId: string;
  stages: ResidentialStage[];
  /** Optional reassignment map: orphaned interest id → new stage id.
   *  When `force=true` and a non-empty reassignments map is supplied,
   *  the service applies the reassignments inside the same transaction
   *  as the stage-list write. */
  reassignments?: Record<string, string>;
  /** When true, save proceeds even if reassignments don't cover every
   *  orphan - remaining orphans are left at their old (now-removed)
   *  stage and will need a follow-up cleanup. */
  force?: boolean;
}

export async function saveStages(args: SaveStagesArgs, meta: AuditMeta): Promise<void> {
  const ids = args.stages.map((s) => s.id);
  if (ids.length === 0) {
    throw new ConflictError('At least one stage is required');
  }
  if (new Set(ids).size !== ids.length) {
    throw new ConflictError('Stage ids must be unique');
  }

  const orphans = await findOrphanInterests(args.portId, ids);
  const uncovered = orphans.filter((o) => !args.reassignments || !args.reassignments[o.id]);
  if (uncovered.length > 0 && !args.force) {
    throw new ConflictError(
      `${uncovered.length} interest${uncovered.length === 1 ? '' : 's'} sit on a stage you're removing. Reassign them or pass force=true.`,
    );
  }

  // Pre-compute the reassignment groups (one UPDATE per target stage)
  // and validate every target is in the new list BEFORE opening the
  // transaction, so an invalid target fails fast without a rollback.
  const byTarget = new Map<string, string[]>();
  if (args.reassignments) {
    for (const [interestId, newStage] of Object.entries(args.reassignments)) {
      if (!ids.includes(newStage)) {
        throw new ConflictError(`Reassignment target stage '${newStage}' is not in the new list`);
      }
      const list = byTarget.get(newStage) ?? [];
      list.push(interestId);
      byTarget.set(newStage, list);
    }
  }

  // Read the prior setting for the audit-log diff before the write.
  // The onConflictDoUpdate guards against concurrent admin saves
  // race-inserting duplicates (migration 0047 made the index NULLS NOT
  // DISTINCT).
  const existing = await db.query.systemSettings.findFirst({
    where: and(eq(systemSettings.key, SETTING_KEY), eq(systemSettings.portId, args.portId)),
  });

  // Single transaction: the orphan reassignments and the stage-list
  // UPSERT must land together. A crash between them previously left
  // interests reassigned to stages that were never persisted (or vice
  // versa), contradicting the docstring's one-transaction contract.
  await db.transaction(async (tx) => {
    // Apply reassignments first so any orphan handlers see the new
    // stage ids. One UPDATE per target stage to keep the SQL simple.
    for (const [target, interestIds] of byTarget.entries()) {
      await tx
        .update(residentialInterests)
        .set({ pipelineStage: target, updatedAt: new Date() })
        .where(
          and(
            eq(residentialInterests.portId, args.portId),
            inArray(residentialInterests.id, interestIds),
          ),
        );
    }

    await tx
      .insert(systemSettings)
      .values({
        key: SETTING_KEY,
        value: args.stages,
        portId: args.portId,
        updatedBy: meta.userId,
      })
      .onConflictDoUpdate({
        target: [systemSettings.key, systemSettings.portId],
        set: {
          value: args.stages,
          updatedBy: meta.userId,
          updatedAt: new Date(),
        },
      });
  });

  void createAuditLog({
    userId: meta.userId,
    portId: args.portId,
    action: existing ? 'update' : 'create',
    entityType: 'setting',
    entityId: SETTING_KEY,
    oldValue: existing ? { value: existing.value } : undefined,
    newValue: { stages: args.stages, reassignments: args.reassignments ?? null },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });
}
