import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import type { ParsedPolicy } from "./parsers/policy-parser";
import {
  suggestCustomersByName,
  suggestionNote,
  type CustomerNameRow,
  type CustomerNameSuggestion,
} from "./name-matcher";

export interface MatchResult {
  policyId: string | null;
  customerId: string | null;
  /** Why it landed here — shown in the review queue verbatim. */
  note: string;
  /** True only for an unambiguous hit on `Policy.policyNumber`. */
  confident: boolean;
  /**
   * Every policy that carries the parsed number, with its customer. >1 means
   * the policy number is shared across customers and a human must pick.
   */
  candidates: { policyId: string; customerId: string; customerName: string; policyNumber: string }[];
  /**
   * Customers whose name resembles the printed insured name. Populated only
   * when the policy number resolved to nothing, and never used to set
   * `customerId` or `confident` — see the class comment.
   */
  customerSuggestions: CustomerNameSuggestion[];
}

/**
 * How long the customer book is reused across documents in a batch.
 *
 * A twenty-page batch would otherwise read all 1536 rows twenty times. The
 * only cost of the staleness is that a customer created in the last minute
 * is not suggested — the picker still finds them, so nothing is lost that a
 * reviewer cannot do in one click.
 */
const BOOK_TTL_MS = 60_000;

/**
 * Resolves a parsed policy page to an existing Policy (and its customer) the
 * office already holds.
 *
 * **Match on `Policy.policyNumber` alone, never on the printed insured name.**
 * The certificate's "Insured" line is the account's registrant, which drifts
 * from the current owner — the same problem the statement matcher cites for
 * utility bills ("ARNAIZ ROSAS ELSA AURORA" on a CESPT receipt for a
 * customer this office holds as "CATT, RANDY"). Names are surfaced for the
 * reviewer to sanity-check and never feed matching.
 *
 * A policy number that matches zero rows means the policy is new: the
 * review screen then offers a customer picker and the confirm step creates
 * the row. Multiple hits are surfaced rather than auto-picked — duplicate
 * policy numbers across customers do occur (same group policy bound by two
 * related parties), and picking one arbitrarily would silently book the
 * wrong coverage.
 *
 * On that zero-hit path only, the printed name is used to *rank the picker*
 * — see `name-matcher.ts`. That is not a walk-back of the rule above: the
 * suggestion never reaches `customerId` or `confident`, a human still picks,
 * and the ranking exists because the office writes names surname-first
 * ("WAGONER, PAMELA") while carriers print them given-name-first ("PAMELA
 * DENISE WAGONER"), so the reviewer is retyping a name the machine could
 * have offered.
 */
@Injectable()
export class PolicyMatcherService {
  private book: { rows: CustomerNameRow[]; loadedAt: number } | null = null;

  constructor(private readonly prisma: PrismaService) {}

  async match(parsed: ParsedPolicy): Promise<MatchResult> {
    if (!parsed.policyNumber) {
      // No number to search on, so the page goes to review with a picker —
      // the same place the name suggestions help.
      return this.unmatched(
        "no se pudo leer el número de póliza",
        await this.suggestByName(parsed.insuredName),
      );
    }

    const rows = await this.prisma.policy.findMany({
      where: { policyNumber: parsed.policyNumber },
      select: {
        id: true,
        policyNumber: true,
        customerId: true,
        customer: { select: { name: true } },
      },
    });

    const candidates = rows.map((r) => ({
      policyId: r.id,
      customerId: r.customerId,
      customerName: r.customer.name,
      policyNumber: r.policyNumber,
    }));

    if (rows.length === 0) {
      const suggestions = await this.suggestByName(parsed.insuredName);
      const hint = suggestionNote(suggestions);
      return {
        policyId: null,
        customerId: null,
        note: [
          `no se encontró ninguna póliza con el número ${parsed.policyNumber}`,
          hint,
        ]
          .filter(Boolean)
          .join("; "),
        confident: false,
        candidates: [],
        customerSuggestions: suggestions,
      };
    }

    if (rows.length > 1) {
      // The policy number did find rows; the reviewer picks among those, and
      // adding name guesses on top would only add noise.
      return {
        policyId: null,
        customerId: null,
        note: `${rows.length} pólizas comparten el número ${parsed.policyNumber}`,
        confident: false,
        candidates,
        customerSuggestions: [],
      };
    }

    return {
      policyId: candidates[0].policyId,
      customerId: candidates[0].customerId,
      note: `coincidencia exacta por número de póliza ${parsed.policyNumber}`,
      confident: true,
      candidates,
      customerSuggestions: [],
    };
  }

  private async suggestByName(
    insuredName: string | null | undefined,
  ): Promise<CustomerNameSuggestion[]> {
    if (!insuredName) return [];
    return suggestCustomersByName(insuredName, await this.customerBook());
  }

  /**
   * The whole customer book, held briefly. 1536 rows of `{id, name}` is a
   * few hundred kilobytes and the comparison is pure token-set work, so
   * scanning it beats any SQL approximation — and a `LIKE` search would in
   * any case have to guess which token is the surname, which is the one
   * thing the office's own data does not agree on.
   */
  private async customerBook(): Promise<CustomerNameRow[]> {
    if (this.book && Date.now() - this.book.loadedAt < BOOK_TTL_MS) {
      return this.book.rows;
    }
    const rows = await this.prisma.customer.findMany({
      select: { id: true, name: true },
    });
    this.book = { rows, loadedAt: Date.now() };
    return rows;
  }

  private unmatched(
    note: string,
    customerSuggestions: CustomerNameSuggestion[] = [],
  ): MatchResult {
    const hint = suggestionNote(customerSuggestions);
    return {
      policyId: null,
      customerId: null,
      note: [note, hint].filter(Boolean).join("; "),
      confident: false,
      candidates: [],
      customerSuggestions,
    };
  }
}