import {
  BadRequestException,
  ConflictException,
  Injectable,
  Logger,
  NotFoundException,
  OnModuleInit,
  ServiceUnavailableException,
} from "@nestjs/common";
import { AuditService } from "../common/audit.service";
import { MailService } from "../mail/mail.service";
import { NotificationLogService } from "../notifications/notification-log.service";
import {
  NotificationScheduleService,
  SCHEDULE_TIME_ZONE,
} from "../notifications/notification-schedule.service";
import { DEBUG_RECIPIENT } from "../notifications/notification.types";
import { PrismaService } from "../prisma/prisma.service";
import {
  RenewalLetterPolicy,
  renewalLetterSelect,
  toRenewalLetterRow,
} from "../reports/renewal-letter";
import { renderRenewalEmail } from "./renewal-email";

export const RENEWAL_CADENCE = [
  { generation: 1, offsetDays: 30 },
  { generation: 2, offsetDays: 15 },
  { generation: 3, offsetDays: -7 },
] as const;

const JOB_NAME = "renewal-email-sweep";
/** The window maths runs in office time; the cadence itself is owned by
 *  `NotificationScheduleService`, which uses the same zone. */
const TIME_ZONE = SCHEDULE_TIME_ZONE;
const DAY_MS = 86400000;

export function dateInTimeZone(now: Date, timeZone = TIME_ZONE): Date {
  const parts = new Intl.DateTimeFormat("en-US", {
    timeZone,
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
  }).formatToParts(now);
  const value = (type: Intl.DateTimeFormatPartTypes) =>
    Number(parts.find((part) => part.type === type)?.value);
  return new Date(Date.UTC(value("year"), value("month") - 1, value("day")));
}

export function addUtcDays(date: Date, days: number): Date {
  return new Date(date.getTime() + days * DAY_MS);
}

export function renewalWindow(
  today: Date,
  offsetDays: number,
  lastSuccessfulAt?: Date | null,
): { from: Date; to: Date } {
  const to = addUtcDays(today, offsetDays);
  if (!lastSuccessfulAt) return { from: to, to };
  const previousDay = dateInTimeZone(lastSuccessfulAt);
  if (previousDay >= today) return { from: to, to };
  return { from: addUtcDays(previousDay, offsetDays + 1), to };
}

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

  constructor(
    private readonly prisma: PrismaService,
    private readonly mail: MailService,
    private readonly audit: AuditService,
    private readonly notificationLog: NotificationLogService,
    private readonly schedule: NotificationScheduleService,
  ) {}

  /** The cadence used to be a `@Cron("0 6 * * *")` literal here; it is now
   *  operator-editable, and the stored value defaults to that same 06:00
   *  daily run. */
  async onModuleInit(): Promise<void> {
    await this.schedule.register("polizas", () => this.scheduledSweep());
  }

  /** The unattended run always sends for real: `debug` is a per-click switch
   *  in the UI, never persisted, so the schedule cannot inherit a forgotten
   *  test toggle and silently stop mailing customers. */
  async scheduledSweep(): Promise<void> {
    try {
      await this.sweep();
    } catch (error) {
      this.logger.error(
        `Falló el barrido de renovaciones: ${(error as Error).message}`,
      );
    }
  }

  async pending(days = 30) {
    const today = dateInTimeZone(new Date());
    const state = await this.prisma.scheduledJobState.findUnique({
      where: { name: JOB_NAME },
      select: { lastSuccessfulAt: true },
    });
    const cadence = RENEWAL_CADENCE.filter(
      (item) => item.offsetDays < 0 || item.offsetDays <= days,
    );
    const groups = await Promise.all(
      cadence.map(async (item) => ({
        generation: item.generation,
        rows: await this.findCandidates(
          item,
          today,
          state?.lastSuccessfulAt ?? null,
        ),
      })),
    );

    return groups.flatMap(({ generation, rows }) =>
      rows
        .filter((policy) => Boolean(policy.customer.email?.trim()))
        .map((policy) => toRenewalLetterRow(policy, generation)),
    );
  }

  async sweep(userId?: string, flags: { debug?: boolean } = {}) {
    const debug = !!flags.debug;
    const now = new Date();
    const state = await this.acquireLock(now);

    try {
      if (!this.mail.available) {
        throw new ServiceUnavailableException(
          "El servicio de correo no está configurado.",
        );
      }

      const today = dateInTimeZone(now);
      let eligible = 0;
      let sent = 0;
      let skipped = 0;
      const failures: Array<{ policyId: string; generation: number; error: string }> = [];

      for (const cadence of RENEWAL_CADENCE) {
        const policies = await this.findCandidates(
          cadence,
          today,
          state.lastSuccessfulAt,
        );
        eligible += policies.length;

        for (const policy of policies) {
          const to = policy.customer.email?.trim();
          if (!to) {
            // Logged rather than silently counted: "we had nobody to mail"
            // is a finding the office acts on, and only the log survives the
            // HTTP response.
            await this.recordLog(policy, cadence.generation, "", {
              status: "SKIPPED_NO_EMAIL",
              debug,
            });
            skipped++;
            continue;
          }

          try {
            await this.deliver(policy, cadence.generation, to, userId, debug);
            sent++;
          } catch (error) {
            failures.push({
              policyId: policy.id,
              generation: cadence.generation,
              error: (error as Error).message,
            });
          }
        }
      }

      const result = {
        eligible,
        sent,
        skipped,
        failed: failures.length,
        failures,
        debug,
      };
      // A debug run must not advance `lastSuccessfulAt`: it wrote no
      // RenewalNotice rows, so the days it "covered" are still owed, and
      // narrowing tomorrow's window back to a single day would drop them.
      await this.releaseLock(!debug && failures.length === 0 ? now : null);
      void this.audit.log(userId, "renewalNotice.sweep", result);
      return result;
    } catch (error) {
      await this.releaseLock(null);
      throw error;
    }
  }

  /**
   * Send one pending renewal notice on demand, from the /notificaciones
   * list. Same path the sweep takes — render, send, then record the notice —
   * so a letter sent by hand is marked exactly like a swept one and drops
   * off the pending list. Refuses a generation already sent so a double
   * click can't mail the customer twice.
   *
   * Under `debug` the notice is NOT marked as sent, so the row stays in the
   * pending list — the customer has still not been told anything.
   */
  async sendOne(
    policyId: string,
    generation: number,
    userId?: string,
    flags: { debug?: boolean } = {},
  ) {
    const debug = !!flags.debug;
    if (!this.mail.available) {
      throw new ServiceUnavailableException(
        "El servicio de correo no está configurado.",
      );
    }

    const policy = await this.prisma.policy.findFirst({
      where: { id: policyId, archivedAt: null },
      select: renewalLetterSelect(generation),
    });
    if (!policy) {
      throw new NotFoundException("Póliza no encontrada.");
    }
    if (policy.renewalNotices.some((notice) => notice.sentAt)) {
      throw new ConflictException("Este aviso ya fue enviado.");
    }
    const to = policy.customer.email?.trim();
    if (!to) {
      throw new BadRequestException("El cliente no tiene correo registrado.");
    }

    const { sentAt, providerMessageId, addressedTo } = await this.deliver(
      policy,
      generation,
      to,
      userId,
      debug,
    );
    return {
      policyId,
      generation,
      // The address the mail actually went to — under debug that is the
      // override inbox, and the UI says so rather than claiming the customer
      // was notified.
      to: addressedTo,
      debug,
      sentAt: sentAt.toISOString(),
      providerMessageId,
    };
  }

  /** Render + send + record one notice. Shared by the sweep and `sendOne`.
   *
   *  Two records come out of a send: the `RenewalNotice` row, which gates the
   *  pending list, and an `email_notification_log` row, which is the send
   *  history the /notificaciones "Registro de envíos" reads. A failed send
   *  writes only the second — there is no notice to gate on — and rethrows so
   *  the sweep counts it as a failure.
   *
   *  Under `debug` the mail is diverted to `DEBUG_RECIPIENT` and the
   *  `RenewalNotice` row is deliberately skipped: the customer was not
   *  notified, so nothing may gate the letter they are still owed. Only the
   *  log row is written, flagged `debug`. */
  private async deliver(
    policy: RenewalLetterPolicy,
    generation: number,
    to: string,
    userId?: string,
    debug = false,
  ) {
    const letter = toRenewalLetterRow(policy, generation);
    const message = renderRenewalEmail(letter);
    const addressedTo = debug ? DEBUG_RECIPIENT : to;

    let result: Awaited<ReturnType<MailService["send"]>>;
    try {
      result = await this.mail.send({
        to: addressedTo,
        toName: letter.customerName,
        subject: message.subject,
        html: message.html,
        xTracking: debug ? "debug" : "renewals",
      });
    } catch (error) {
      const detail = error instanceof Error ? error.message : String(error);
      await this.recordLog(policy, generation, addressedTo, {
        status: "FAILED",
        error: detail,
        debug,
      });
      throw error;
    }

    const sentAt = new Date();

    if (!debug) {
      await this.prisma.renewalNotice.upsert({
        where: {
          policyId_generation: { policyId: policy.id, generation },
        },
        create: {
          policyId: policy.id,
          generation,
          channel: "EMAIL",
          sentAt,
          sentById: userId,
          providerMessageId: result.messageId,
        },
        update: {
          channel: "EMAIL",
          sentAt,
          sentById: userId,
          providerMessageId: result.messageId,
        },
      });
    }
    await this.recordLog(policy, generation, addressedTo, {
      status: "SENT",
      providerMessageId: result.messageId || undefined,
      providerResponse: result.response || undefined,
      sendDate: sentAt,
      debug,
    });
    void this.audit.log(userId, "renewalNotice.send", {
      policyId: policy.id,
      generation,
      debug,
      providerMessageId: result.messageId,
    });
    return { sentAt, providerMessageId: result.messageId, addressedTo };
  }

  /**
   * Write one row to the shared notification log.
   *
   * Never throws: the mail is already gone (or already failed) by the time we
   * get here, and losing the audit row must not turn a delivered notice into
   * a reported failure — which on the SENT path would also strand the
   * `RenewalNotice` we just wrote and re-send tomorrow.
   */
  private async recordLog(
    policy: RenewalLetterPolicy,
    generation: number,
    /** Recipient as addressed. Empty on the SKIPPED_NO_EMAIL path — that
     *  emptiness IS the reason the row exists. */
    to: string,
    outcome: {
      status: "SENT" | "FAILED" | "SKIPPED_NO_EMAIL";
      providerMessageId?: string;
      providerResponse?: string;
      error?: string;
      sendDate?: Date;
      debug?: boolean;
    },
  ): Promise<void> {
    const letter = toRenewalLetterRow(policy, generation);
    const message = renderRenewalEmail(letter);
    try {
      await this.notificationLog.record({
        notificationType: "RENEWAL_NOTICE",
        servicio: "POLICIES",
        sendDate: outcome.sendDate,
        // `level` carries the aviso generation for RENEWAL_NOTICE rows — see
        // the column doc on the Prisma model.
        level: generation,
        customerId: policy.customer.id,
        customerName: letter.customerName,
        customerEmail: to,
        subject: message.subject,
        bodySnapshot: message.html,
        status: outcome.status,
        debug: !!outcome.debug,
        providerMessageId: outcome.providerMessageId,
        providerResponse: outcome.providerResponse,
        error: outcome.error,
      });
    } catch (error) {
      this.logger.warn(
        `No se pudo registrar el aviso de renovación en el log ` +
          `(póliza ${policy.id}, aviso ${generation}): ` +
          `${(error as Error).message}`,
      );
    }
  }

  private findCandidates(
    cadence: (typeof RENEWAL_CADENCE)[number],
    today: Date,
    lastSuccessfulAt: Date | null,
  ) {
    const window = renewalWindow(today, cadence.offsetDays, lastSuccessfulAt);
    return this.prisma.policy.findMany({
      where: {
        archivedAt: null,
        policyTo: { gte: window.from, lte: window.to },
        customer: {
          archivedAt: null,
          emailOptOut: false,
          email: { not: "" },
        },
        renewalNotices: {
          none: { generation: cadence.generation, sentAt: { not: null } },
        },
      },
      orderBy: [{ policyTo: "asc" }, { policyNumber: "asc" }],
      select: renewalLetterSelect(cadence.generation),
    });
  }

  private async acquireLock(now: Date) {
    await this.prisma.scheduledJobState.upsert({
      where: { name: JOB_NAME },
      create: { name: JOB_NAME },
      update: { updatedAt: now },
    });

    const acquired = await this.prisma.scheduledJobState.updateMany({
      where: {
        name: JOB_NAME,
        OR: [{ lockedUntil: null }, { lockedUntil: { lte: now } }],
      },
      data: { lockedUntil: new Date(now.getTime() + 2 * 60 * 60 * 1000) },
    });

    if (acquired.count !== 1) {
      throw new ConflictException(
        "Ya hay un barrido de renovaciones en curso.",
      );
    }

    return this.prisma.scheduledJobState.findUniqueOrThrow({
      where: { name: JOB_NAME },
    });
  }

  private async releaseLock(lastSuccessfulAt: Date | null): Promise<void> {
    await this.prisma.scheduledJobState.update({
      where: { name: JOB_NAME },
      data: {
        lockedUntil: null,
        ...(lastSuccessfulAt && { lastSuccessfulAt }),
      },
    });
  }
}
