/**
 * PAX8 company <-> Autotask company fuzzy matcher (PAX8-10, PAX8-11).
 *
 * Ported from lib/services/device-link-reconciler.ts's cascading
 * findBy-x / pickBestCandidate / applyLink / recordConflict shape, adapted
 * from exact-match cascades to a single trigram-similarity score.
 *
 * Unlike the reconciler, this does NOT run as a standalone cron this phase —
 * it is invoked from Pax8SyncService.fullSync() (Plan 04), once per sync run,
 * against the eligible (unresolved) subset of pax8_companies.
 *
 * Match policy (12-CONTEXT.md / 12-RESEARCH.md locked decisions):
 *   D-01: auto-link only at similarity >= AUTO_LINK_THRESHOLD, single
 *         candidate, no near-tie.
 *   D-02: a second candidate within TIE_MARGIN of the top score forces
 *         review even at score 1.0 — never silently tie-break.
 *   D-03: zero candidates above CANDIDATE_FLOOR still creates a review row
 *         with an empty candidate_company_ids array — never silently dropped.
 *   D-04: ambiguous review rows carry the top 3 candidates.
 *   D-05 / SC#4: only unresolved rows are (re)scored every sync; a
 *         human-resolved match (resolved review row, or match_method='manual')
 *         is never overwritten.
 */

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

// D-01: conservative, initial/tunable auto-link floor. Validated live against
// this project's real 118 pax8_companies vs 242 active companies — every
// genuine match scored 1.00, the highest non-match observed was 0.70, so 0.90
// sits comfortably in the empty gap between the two clusters.
export const AUTO_LINK_THRESHOLD = 0.9;

// D-02: a second candidate within this margin of the top score is treated as
// an unresolved tie and forced to review, even if the top score alone would
// clear AUTO_LINK_THRESHOLD.
export const TIE_MARGIN = 0.05;

// Floor below which a candidate isn't even considered — keeps the review
// candidate list small and relevant.
export const CANDIDATE_FLOOR = 0.3;

interface CompanyCandidate {
  autotask_company_id: number;
  score: number; // pg_trgm similarity(), 0.0-1.0
}

interface EligibleCompany {
  id: string;
  name: string;
}

export interface Pax8CompanyMatchResult {
  scanned: number;
  autoLinked: number;
  flaggedAmbiguous: number;
  flaggedNoCandidate: number;
  durationMs: number;
}

type Decision =
  | { kind: 'auto'; match: CompanyCandidate }
  | { kind: 'review'; top3: CompanyCandidate[] };

/**
 * Fuzzy candidate lookup against active Autotask companies. The PAX8 name is
 * always bound as $1 — never string-interpolated (Security Domain / T-12-01).
 * pg_trgm's similarity() is already case-insensitive (Pitfall 4) so no
 * LOWER() wrapping is added; TRIM() is applied to the input purely for
 * cleaner review-table display, not for scoring purposes.
 */
async function findCandidates(pax8Name: string): Promise<CompanyCandidate[]> {
  const res = await postgresClient.query<{ id: string; score: string }>(
    `SELECT id::text, similarity($1, company_name)::text AS score
       FROM companies
      WHERE is_active = true
        AND similarity($1, company_name) > ${CANDIDATE_FLOOR}
      ORDER BY score DESC
      LIMIT 5`,
    [pax8Name]
  );
  return res.rows.map((r) => ({
    autotask_company_id: Number(r.id),
    score: Number(r.score),
  }));
}

/**
 * D-01..D-04 decision logic: exactly one auto-link path (unambiguous,
 * high-confidence), everything else routed to review.
 */
function decide(candidates: CompanyCandidate[]): Decision {
  if (candidates.length === 0) return { kind: 'review', top3: [] }; // D-03
  const [best, second] = candidates;
  const tie = second !== undefined && best.score - second.score < TIE_MARGIN;
  if (best.score >= AUTO_LINK_THRESHOLD && !tie) {
    return { kind: 'auto', match: best };
  }
  return { kind: 'review', top3: candidates.slice(0, 3) }; // D-02/D-04
}

/**
 * Auto-link a PAX8 company to its matched Autotask company. Guarded so a
 * human-resolved match is never overwritten (D-05 / SC#4): the UPDATE only
 * fires when match_method isn't already 'manual' AND there is no review row
 * for this company that has already been human-resolved.
 *
 * Any open (never-human-touched) review row for this company is closed —
 * a now-confident automated match supersedes a stale open flag.
 */
async function applyLink(
  pax8CompanyId: string,
  autotaskCompanyId: number,
  score: number
): Promise<void> {
  await postgresClient.query(
    `UPDATE pax8_companies
        SET autotask_company_id = $2,
            match_confidence    = $3,
            match_method        = 'pg_trgm',
            matched_at          = NOW()
      WHERE id = $1
        AND match_method IS DISTINCT FROM 'manual'
        AND NOT EXISTS (
          SELECT 1 FROM pax8_company_match_review r
           WHERE r.pax8_company_id = pax8_companies.id
             AND r.resolved_at IS NOT NULL
        )`,
    [pax8CompanyId, autotaskCompanyId, score.toFixed(3)]
  );

  await postgresClient.query(
    `DELETE FROM pax8_company_match_review
      WHERE pax8_company_id = $1
        AND resolved_at IS NULL`,
    [pax8CompanyId]
  );
}

/**
 * Flag a PAX8 company for manual review, carrying its top-3 candidates
 * (D-04) or an empty array when there were none above CANDIDATE_FLOOR
 * (D-03). Upserts against the "one open review per company" partial unique
 * index (uq_pax8_company_match_review_open).
 *
 * If this company previously held a confident pg_trgm auto-match that is now
 * ambiguous or below-threshold, the stale match columns are cleared
 * conservatively — but a 'manual' match is never cleared this way.
 */
async function recordConflict(
  pax8CompanyId: string,
  top3: CompanyCandidate[]
): Promise<void> {
  await postgresClient.query(
    `UPDATE pax8_companies
        SET autotask_company_id = NULL,
            match_confidence    = NULL,
            match_method        = NULL,
            matched_at          = NULL
      WHERE id = $1
        AND match_method = 'pg_trgm'`,
    [pax8CompanyId]
  );

  const candidateIds = top3.map((c) => c.autotask_company_id);
  const confidences = top3.map((c) => c.score.toFixed(3));

  await postgresClient.query(
    `INSERT INTO pax8_company_match_review
       (pax8_company_id, candidate_company_ids, match_confidences)
     VALUES ($1, $2::bigint[], $3::text[])
     ON CONFLICT (pax8_company_id) WHERE resolved_at IS NULL
     DO UPDATE SET candidate_company_ids = EXCLUDED.candidate_company_ids,
                   match_confidences     = EXCLUDED.match_confidences,
                   detected_at           = NOW()`,
    [pax8CompanyId, candidateIds, confidences]
  );
}

/**
 * Run one matching pass over the re-scoring-eligible subset of
 * pax8_companies (D-05: excludes manually-resolved rows and rows with a
 * human-resolved review). Idempotent — safe to run on every sync.
 */
export async function matchPax8Companies(opts?: {
  limit?: number;
  dryRun?: boolean;
}): Promise<Pax8CompanyMatchResult> {
  const startedAt = Date.now();
  // WR-02 default raised well above any near-term PAX8/Autotask company
  // count (validated at 118 companies; generous headroom for growth) and
  // the eligibility ORDER BY below rotates fairness so accumulating
  // review-flagged rows can no longer permanently starve alphabetically
  // later companies even if this limit is ever hit.
  const limit = opts?.limit ?? 10000;
  const dryRun = opts?.dryRun ?? false;

  const result: Pax8CompanyMatchResult = {
    scanned: 0,
    autoLinked: 0,
    flaggedAmbiguous: 0,
    flaggedNoCandidate: 0,
    durationMs: 0,
  };

  try {
    // WR-02: ORDER BY name is a static cursor — once flagged-for-review
    // companies accumulate (their matched_at stays NULL indefinitely; see
    // recordConflict), they keep re-occupying the same slots in every run's
    // top-N by name, and once eligible rows exceed `limit`, everything
    // sorting alphabetically after that point is starved. Ordering instead
    // by "last time this row was actually considered" (the open review's
    // detected_at, falling back to matched_at, falling back to the epoch
    // for never-yet-scanned rows) rotates fairness: least-recently-attempted
    // rows always sort first, so a permanently-open review row sinks behind
    // any row that hasn't been reconsidered as recently.
    const eligible = await postgresClient.query<EligibleCompany>(
      `SELECT c.id::text, c.name
         FROM pax8_companies c
         LEFT JOIN pax8_company_match_review r
           ON r.pax8_company_id = c.id AND r.resolved_at IS NULL
        WHERE c.is_deleted = false
          AND c.match_method IS DISTINCT FROM 'manual'
          AND NOT EXISTS (
            SELECT 1 FROM pax8_company_match_review r2
             WHERE r2.pax8_company_id = c.id
               AND r2.resolved_at IS NOT NULL
          )
        ORDER BY COALESCE(r.detected_at, c.matched_at, 'epoch'::timestamptz) ASC
        LIMIT $1`,
      [limit]
    );

    for (const company of eligible.rows) {
      result.scanned += 1;
      const candidates = await findCandidates(company.name.trim());
      const decision = decide(candidates);

      if (decision.kind === 'auto') {
        if (!dryRun) {
          await applyLink(company.id, decision.match.autotask_company_id, decision.match.score);
        }
        result.autoLinked += 1;
      } else if (decision.top3.length === 0) {
        if (!dryRun) {
          await recordConflict(company.id, []);
        }
        result.flaggedNoCandidate += 1;
      } else {
        if (!dryRun) {
          await recordConflict(company.id, decision.top3);
        }
        result.flaggedAmbiguous += 1;
      }
    }

    result.durationMs = Date.now() - startedAt;
    console.log(
      `[Pax8Match] scanned=${result.scanned} autoLinked=${result.autoLinked} ` +
        `flaggedAmbiguous=${result.flaggedAmbiguous} flaggedNoCandidate=${result.flaggedNoCandidate} ` +
        `durationMs=${result.durationMs}`
    );
    return result;
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    console.error('[Pax8Match] failed:', msg);
    result.durationMs = Date.now() - startedAt;
    throw err;
  }
}
