import {
  ConflictException,
  Injectable,
  Logger,
  NotFoundException,
} from "@nestjs/common";
import { Prisma } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service";
import { SettingsService } from "../settings/settings.service";

/**
 * Allocation of the portal NUMid — the "Security Number" my.jorgecuadros.com
 * asks for at login.
 *
 * The NUMid is not a column on `Customer`. It is a `CustomerLegacyRef` row with
 * (sourceSystem='utilities', sourceTable='DATGRAL'), and `CustomersService.create`
 * deliberately writes none: a natively created customer has no legacy provenance.
 * The consequence is that every customer created in the staff UI is invisible to
 * the portal until this service gives them an id.
 *
 * WHY THIS IS NOT DONE AT CREATE TIME. Insurance is expected to move to the
 * platform before utilities, and an insurance-only customer has no reason to hold
 * a portal identity. Allocating on every create would spend utilities ids — and
 * the handful of reusable ones — on people who will never log in. So this is an
 * explicit staff action instead.
 */

/** The pair that identifies a portal NUMid. */
export const UTILITIES_SYSTEM = "utilities";
export const UTILITIES_TABLE = "DATGRAL";

/**
 * insurance/DATGRAL is a SEPARATE id space that reuses the same sourceTable name
 * and runs past 4,000. It must never be read as a NUMid, and never allocated
 * from: the portal cannot resolve those ids. Every query here filters on BOTH
 * columns for that reason, never on sourceTable alone. A customer can also hold
 * more than one insurance ref — 16 of them do, where several insurance rows
 * folded into one customer — so those are tested with EXISTS rather than joined.
 */
const POOL = {
  sourceSystem: UTILITIES_SYSTEM,
  sourceTable: UTILITIES_TABLE,
} as const;

export type AllocationOrigin = "existing" | "new" | "recycled";

export interface Allocation {
  numid: string;
  origin: AllocationOrigin;
  /** Set only on a recycle — the customer the id was taken from. */
  previousCustomerId?: string;
}

/**
 * NUMids that were created and never used, safe for an allocator to take.
 *
 * THE TWO OBVIOUS RULES BOTH FIND NOTHING, which is why this one looks the way
 * it does. "Owns no rows" matches nobody: migration gave all 1,171 NUMids a
 * property and a transaction. "No transaction in N years" also matches nobody:
 * every customer carries a synthetic Jan-1 opening-balance row, so everyone
 * looks active in the current year. That row has to be subtracted before any
 * activity test means anything, which is what `bf` does below.
 *
 * The balance-forward row is matched in two shapes on purpose.
 * transform_transactions.py:120 mints a type literally named 'BALANCE FORWARD';
 * databases loaded before that change carry the same rows with typeId NULL,
 * dated Jan 1, legacySourceTable='datos2'. Matching only the type name floors
 * nothing on such a database and turns the balance test into a raw lifetime sum
 * — the double-count that read the whole book as +20.6M MXN in credit before
 * d173c9e, and which here would mark live customers as empty.
 *
 * Services are tested as "any service" rather than "any ACTIVE service": a
 * deactivated water account is still a record of somebody having lived behind
 * this id.
 *
 * Kept in step with scripts/numid-audit.sql, which reports the same tier for a
 * human. That script is the reporting copy of this rule; change both together.
 */
const EMPTY_NUMID_SQL = Prisma.sql`
  WITH bf AS (
    SELECT t.id, t.customerId
    FROM transactions t
    LEFT JOIN type_transactions tt ON tt.id = t.typeId
    WHERE t.voidedAt IS NULL
      AND (
        tt.nameEn = 'BALANCE FORWARD'
        OR (t.typeId IS NULL AND MONTH(t.transactionDate) = 1 AND DAY(t.transactionDate) = 1
            AND t.legacySourceTable = 'datos2')
      )
  )
  SELECT r.legacyId AS numid, r.id AS refId, r.customerId AS customerId
  FROM customer_legacy_refs r
  JOIN customers c ON c.id = r.customerId
  WHERE r.sourceSystem = ${UTILITIES_SYSTEM} AND r.sourceTable = ${UTILITIES_TABLE}
    AND (c.email IS NULL OR c.email = '')
    AND NOT EXISTS (SELECT 1 FROM transactions t
                    WHERE t.customerId = c.id AND t.voidedAt IS NULL
                      AND t.id NOT IN (SELECT id FROM bf))
    AND NOT EXISTS (SELECT 1 FROM transactions t
                    WHERE t.customerId = c.id AND t.voidedAt IS NULL AND t.outstanding = 1)
    AND NOT EXISTS (SELECT 1 FROM property_services ps
                    JOIN properties p ON p.id = ps.propertyId WHERE p.customerId = c.id)
    AND NOT EXISTS (SELECT 1 FROM policies p WHERE p.customerId = c.id)
    AND NOT EXISTS (SELECT 1 FROM vehicles v WHERE v.customerId = c.id)
    AND NOT EXISTS (SELECT 1 FROM trust_accounts ta
                    JOIN properties p ON p.id = ta.propertyId WHERE p.customerId = c.id)
    AND NOT EXISTS (SELECT 1 FROM statement_documents s WHERE s.matchedCustomerId = c.id)
    AND NOT EXISTS (SELECT 1 FROM policy_ocr_documents o WHERE o.matchedCustomerId = c.id)
    AND NOT EXISTS (SELECT 1 FROM email_notification_log e WHERE e.customerId = c.id)
    AND NOT EXISTS (SELECT 1 FROM email_log e WHERE e.customerId = c.id)
    AND NOT EXISTS (SELECT 1 FROM account_status_history a WHERE a.customerId = c.id)
    AND NOT EXISTS (SELECT 1 FROM customer_legacy_refs i
                    WHERE i.customerId = c.id AND i.sourceSystem = 'insurance')
  ORDER BY CAST(r.legacyId AS UNSIGNED)`;

interface EmptyRow {
  numid: string;
  refId: string;
  customerId: string;
}

@Injectable()
export class NumidService {
  private readonly logger = new Logger(NumidService.name);

  constructor(
    private readonly prisma: PrismaService,
    private readonly settings: SettingsService,
  ) {}

  /** The customer's portal id, or null if they have none. */
  async current(customerId: string): Promise<string | null> {
    const ref = await this.prisma.customerLegacyRef.findFirst({
      where: { customerId, ...POOL },
      select: { legacyId: true },
    });
    return ref?.legacyId ?? null;
  }

  /** Reusable ids, lowest first. Empty unless recycling is switched on. */
  async emptyCandidates(): Promise<string[]> {
    const rows = await this.prisma.$queryRaw<EmptyRow[]>(EMPTY_NUMID_SQL);
    return rows.map((r) => r.numid);
  }

  /**
   * Give a customer a portal NUMid.
   *
   * Idempotent: a customer who already holds one gets it back rather than a
   * second id, so a double-clicked button cannot fork an identity.
   */
  async allocate(customerId: string): Promise<Allocation> {
    const customer = await this.prisma.customer.findUnique({
      where: { id: customerId },
      select: { id: true, archivedAt: true },
    });
    if (!customer) throw new NotFoundException(`Customer ${customerId} not found`);
    if (customer.archivedAt) {
      throw new ConflictException(
        "No se puede asignar un número de portal a un cliente archivado",
      );
    }

    const existing = await this.current(customerId);
    if (existing) return { numid: existing, origin: "existing" };

    const recycle = await this.recycleEnabled();

    // Two writers can pick the same id between the read and the write. The
    // unique key on (sourceSystem, sourceTable, legacyId) is what actually
    // decides the winner; the loser retries and takes the next id rather than
    // silently overwriting. Bounded so a genuinely wedged pool fails loudly.
    for (let attempt = 0; attempt < 5; attempt++) {
      try {
        if (recycle) {
          const recycled = await this.tryRecycle(customerId);
          if (recycled) return recycled;
        }
        return await this.allocateNext(customerId);
      } catch (error) {
        if (!isUniqueViolation(error)) throw error;
        this.logger.warn(
          `NUMid allocation for ${customerId} lost a race (attempt ${attempt + 1}), retrying`,
        );
      }
    }
    throw new ConflictException(
      "No se pudo asignar un número de portal; intente de nuevo",
    );
  }

  /**
   * Whether the recycle tier is live.
   *
   * Off by default, and that default is the safe one while Access is still the
   * utilities master. Every id in the pool ALSO exists in Access DATGRAL, and a
   * `--sync` migration run upserts refs with ON DUPLICATE KEY UPDATE customerId
   * (transform_customers.py:327) — so an id recycled today is silently handed
   * back to its Access owner on the next sync, and the customer who was given it
   * loses their portal identity. Turn this on once utilities has cut over, or
   * for ids that have been deleted at the source.
   */
  private async recycleEnabled(): Promise<boolean> {
    const { value } = await this.settings.numidRecycleEmpty();
    return value;
  }

  /** Re-point the lowest empty id at this customer. Null when none is free. */
  private async tryRecycle(customerId: string): Promise<Allocation | null> {
    const rows = await this.prisma.$queryRaw<EmptyRow[]>(EMPTY_NUMID_SQL);
    for (const row of rows) {
      // Guarded by the owner we just read: if anything moved the ref in the
      // meantime the update matches nothing and we fall through to the next
      // candidate rather than stealing an id that is no longer empty.
      const moved = await this.prisma.customerLegacyRef.updateMany({
        where: { id: row.refId, customerId: row.customerId },
        data: { customerId },
      });
      if (moved.count === 1) {
        this.logger.log(
          `NUMid ${row.numid} recycled from ${row.customerId} to ${customerId}`,
        );
        return {
          numid: row.numid,
          origin: "recycled",
          previousCustomerId: row.customerId,
        };
      }
    }
    return null;
  }

  /** One past the highest id in the pool. */
  private async allocateNext(customerId: string): Promise<Allocation> {
    const [{ max }] = await this.prisma.$queryRaw<{ max: number | null }[]>(
      // MAX over a CAST, not over the string: legacyId is VARCHAR, so a plain
      // MAX returns '999' as the highest of 1,171 rows and the allocator hands
      // out an id that is already taken.
      Prisma.sql`SELECT MAX(CAST(legacyId AS UNSIGNED)) AS max
                 FROM customer_legacy_refs
                 WHERE sourceSystem = ${UTILITIES_SYSTEM} AND sourceTable = ${UTILITIES_TABLE}`,
    );
    const numid = String(Number(max ?? 0) + 1);
    await this.prisma.customerLegacyRef.create({
      data: { customerId, ...POOL, legacyId: numid },
    });
    return { numid, origin: "new" };
  }
}

function isUniqueViolation(error: unknown): boolean {
  return (
    error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002"
  );
}
