/**
 * Device-link reconciler — links unlinked device_external_ids rows to a
 * configuration_item. Cascading match strategies, highest confidence first.
 * Conflicts (multiple matches) are logged for admin review, not auto-merged.
 *
 * Wire into sync-scheduler.ts as an hourly cron when ready. Not wired yet —
 * reviewer should approve the match strategies + conflict policy first.
 */

import postgresClient from '@/lib/services/postgres-client';

type LinkConfidence = 'canonical' | 'exact_uid' | 'exact_serial' | 'hostname_in_company' | 'mac' | 'manual';

interface UnlinkedRow {
  id: number;
  source: string;
  source_id: string;
  hostname: string | null;
  serial: string | null;
  mac: string | null;
  company_id: number | null;
}

interface MatchCandidate {
  configuration_item_id: number;
  link_confidence: LinkConfidence;
}

export interface ReconcileResult {
  scanned: number;
  linked: number;
  conflicts: number;
  unmatched: number;
  byConfidence: Record<LinkConfidence, number>;
}

const LINK_CONFIDENCE_RANK: Record<LinkConfidence, number> = {
  canonical: 100,
  exact_uid: 90,
  exact_serial: 80,
  mac: 70,
  hostname_in_company: 60,
  manual: 50,
};

// Common BIOS/inventory placeholder serials that shouldn't be matched on —
// hundreds of unrelated CIs share these and any link based on them is noise.
const PLACEHOLDER_SERIALS = new Set([
  '', '0', '1', 'n/a', 'na', 'none', 'null', 'unknown', 'not listed',
  'not specified', 'not applicable', 'default string', 'to be filled by o.e.m.',
  'system serial number', 'chassis serial number',
  '0000000000', '00000000', 'ffffffffffff',
  '00000000-0000-0000-0000-000000000000',
]);

function isPlaceholderSerial(serial: string): boolean {
  const s = serial.trim().toLowerCase();
  if (s.length < 4) return true;
  if (PLACEHOLDER_SERIALS.has(s)) return true;
  // Strings that are all the same character (e.g. "00000000", "FFFFFFFF").
  if (/^(.)\1+$/.test(s)) return true;
  return false;
}

async function findBySerial(serial: string): Promise<MatchCandidate[]> {
  if (isPlaceholderSerial(serial)) return [];
  const res = await postgresClient.query<{ id: string }>(
    `SELECT id::text FROM configuration_items
      WHERE serial_number IS NOT NULL
        AND serial_number = $1
        AND (is_deleted IS NULL OR is_deleted = false)`,
    [serial]
  );
  return res.rows.map((r) => ({
    configuration_item_id: Number(r.id),
    link_confidence: 'exact_serial' as const,
  }));
}

async function findByMac(mac: string): Promise<MatchCandidate[]> {
  const res = await postgresClient.query<{ id: string }>(
    `SELECT id::text FROM configuration_items
      WHERE rmm_device_audit_mac_address IS NOT NULL
        AND LOWER(rmm_device_audit_mac_address) = LOWER($1)
        AND (is_deleted IS NULL OR is_deleted = false)`,
    [mac]
  );
  return res.rows.map((r) => ({
    configuration_item_id: Number(r.id),
    link_confidence: 'mac' as const,
  }));
}

async function findByHostnameInCompany(
  hostname: string,
  companyId: number | null
): Promise<MatchCandidate[]> {
  if (!companyId) return [];
  const res = await postgresClient.query<{ id: string }>(
    `SELECT id::text FROM configuration_items
      WHERE company_id = $1
        AND reference_title IS NOT NULL
        AND LOWER(reference_title) = LOWER($2)
        AND (is_deleted IS NULL OR is_deleted = false)`,
    [companyId, hostname]
  );
  return res.rows.map((r) => ({
    configuration_item_id: Number(r.id),
    link_confidence: 'hostname_in_company' as const,
  }));
}

async function applyLink(
  rowId: number,
  configurationItemId: number,
  confidence: LinkConfidence
): Promise<void> {
  await postgresClient.query(
    `UPDATE device_external_ids
        SET configuration_item_id = $2,
            link_confidence       = $3,
            linked_at             = NOW()
      WHERE id = $1
        AND configuration_item_id IS NULL`,
    [rowId, configurationItemId, confidence]
  );

  // Propagate the new link into endpoint_audits / device_observations that
  // were anchored only on the tool-side ID (e.g. an IT Glue config) at the
  // time they were written. Without this they'd stay "unanchored" in the UI.
  const linked = await postgresClient.query<{
    source: string;
    source_id: string;
  }>(
    `SELECT source, source_id FROM device_external_ids WHERE id = $1`,
    [rowId]
  );
  const link = linked.rows[0];
  if (!link) return;

  if (link.source === 'itglue') {
    await postgresClient.query(
      `UPDATE endpoint_audits
          SET configuration_item_id = $1
        WHERE configuration_item_id IS NULL
          AND itglue_configuration_id::text = $2`,
      [configurationItemId, link.source_id]
    );
  }
}

async function recordConflict(
  rowId: number,
  candidates: MatchCandidate[]
): Promise<void> {
  // Order candidates highest-confidence-first so the admin UI sees the best
  // match at the top.
  const ordered = [...candidates].sort(
    (a, b) => LINK_CONFIDENCE_RANK[b.link_confidence] - LINK_CONFIDENCE_RANK[a.link_confidence]
  );
  const ciIds = ordered.map((c) => c.configuration_item_id);
  const confidences = ordered.map((c) => c.link_confidence);
  await postgresClient.query(
    `INSERT INTO device_link_review (device_external_id, candidate_ci_ids, match_confidences)
     VALUES ($1, $2::bigint[], $3::text[])
     ON CONFLICT (device_external_id) WHERE resolved_at IS NULL
     DO UPDATE SET candidate_ci_ids = EXCLUDED.candidate_ci_ids,
                   match_confidences = EXCLUDED.match_confidences,
                   detected_at = NOW()`,
    [rowId, ciIds, confidences]
  );
}

function pickBestCandidate(candidates: MatchCandidate[]): MatchCandidate | null {
  if (candidates.length === 0) return null;
  const ids = new Set(candidates.map((c) => c.configuration_item_id));
  if (ids.size > 1) return null; // ambiguous — admin review
  return candidates.reduce((best, c) =>
    LINK_CONFIDENCE_RANK[c.link_confidence] > LINK_CONFIDENCE_RANK[best.link_confidence] ? c : best
  );
}

/**
 * Run one reconciliation pass over unlinked rows. Idempotent — safe to run
 * repeatedly. Caller should schedule via sync-scheduler.
 */
export async function reconcileUnlinkedDevices(opts?: {
  limit?: number;
  dryRun?: boolean;
}): Promise<ReconcileResult> {
  const limit = opts?.limit ?? 500;
  const dryRun = opts?.dryRun ?? false;
  const result: ReconcileResult = {
    scanned: 0,
    linked: 0,
    conflicts: 0,
    unmatched: 0,
    byConfidence: {
      canonical: 0,
      exact_uid: 0,
      exact_serial: 0,
      mac: 0,
      hostname_in_company: 0,
      manual: 0,
    },
  };

  const unlinked = await postgresClient.query<UnlinkedRow>(
    `SELECT id, source, source_id, hostname, serial, mac, company_id
       FROM device_external_ids
      WHERE configuration_item_id IS NULL
      ORDER BY last_seen_at DESC NULLS LAST
      LIMIT $1`,
    [limit]
  );

  for (const row of unlinked.rows) {
    result.scanned += 1;
    const candidates: MatchCandidate[] = [];
    if (row.serial) candidates.push(...(await findBySerial(row.serial)));
    if (row.mac) candidates.push(...(await findByMac(row.mac)));
    if (row.hostname) candidates.push(...(await findByHostnameInCompany(row.hostname, row.company_id)));

    if (candidates.length === 0) {
      result.unmatched += 1;
      continue;
    }
    const ids = new Set(candidates.map((c) => c.configuration_item_id));
    if (ids.size > 1) {
      result.conflicts += 1;
      if (!dryRun) {
        await recordConflict(row.id, candidates);
      }
      continue;
    }
    const best = pickBestCandidate(candidates);
    if (!best) {
      result.unmatched += 1;
      continue;
    }
    if (!dryRun) {
      await applyLink(row.id, best.configuration_item_id, best.link_confidence);
    }
    result.linked += 1;
    result.byConfidence[best.link_confidence] += 1;
  }

  return result;
}
