/**
 * Qualification-criteria service. Per-port admins configure the criteria that
 * a deal must satisfy to be considered "qualified" (the gate between enquiry
 * and the rest of the pipeline). Per-interest state is captured separately
 * so changing the port's criteria doesn't retroactively affect existing
 * deals.
 *
 * The "fully qualified" derivation drives the soft hint on the interest
 * detail page that an enquiry is ready to advance.
 */

import { and, asc, eq } from 'drizzle-orm';

import { db } from '@/lib/db';
import { interestQualifications, interests, qualificationCriteria } from '@/lib/db/schema';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { PIPELINE_STAGES, type PipelineStage } from '@/lib/constants';
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import type {
  CreateQualificationCriterionInput,
  ReorderQualificationCriteriaInput,
  SetInterestQualificationInput,
  UpdateQualificationCriterionInput,
} from '@/lib/validators/qualification';

// ─── Port-scoped criterion config (admin) ───────────────────────────────────

export async function listCriteriaForPort(portId: string) {
  return db
    .select()
    .from(qualificationCriteria)
    .where(eq(qualificationCriteria.portId, portId))
    .orderBy(asc(qualificationCriteria.displayOrder), asc(qualificationCriteria.createdAt));
}

export async function createCriterion(
  portId: string,
  data: CreateQualificationCriterionInput,
  meta: AuditMeta,
) {
  // Unique (portId, key) is enforced at DB level, but doing the check here
  // surfaces a friendlier 409 with the offending key.
  const existing = await db.query.qualificationCriteria.findFirst({
    where: and(eq(qualificationCriteria.portId, portId), eq(qualificationCriteria.key, data.key)),
  });
  if (existing) {
    throw new ConflictError(`A criterion with key "${data.key}" already exists for this port`);
  }

  const [row] = await db
    .insert(qualificationCriteria)
    .values({
      portId,
      key: data.key,
      label: data.label,
      description: data.description ?? null,
      enabled: data.enabled,
      displayOrder: data.displayOrder,
    })
    .returning();

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'create',
    entityType: 'qualification_criterion',
    entityId: row!.id,
    newValue: { key: data.key, label: data.label, enabled: data.enabled },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  return row!;
}

export async function updateCriterion(
  id: string,
  portId: string,
  data: UpdateQualificationCriterionInput,
  meta: AuditMeta,
) {
  const existing = await db.query.qualificationCriteria.findFirst({
    where: and(eq(qualificationCriteria.id, id), eq(qualificationCriteria.portId, portId)),
  });
  if (!existing) throw new NotFoundError('Qualification criterion');

  const next: Record<string, unknown> = { updatedAt: new Date() };
  if (data.label !== undefined) next.label = data.label;
  if (data.description !== undefined) next.description = data.description;
  if (data.enabled !== undefined) next.enabled = data.enabled;
  if (data.displayOrder !== undefined) next.displayOrder = data.displayOrder;

  const [updated] = await db
    .update(qualificationCriteria)
    .set(next)
    .where(and(eq(qualificationCriteria.id, id), eq(qualificationCriteria.portId, portId)))
    .returning();

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'update',
    entityType: 'qualification_criterion',
    entityId: id,
    oldValue: { label: existing.label, enabled: existing.enabled },
    newValue: next,
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  return updated!;
}

export async function deleteCriterion(id: string, portId: string, meta: AuditMeta) {
  const existing = await db.query.qualificationCriteria.findFirst({
    where: and(eq(qualificationCriteria.id, id), eq(qualificationCriteria.portId, portId)),
  });
  if (!existing) throw new NotFoundError('Qualification criterion');

  // Per-interest state rows reference the key, not the criterion id, so they
  // survive a criterion deletion as historical noise. UI hides rows whose key
  // no longer matches an active criterion.
  await db
    .delete(qualificationCriteria)
    .where(and(eq(qualificationCriteria.id, id), eq(qualificationCriteria.portId, portId)));

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'delete',
    entityType: 'qualification_criterion',
    entityId: id,
    oldValue: { key: existing.key, label: existing.label },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  return { ok: true };
}

/**
 * Whole-list reorder. Rewrites display_order to match the array index of
 * each id, inside a single transaction so partial failure can't leave the
 * port with a scrambled order. The ids array must cover exactly the port's
 * current criteria - any mismatch (extra or missing id) is rejected so the
 * UI can't silently drop a criterion by sending a stale list.
 */
export async function reorderCriteria(
  portId: string,
  data: ReorderQualificationCriteriaInput,
  meta: AuditMeta,
) {
  return db
    .transaction(async (tx) => {
      const current = await tx
        .select({ id: qualificationCriteria.id })
        .from(qualificationCriteria)
        .where(eq(qualificationCriteria.portId, portId));

      const currentIds = new Set(current.map((r) => r.id));
      const incomingIds = new Set(data.ids);

      if (
        currentIds.size !== incomingIds.size ||
        [...currentIds].some((id) => !incomingIds.has(id))
      ) {
        throw new ValidationError(
          'Reorder payload must reference exactly the port’s current criteria',
        );
      }

      for (let i = 0; i < data.ids.length; i++) {
        await tx
          .update(qualificationCriteria)
          .set({ displayOrder: i, updatedAt: new Date() })
          .where(
            and(
              eq(qualificationCriteria.id, data.ids[i]!),
              eq(qualificationCriteria.portId, portId),
            ),
          );
      }

      return tx
        .select()
        .from(qualificationCriteria)
        .where(eq(qualificationCriteria.portId, portId))
        .orderBy(asc(qualificationCriteria.displayOrder), asc(qualificationCriteria.createdAt));
    })
    .then((rows) => {
      void createAuditLog({
        userId: meta.userId,
        portId,
        action: 'update',
        entityType: 'qualification_criterion',
        entityId: 'reorder',
        newValue: { orderedIds: data.ids },
        ipAddress: meta.ipAddress,
        userAgent: meta.userAgent,
      });
      return rows;
    });
}

// ─── Per-interest state ─────────────────────────────────────────────────────

export interface QualificationRow {
  key: string;
  label: string;
  description: string | null;
  enabled: boolean;
  displayOrder: number;
  confirmed: boolean;
  confirmedAt: Date | null;
  confirmedBy: string | null;
  notes: string | null;
  /**
   * True when the criterion is automatically satisfied by data already on
   * the interest (or a linked record). Surfaced to the UI so the checklist
   * can render the criterion as ticked-by-the-system without needing an
   * explicit `interest_qualifications` row. When both `autoSatisfied` and
   * the explicit `confirmed` are true, either signal alone counts.
   *
   * Auto-satisfaction rules:
   *   - `dimensions` → ticked when EITHER (a) the linked yacht has all
   *     three dims (length/width/draft) OR (b) the interest itself has
   *     desired-berth dims set. The "no yacht needed" case is the second
   *     branch - a client buying a berth doesn't have to own a vessel,
   *     they just have to know the berth size they want.
   */
  autoSatisfied: boolean;
  /**
   * Human-readable summary of WHY a criterion is auto-satisfied (e.g.
   * "Desired: 60 × 25 × 6 ft"). Empty string when the criterion is not
   * auto-satisfied OR when no derivation rule applies. Surfaced on the
   * checklist row so the rep can see the evidence behind the tick - the
   * "why is this checked?" question came up in UAT.
   */
  evidence: string;
}

/**
 * The qualification state for a specific interest, joined with the port's
 * current criterion definitions. Returns only currently-enabled criteria -
 * disabled ones are hidden from the rep but their state rows are preserved
 * in the DB for audit.
 */
export async function listInterestQualifications(
  interestId: string,
  portId: string,
): Promise<QualificationRow[]> {
  // Pull the interest row with the fields needed to derive auto-satisfaction -
  // desired-berth dims (length/width/draft) plus a linked yacht if any. Cost
  // is one extra column-select vs the previous columns:{id:true} probe, so
  // negligible.
  const interest = await db.query.interests.findFirst({
    where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
    columns: {
      id: true,
      yachtId: true,
      desiredLengthFt: true,
      desiredWidthFt: true,
      desiredDraftFt: true,
      pipelineStage: true,
    },
  });
  if (!interest) throw new NotFoundError('Interest');

  // Pull the linked yacht's dims when one is attached. Used by the
  // `dimensions` criterion's auto-satisfaction rule (yacht-side branch).
  let yachtDims: {
    lengthFt: string | null;
    widthFt: string | null;
    draftFt: string | null;
  } | null = null;
  if (interest.yachtId) {
    const { yachts } = await import('@/lib/db/schema/yachts');
    const yacht = await db.query.yachts.findFirst({
      where: eq(yachts.id, interest.yachtId),
      columns: { lengthFt: true, widthFt: true, draftFt: true },
    });
    if (yacht) yachtDims = yacht;
  }

  const criteria = await db
    .select()
    .from(qualificationCriteria)
    .where(and(eq(qualificationCriteria.portId, portId), eq(qualificationCriteria.enabled, true)))
    .orderBy(asc(qualificationCriteria.displayOrder), asc(qualificationCriteria.createdAt));

  const states = await db
    .select()
    .from(interestQualifications)
    .where(eq(interestQualifications.interestId, interestId));
  const stateByKey = new Map(states.map((s) => [s.criterionKey, s] as const));

  return criteria.map((c) => {
    const s = stateByKey.get(c.key);
    const ctx = {
      yachtDims,
      desiredDims: {
        lengthFt: interest.desiredLengthFt ?? null,
        widthFt: interest.desiredWidthFt ?? null,
        draftFt: interest.desiredDraftFt ?? null,
      },
      pipelineStage: (interest.pipelineStage ?? 'enquiry') as PipelineStage,
    };
    const autoSatisfied = computeAutoSatisfied(c.key, ctx);
    const explicit = s?.confirmed ?? false;
    const evidence = autoSatisfied ? computeEvidence(c.key, ctx) : '';
    // Derived-only criteria (e.g. `dimensions`) ignore the explicit tick
    // entirely - if the underlying evidence disappears, the row un-ticks.
    // Judgement-based criteria keep the OR semantic so a rep's explicit
    // confirmation survives an evidence change.
    const confirmed = isDerivedOnly(c.key) ? autoSatisfied : explicit || autoSatisfied;
    return {
      key: c.key,
      label: c.label,
      description: c.description,
      enabled: c.enabled,
      displayOrder: c.displayOrder,
      // Surface ticked state per `confirmed` above. Explicit confirmation
      // still gets its confirmedAt/By stamps; auto-satisfied state leaves
      // those null so the rep can see "this was system-derived, not an
      // explicit sign-off".
      confirmed,
      confirmedAt: s?.confirmedAt ?? null,
      confirmedBy: s?.confirmedBy ?? null,
      notes: s?.notes ?? null,
      autoSatisfied,
      evidence,
    };
  });
}

/**
 * Per-criterion derivation rules. Each key knows how to read the interest
 * (and any linked records) and decide whether the criterion is satisfied
 * without an explicit rep tick. Add new rules by branching on `key`.
 */
interface AutoCtx {
  yachtDims: { lengthFt: string | null; widthFt: string | null; draftFt: string | null } | null;
  desiredDims: { lengthFt: string | null; widthFt: string | null; draftFt: string | null };
  pipelineStage: PipelineStage;
}

/**
 * Keys whose `confirmed` state should be purely derived (no explicit
 * tick respected). Removing the underlying evidence un-ticks the row.
 * Compare with keys carrying real rep judgement (e.g. intent_confirmed
 * before auto-derivation kicks in), which retain explicit-vs-auto OR
 * semantics.
 */
const DERIVED_ONLY_KEYS: ReadonlySet<string> = new Set(['dimensions']);

function isDerivedOnly(key: string): boolean {
  return DERIVED_ONLY_KEYS.has(key);
}

function computeAutoSatisfied(key: string, ctx: AutoCtx): boolean {
  if (key === 'dimensions') {
    const hasYachtDims =
      !!ctx.yachtDims &&
      !!ctx.yachtDims.lengthFt &&
      !!ctx.yachtDims.widthFt &&
      !!ctx.yachtDims.draftFt;
    const hasDesiredDims =
      !!ctx.desiredDims.lengthFt && !!ctx.desiredDims.widthFt && !!ctx.desiredDims.draftFt;
    return hasYachtDims || hasDesiredDims;
  }
  if (key === 'intent_confirmed') {
    // Signing an EOI (or later) is the strongest signal of intent -
    // auto-tick once the rep has moved past Qualified. The criterion
    // can still be ticked manually before then.
    const stageIdx = PIPELINE_STAGES.indexOf(ctx.pipelineStage);
    const qualifiedIdx = PIPELINE_STAGES.indexOf('qualified');
    return stageIdx > qualifiedIdx;
  }
  return false;
}

/**
 * Returns a short human-readable string explaining what data drove the
 * auto-satisfaction. Mirrors `computeAutoSatisfied`'s branching so the UI
 * can render "Auto · <evidence>" - closes the "why is this ticked?" gap.
 */
function computeEvidence(key: string, ctx: AutoCtx): string {
  if (key === 'dimensions') {
    const hasYacht =
      !!ctx.yachtDims &&
      !!ctx.yachtDims.lengthFt &&
      !!ctx.yachtDims.widthFt &&
      !!ctx.yachtDims.draftFt;
    if (hasYacht && ctx.yachtDims) {
      return `Yacht: ${ctx.yachtDims.lengthFt} × ${ctx.yachtDims.widthFt} × ${ctx.yachtDims.draftFt} ft`;
    }
    const hasDesired =
      !!ctx.desiredDims.lengthFt && !!ctx.desiredDims.widthFt && !!ctx.desiredDims.draftFt;
    if (hasDesired) {
      return `Desired: ${ctx.desiredDims.lengthFt} × ${ctx.desiredDims.widthFt} × ${ctx.desiredDims.draftFt} ft`;
    }
    return '';
  }
  if (key === 'intent_confirmed') {
    return 'Stage advanced past Qualified';
  }
  return '';
}

/**
 * Upsert a single criterion's confirmed-state for an interest. Stamping the
 * server-side fields (confirmedBy / confirmedAt) makes the row a proper
 * audit record - the caller can't backdate it.
 */
export async function setInterestQualification(
  interestId: string,
  portId: string,
  data: SetInterestQualificationInput,
  meta: AuditMeta,
) {
  const interest = await db.query.interests.findFirst({
    where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
    columns: { id: true },
  });
  if (!interest) throw new NotFoundError('Interest');

  // Refuse keys the port doesn't have a criterion for - keeps state rows
  // referentially consistent with the visible config.
  const criterion = await db.query.qualificationCriteria.findFirst({
    where: and(
      eq(qualificationCriteria.portId, portId),
      eq(qualificationCriteria.key, data.criterionKey),
    ),
  });
  if (!criterion) throw new NotFoundError('Qualification criterion');

  const now = new Date();
  await db
    .insert(interestQualifications)
    .values({
      interestId,
      criterionKey: data.criterionKey,
      confirmed: data.confirmed,
      confirmedAt: data.confirmed ? now : null,
      confirmedBy: data.confirmed ? meta.userId : null,
      notes: data.notes ?? null,
    })
    .onConflictDoUpdate({
      target: [interestQualifications.interestId, interestQualifications.criterionKey],
      set: {
        confirmed: data.confirmed,
        confirmedAt: data.confirmed ? now : null,
        confirmedBy: data.confirmed ? meta.userId : null,
        notes: data.notes ?? null,
      },
    });

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'update',
    entityType: 'interest_qualification',
    entityId: `${interestId}:${data.criterionKey}`,
    newValue: { confirmed: data.confirmed, key: data.criterionKey },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'interest:qualificationChanged', {
    interestId,
    criterionKey: data.criterionKey,
    confirmed: data.confirmed,
  });

  return listInterestQualifications(interestId, portId);
}

/**
 * Returns true when every enabled criterion for the port is confirmed for
 * the given interest. Used by the UI to surface the "ready to qualify" hint
 * and by the auto-advance helper to soft-suggest moving to 'qualified'.
 */
export async function isInterestFullyQualified(
  interestId: string,
  portId: string,
): Promise<boolean> {
  const rows = await listInterestQualifications(interestId, portId);
  if (rows.length === 0) return false;
  return rows.every((r) => r.confirmed);
}
