import { ViewpointMember } from "@karma/viewpoint-sdk/member";
import type { Account } from "@karma/viewpoint-sdk/types";
import { logError } from "@/lib/logger";
import { getMailQueue } from "@/internal/queue/mail";
import type { TAccountDeletionRepository } from "./account-deletion.repository";
import {
  DELETION_TEMPLATES,
  resolveDeletionRegion,
  resolveRegionFromChargeCentre,
  type RegionSource,
  type DeletionRegion,
} from "./deletion-templates";

/**
 * Retiring a member account.
 *
 * Two systems hold the answer and they are not equals: Viewpoint is the system of
 * record for account status, the member database is Subito's working copy. So the
 * order is fixed — Viewpoint first, and the local mirror only if Viewpoint accepted
 * the change. The reverse order can leave an account that Subito refuses to serve
 * while Viewpoint still shows it live, which is invisible from both consoles.
 *
 * "Deletion" is a status move, never a row delete. Nothing here removes a member,
 * a booking or a contact.
 */

export type AccountDeletionAction = "invalid" | "cancellation_subito";

export interface AccountSearchResult {
  /** The Viewpoint AccountID, which is the member database's membership number. */
  accountNumber: string;
  viewpoint: {
    found: boolean;
    accountName: string | null;
    accountType: string | null;
    accountStatus: string | null;
    accountStatusID: number | null;
    joinedDate: string | null;
    country: string | null;
    chargeCentreID: string | null;
    chargeCentreName: string | null;
    contacts: Array<{
      contactID: number;
      name: string;
      email: string | null;
      isOwner: boolean;
      isPrimary: boolean;
      active: boolean;
    }>;
  };
  local: {
    found: boolean;
    members: Array<{
      memberID: string;
      memberNumber: string;
      userID: string;
      email: string | null;
      firstName: string | null;
      lastName: string | null;
      isOwner: boolean | null;
      isActive: boolean | null;
      isDeleted: boolean | null;
      userIsActive: boolean | null;
      country: string | null;
      accountTypeName: string;
      accountStatusID: string;
      accountStatusName: string;
    }>;
    activeSessions: number;
  };
  /**
   * Which region's deletion letters this account would get.
   *
   * Detected, not decided: the operator sees it in the confirmation dialog and can
   * change it. An account with no country on file resolves to "others", which is the
   * correct default and also the one most worth being able to correct.
   */
  region: DeletionRegion;
  /**
   * What decided the region.
   *
   * Shown in the confirmation dialog. "fallback" means neither the charge centre nor
   * the country said anything and the account landed on "Rest of world" by default,
   * which is a different thing from a member who genuinely is outside the named
   * regions — and only one of the two is worth correcting.
   */
  regionSource: RegionSource;
}

const contactName = (first?: string | null, last?: string | null) =>
  [first, last].filter(Boolean).join(" ").trim();

/**
 * The country the region is read off.
 *
 * Viewpoint's billing country first — it is the account's address, which is what the
 * data-protection regime follows — then the member profile, then a contact's own
 * address. Older accounts frequently have one of the three and not the others.
 */
/**
 * Which region's letters an account gets, and what decided it.
 *
 * Charge centre first: it is where the membership is billed, which states which
 * entity the member belongs to more firmly than an address that may be a holiday
 * one, or absent. The country is consulted only when the charge centre is unknown or
 * its name names no region.
 */
const decideRegion = (
  chargeCentreName: string | null,
  country: string | null,
): { region: DeletionRegion; regionSource: RegionSource } => {
  const fromCentre = resolveRegionFromChargeCentre(chargeCentreName);
  if (fromCentre) {
    return { region: fromCentre, regionSource: "charge-centre" };
  }
  if (country) {
    return {
      region: resolveDeletionRegion(country),
      regionSource: "country",
    };
  }
  return { region: "others", regionSource: "fallback" };
};

const accountCountry = (
  viewpointCountry: string | null,
  memberCountries: (string | null)[],
): string | null =>
  viewpointCountry?.trim() ||
  memberCountries.find((country) => country?.trim())?.trim() ||
  null;

export const AccountDeletionServices = (deps: {
  AccountDeletionRepository: TAccountDeletionRepository;
}) => {
  const { AccountDeletionRepository: repo } = deps;

  /*
   * Constructed per call rather than at module load: the constructor throws when the
   * Viewpoint credentials are missing, and doing that at import time would take the
   * whole core process down over a feature almost nobody uses.
   */
  const viewpoint = () => new ViewpointMember();

  /**
   * Names the account's charge centre from the console's mirror of Viewpoint's list.
   *
   * A miss is not an error: the mirror can lag Viewpoint, and an unmapped centre
   * simply means the region falls through to the country. Mutates the shaped view
   * rather than threading the name through the synchronous shaper.
   */
  const withChargeCentre = async <
    T extends { chargeCentreID: string | null; chargeCentreName: string | null },
  >(
    view: T,
  ): Promise<T> => {
    if (!view.chargeCentreID) return view;
    try {
      const centre = await repo.FindChargeCentreByViewpointID(
        view.chargeCentreID,
      );
      view.chargeCentreName = centre?.name ?? null;
    } catch (err) {
      logError(err);
    }
    return view;
  };

  const shapeViewpoint = (account: Account | null) => ({
    found: Boolean(account),
    accountName: account?.AccountName ?? null,
    accountType: account?.AccountType ?? null,
    accountStatus: account?.AccountStatus ?? null,
    accountStatusID: account?.AccountStatusID ?? null,
    joinedDate: account?.JoinedDate ?? null,
    country: account?.BillingAddrCountry ?? null,
    chargeCentreID: account?.DefaultChargeCenter
      ? String(account.DefaultChargeCenter)
      : null,
    // Filled in by `withChargeCentre` — the name is a database lookup and this
    // shaper is synchronous.
    chargeCentreName: null as string | null,
    contacts: [...(account?.Owners ?? []), ...(account?.Contacts ?? [])].map(
      (member) => ({
        contactID: member.ContactID,
        name: contactName(member.FirstName, member.LastName),
        email: member.Email ?? null,
        isOwner: member.IsOwner,
        isPrimary: member.IsPrimary,
        active: member.Active,
      }),
    ),
  });

  const shapeLocal = async (
    rows: Awaited<ReturnType<TAccountDeletionRepository["FindMembersByMembershipNumber"]>>,
  ) => {
    const userIDs = [...new Set(rows.map((row) => String(row.user_id)))];
    return {
      found: rows.length > 0,
      members: rows.map((row) => ({
        memberID: String(row.member_id),
        memberNumber: row.member_number,
        userID: String(row.user_id),
        email: row.email ?? null,
        firstName: row.first_name ?? null,
        lastName: row.last_name ?? null,
        isOwner: row.is_owner ?? null,
        isActive: row.is_active ?? null,
        isDeleted: row.is_deleted ?? null,
        userIsActive: row.user_is_active ?? null,
        country: row.country ?? null,
        accountTypeName: row.account_type_name,
        accountStatusID: String(row.account_status_id),
        accountStatusName: row.account_status_name,
      })),
      activeSessions: await repo.CountActiveSessions(userIDs),
    };
  };

  /**
   * Finds one account by its number.
   *
   * Viewpoint is queried live rather than read from the member database, because the
   * operator is about to act on the account's *current* status and the local mirror
   * can be a sync behind. A Viewpoint outage does not fail the search — it returns
   * `viewpoint.found: false` and the local record, and the apply step refuses.
   */
  const SearchByAccountNumber = async (
    accountNumber: string,
  ): Promise<AccountSearchResult> => {
    const [account, rows] = await Promise.all([
      viewpoint()
        .findByAccountID(accountNumber)
        .catch((err) => {
          logError(err);
          return null;
        }),
      repo.FindMembersByMembershipNumber(accountNumber),
    ]);

    // Named apart from the `viewpoint()` client factory above — shadowing it here
    // is what broke the lookup this function performs.
    const viewpointView = await withChargeCentre(shapeViewpoint(account));
    const localView = await shapeLocal(rows);

    return {
      accountNumber,
      viewpoint: viewpointView,
      local: localView,
      ...decideRegion(
        viewpointView.chargeCentreName,
        accountCountry(
          viewpointView.country,
          localView.members.map((member) => member.country),
        ),
      ),
    };
  };

  /**
   * Finds every account an address appears on.
   *
   * An email is not a key in either system — a household shares one, and a person
   * can own several memberships — so this returns a list for the operator to pick
   * from rather than guessing which one was meant.
   */
  const SearchByEmail = async (
    email: string,
  ): Promise<AccountSearchResult[]> => {
    const [accounts, rows] = await Promise.all([
      viewpoint()
        .findByEmail(email)
        .catch((err) => {
          logError(err);
          return [] as Account[];
        }),
      repo.FindMembersByEmail(email),
    ]);

    const numbers = [
      ...new Set([
        ...accounts.map((account) => account.AccountID),
        ...rows.map((row) => row.membership_number),
      ]),
    ];

    return await Promise.all(
      numbers.map(async (accountNumber) => {
        const viewpointView = await withChargeCentre(
          shapeViewpoint(
            accounts.find((account) => account.AccountID === accountNumber) ??
              null,
          ),
        );
        const localView = await shapeLocal(
          rows.filter((row) => row.membership_number === accountNumber),
        );
        return {
          accountNumber,
          viewpoint: viewpointView,
          local: localView,
          ...decideRegion(
            viewpointView.chargeCentreName,
            accountCountry(
              viewpointView.country,
              localView.members.map((member) => member.country),
            ),
          ),
        };
      }),
    );
  };

  const ListTargetStatuses = async () => await repo.FindAccountStatuses();

  /**
   * Saves the follow-up window, and answers with what was actually stored.
   *
   * Clamped through the same reader that serves it, so the number the console shows
   * back is the number a deletion will use rather than the one that was typed.
   */
  const SaveFollowupDays = async (
    days: number,
    updatedBy: string | null,
  ): Promise<number> => {
    await repo.SaveFollowupDaysSetting(
      Math.min(Math.max(Math.round(days), 1), 365),
      updatedBy,
    );
    return await GetFollowupDays();
  };

  /**
   * The configured follow-up window, in days.
   *
   * Falls back to the repository default when the setting has never been saved or
   * holds something unusable. Clamped to 1–365: a zero would send the "your data has
   * been deleted" letter in the same breath as the "we have received your request"
   * one, and an unbounded value would park mail in the queue indefinitely.
   */
  const GetFollowupDays = async (): Promise<number> => {
    const raw = await repo.FindFollowupDaysSetting();
    if (typeof raw !== "number" || !Number.isFinite(raw)) {
      return repo.DEFAULT_FOLLOWUP_DAYS;
    }
    return Math.min(Math.max(Math.round(raw), 1), 365);
  };

  const ListRecords = async (params: {
    page: number;
    pageSize: number;
    search?: string;
  }) => await repo.ListDeletionRecords(params);

  /**
   * Every account that is currently retired, wherever it was retired from.
   *
   * One row per account. The member database decides who is on the list — it holds
   * the status, and an account moved to Invalid directly in Viewpoint is retired
   * whether or not this console did it. The console's own record is then attached
   * where one exists, which is what supplies the reason, the operator and the two
   * emails.
   *
   * An account with no record is shown as retired elsewhere rather than hidden. That
   * is the case most worth seeing: the account is closed, no acknowledgement went
   * out, and no data-deletion email is scheduled, because nothing here knows it
   * happened.
   */
  const ListRetiredAccounts = async (params: {
    page: number;
    pageSize: number;
    search?: string;
  }) => {
    const statuses = await repo.FindAccountStatuses();
    const { rows, total } = await repo.FindRetiredAccounts({
      statusIDs: statuses.map((status) => String(status.id)),
      page: params.page,
      pageSize: params.pageSize,
      search: params.search,
    });

    const records = await repo.FindLatestRecordsForAccounts(
      rows.map((row) => String(row.membership_number)),
    );

    return {
      total,
      items: rows.map((row) => {
        const accountNumber = String(row.membership_number);
        const record = records.get(accountNumber) ?? null;
        return {
          accountNumber,
          name:
            [row.first_name, row.last_name].filter(Boolean).join(" ") || null,
          statusName: row.status_name,
          memberCount: Number(row.member_count ?? 0),
          statusChangedAt: row.updated_at,
          /*
           * Where the retirement came from.
           *
           * "console" means this system did it and can account for it. "external"
           * means the account arrived in that status by another route — a direct
           * change in Viewpoint, or a sync — and no emails were sent for it.
           */
          source: record ? ("console" as const) : ("external" as const),
          record,
        };
      }),
    };
  };

  /**
   * Who the mail goes to.
   *
   * The member database first, because that is where Subito's addresses live and
   * they are the ones the member actually signs in with. Viewpoint's contacts fill
   * in when there is no local record at all — an account can exist there and not
   * here, and that member still deserves the notice.
   *
   * De-duplicated case-insensitively: a household commonly shares an address across
   * several contacts, and sending the same notice four times is worse than not
   * sending it at all.
   */
  const resolveRecipients = (account: AccountSearchResult): string[] => {
    const fromLocal = account.local.members
      .map((member) => member.email)
      .filter((email): email is string => Boolean(email));

    const emails = fromLocal.length
      ? fromLocal
      : account.viewpoint.contacts
          .filter((contact) => contact.active)
          .map((contact) => contact.email)
          .filter((email): email is string => Boolean(email));

    const seen = new Set<string>();
    return emails.filter((email) => {
      const key = email.trim().toLowerCase();
      if (!key || seen.has(key)) return false;
      seen.add(key);
      return true;
    });
  };


  /**
   * Writes the deletion into the account's Viewpoint log.
   *
   * Viewpoint is where the service centre teams work, so a status change that only
   * appears in this console is a change they cannot see the reason for. The log entry
   * carries who did it and why, which is the part the status field cannot hold.
   *
   * Never throws. It runs after the status change has already been made, so a failure
   * here costs the audit note and nothing else — reported back so the console can
   * say the note did not land, rather than silently implying it did.
   */
  const writeViewpointLog = async (params: {
    accountNumber: string;
    subject: string;
    comment: string;
  }): Promise<string | null> => {
    try {
      await viewpoint().createLog({
        accountID: params.accountNumber,
        // AUDIT rather than MEMBER_PORTAL: this is a record of an operator's action
        // on the account, not something the member did. The SDK sets the status,
        // source and assignee from the account's service centre.
        type: "AUDIT",
        subject: params.subject,
        comment: params.comment,
      });
      return null;
    } catch (err) {
      logError(err);
      return err instanceof Error ? err.message : String(err);
    }
  };

  /**
   * Publishes one of the two account-deletion mails.
   *
   * Never throws. Both callers have already done the irreversible part — the status
   * is changed in Viewpoint — so a broker being down is a thing to record and chase,
   * not a reason to report the retirement as failed.
   *
   * An unset template id is `skipped`, not `failed`: it means this environment has
   * not been given the template yet, which is a configuration state rather than a
   * delivery error, and mixing the two would hide real failures in the noise.
   */
  const publishDeletionMail = async (params: {
    recordID: string;
    kind: "notice" | "followup";
    templateID: string;
    recipients: string[];
    templateData: Record<string, unknown>;
  }) => {
    const { recordID, kind, templateID, recipients, templateData } = params;

    if (!templateID) {
      await repo.MarkEdmOutcome({
        recordID,
        kind,
        status: "skipped",
        error: "No SendGrid template id configured for this mail",
      });
      return;
    }
    if (recipients.length === 0) {
      await repo.MarkEdmOutcome({
        recordID,
        kind,
        status: "skipped",
        error: "The account has no email address on file",
      });
      return;
    }

    try {
      await getMailQueue().publish(templateID, {
        to: recipients.map((email) => ({ email })),
        templateData,
      });
      await repo.MarkEdmOutcome({ recordID, kind, status: "sent", recipients });
    } catch (err) {
      logError(err);
      await repo.MarkEdmOutcome({
        recordID,
        kind,
        status: "failed",
        error: err instanceof Error ? err.message : String(err),
        recipients,
      });
    }
  };

  /**
   * Moves the account to the chosen status, in Viewpoint and then locally.
   *
   * The status must carry an `ext_account_status_id`; without it there is no
   * Viewpoint id to send, and writing only the local mirror would silently create
   * the divergence this whole ordering exists to prevent.
   */
  const RetireAccount = async (params: {
    accountNumber: string;
    statusID: string;
    killLogin: boolean;
    revokeSessions: boolean;
    softDelete: boolean;
    reason: string;
    performedBy: string | null;
    performedByID: string | null;
    /*
     * The EDMs for this deletion, or null to take the region's pair.
     *
     * Almost always null: the region decides the letters, and overriding one is for
     * the case a specific deletion calls for something else. Unlike the region
     * itself, an override is resolved to a concrete id here — someone who deliberately
     * named a template meant that template, not "whatever this slot holds in a
     * month".
     */
    noticeTemplateID: string | null;
    followupTemplateID: string | null;
    /** Detected from the charge centre or country, overridable by the operator. */
    region: DeletionRegion;
    /** The follow-up window in days, from the console setting. */
    followupDays: number;
  }) => {
    const { accountNumber, statusID } = params;

    const status = await repo.FindAccountStatusByID(statusID);
    if (!status) {
      throw new Error("Unknown account status");
    }
    if (!status.ext_account_status_id) {
      throw new Error(
        `Status "${status.name}" has no Viewpoint id mapped, so it cannot be applied`,
      );
    }

    const extStatusID = Number(status.ext_account_status_id);
    if (!Number.isFinite(extStatusID)) {
      throw new Error(
        `Status "${status.name}" maps to a non-numeric Viewpoint id`,
      );
    }

    const regionTemplates = DELETION_TEMPLATES[params.region];
    const noticeTemplateID =
      params.noticeTemplateID || regionTemplates.acknowledgement.id;
    const followupTemplateID =
      params.followupTemplateID || regionTemplates.confirmation.id;

    const before = await SearchByAccountNumber(accountNumber);
    if (!before.viewpoint.found) {
      throw new Error(
        `Viewpoint has no account ${accountNumber}, or was unreachable`,
      );
    }

    /*
     * The account's current retirement, if it already has one.
     *
     * Decides whether this is a new closure or a correction to the existing one —
     * see the two branches at the end. Read before Viewpoint is touched so a refusal
     * costs nothing.
     */
    const activeRecord = await repo.FindActiveRecordForAccount(accountNumber);

    /*
     * A status that is already set is refused outright.
     *
     * Applying Expired/Cancelled-Subito to an account that is already
     * Expired/Cancelled-Subito changes nothing, and the record of it would be a row
     * reading "X → X" — which is not history, it is noise that makes the real
     * closure harder to find. Refusing is also the honest answer: the operator
     * intended a change and none was available.
     */
    if (before.viewpoint.accountStatusID === extStatusID) {
      throw new Error(
        `This account is already ${status.name}. Nothing to change.`,
      );
    }

    // Viewpoint first. A throw here means nothing local has changed yet.
    const viewpointResult = await viewpoint().updateAccountStatus({
      AccountID: accountNumber,
      AccountStatusID: extStatusID,
    });

    const memberIDs = before.local.members.map((member) => member.memberID);
    const userIDs = [
      ...new Set(before.local.members.map((member) => member.userID)),
    ];

    let localResult = { membersUpdated: 0, usersUpdated: 0, sessionsRevoked: 0 };
    let localError: string | null = null;
    try {
      localResult = await repo.ApplyAccountRetirement({
        memberIDs,
        userIDs,
        statusID,
        killLogin: params.killLogin,
        revokeSessions: params.revokeSessions,
        softDelete: params.softDelete,
      });
    } catch (err) {
      /*
       * Reported, not rethrown. Viewpoint has already been changed, and answering
       * with a plain 500 would tell the operator the action failed when half of it
       * succeeded — they would retry and hit an account that is already retired.
       */
      logError(err);
      localError = err instanceof Error ? err.message : String(err);
    }

    const after = await SearchByAccountNumber(accountNumber);
    const recipients = resolveRecipients(before);

    /*
     * The Viewpoint log note, written before the record so its outcome can go on it.
     *
     * Ordered after the status change on purpose: the note describes something that
     * has happened, and writing it first would leave a note about a deletion that
     * then failed.
     */
    const viewpointLogError = await writeViewpointLog({
      accountNumber,
      subject: `Account retired via admin console — ${status.name}`,
      comment: [
        `Status changed from ${viewpointResult.previousStatus ?? "unknown"} to ${status.name}.`,
        `Reason: ${params.reason}`,
        `Performed by: ${params.performedBy ?? "unknown"}`,
        `Subito login disabled: ${params.killLogin ? "yes" : "no"}.`,
        `Sessions revoked: ${localResult.sessionsRevoked}.`,
        `Data-deletion confirmation email due in ${params.followupDays} days.`,
      ].join(" "),
    });

    /*
     * A correction to an existing closure updates it; a new closure opens a record.
     *
     * The distinction is what stops one account accumulating a row per attempt. An
     * account that is already retired and is being moved from Invalid to
     * Expired/Cancelled-Subito was closed once — the status is being corrected, not
     * closed again — so the round it already has is updated in place and no second
     * acknowledgement is queued. The member has already been told the account is
     * closed, and telling them twice for an internal correction is worse than not
     * telling them at all.
     *
     * A new record is opened only when the account has none outstanding: the first
     * closure, or a closure after a reactivation.
     */
    if (activeRecord) {
      const updated = await repo.UpdateDeletionRecordStatus({
        recordID: String(activeRecord.id),
        statusID,
        statusName: status.name,
        extAccountStatusID: status.ext_account_status_id,
        previousStatus: viewpointResult.previousStatus,
        previousStatusID: viewpointResult.previousStatusID,
        reason: params.reason,
        region: params.region,
        membersUpdated: localResult.membersUpdated,
        usersUpdated: localResult.usersUpdated,
        sessionsRevoked: localResult.sessionsRevoked,
        localError,
        performedBy: params.performedBy,
        followupTemplateID,
        viewpointLogError,
      });

      return {
        recordID: String(updated.id),
        accountNumber,
        /** True when this corrected an existing closure rather than opening one. */
        updatedExisting: true,
        /*
         * No acknowledgement was queued, and the caller is told so.
         *
         * Silence here would look identical to a send, and the operator picked a
         * template expecting one to go out.
         */
        noticeSent: false,
        region: params.region,
        followupDays: activeRecord.followup_days ?? params.followupDays,
        viewpointLogError,
        templates: { notice: noticeTemplateID, followup: followupTemplateID },
        appliedStatus: {
          id: String(status.id),
          name: status.name,
          extID: extStatusID,
        },
        viewpoint: {
          changed: viewpointResult.changed,
          previousStatus: viewpointResult.previousStatus,
          previousStatusID: viewpointResult.previousStatusID,
          currentStatus: after.viewpoint.accountStatus,
        },
        local: localResult,
        localError,
        reason: params.reason,
        before,
        after,
      };
    }

    /*
     * The record is written whatever happened to the local mirror.
     *
     * Its existence means Viewpoint accepted the change — the only thing that had to
     * succeed for the account to really be retired. `local_error` carries the rest,
     * so an account left half-applied is findable later instead of living only in
     * whatever the operator saw on screen.
     */
    const record = await repo.InsertDeletionRecord({
      accountNumber,
      accountName: before.viewpoint.accountName,
      statusID,
      statusName: status.name,
      extAccountStatusID: status.ext_account_status_id,
      previousStatus: viewpointResult.previousStatus,
      previousStatusID: viewpointResult.previousStatusID,
      reason: params.reason,
      killLogin: params.killLogin,
      revokeSessions: params.revokeSessions,
      softDelete: params.softDelete,
      membersUpdated: localResult.membersUpdated,
      usersUpdated: localResult.usersUpdated,
      sessionsRevoked: localResult.sessionsRevoked,
      localError,
      performedBy: params.performedBy,
      performedByID: params.performedByID,
      recipients,
      /*
       * Both ids are resolved and stored, region default included.
       *
       * Not left null to be resolved later: the region's letters are what the member
       * was told they would get, and a record that only says "the India pair" would
       * change meaning if that pair were ever repointed. The follow-up 30 days from
       * now must send the letter this deletion promised.
       */
      noticeTemplateID: noticeTemplateID,
      followupTemplateID: followupTemplateID,
      region: params.region,
      viewpointLogError,
      followupDays: params.followupDays,
    });

    // Awaited rather than fired and forgotten, so the response can say whether the
    // member was told. Publishing is a queue write, not a delivery, so it is quick.
    await publishDeletionMail({
      recordID: String(record.id),
      kind: "notice",
      templateID: noticeTemplateID,
      recipients,
      templateData: {
        account_number: accountNumber,
        account_name: before.viewpoint.accountName ?? "",
        previous_status: viewpointResult.previousStatus ?? "",
        new_status: status.name,
        reason: params.reason,
      },
    });

    return {
      recordID: String(record.id),
      accountNumber,
      updatedExisting: false,
      noticeSent: true,
      region: params.region,
      followupDays: params.followupDays,
      /** Non-null means the Viewpoint log note did not land. */
      viewpointLogError,
      templates: {
        notice: noticeTemplateID,
        followup: followupTemplateID,
      },
      appliedStatus: { id: String(status.id), name: status.name, extID: extStatusID },
      viewpoint: {
        changed: viewpointResult.changed,
        previousStatus: viewpointResult.previousStatus,
        previousStatusID: viewpointResult.previousStatusID,
        currentStatus: after.viewpoint.accountStatus,
      },
      local: localResult,
      /** Non-null means Viewpoint moved and the member database did not. */
      localError,
      reason: params.reason,
      before,
      after,
    };
  };



  /**
   * Sends one of a record's two emails now, rather than waiting.
   *
   * For the acknowledgement this is a resend — the usual reason being that the first
   * attempt failed, or the member says it never arrived. For the confirmation it
   * brings the 30-day mail forward, which is a decision an operator sometimes has to
   * make when a member is chasing their erasure confirmation.
   *
   * Uses the template and recipients the record already holds, never fresh ones: the
   * point is to deliver what this deletion promised, and re-deriving either could
   * send a different letter to a different address than the one on file.
   *
   * Marks the outcome, so a brought-forward confirmation leaves 'sent' and the
   * nightly sweep does not send it a second time.
   */
  const SendRecordMailNow = async (params: {
    recordID: string;
    kind: "notice" | "followup";
  }) => {
    const record = await repo.FindDeletionRecordByID(params.recordID);
    if (!record) {
      throw new Error("No such deletion record");
    }
    if (record.reactivated_at) {
      throw new Error(
        "This account has been reactivated, so its deletion emails no longer apply",
      );
    }

    const templateID =
      params.kind === "notice"
        ? record.notice_template_id
        : record.followup_template_id;
    if (!templateID) {
      throw new Error(
        "This record does not name a template for that email, so there is nothing to send",
      );
    }

    const recipients = (
      Array.isArray(record.recipients)
        ? record.recipients
        : JSON.parse(String(record.recipients ?? "[]"))
    ) as string[];
    if (recipients.length === 0) {
      throw new Error("No recipients were recorded for this account");
    }

    await publishDeletionMail({
      recordID: params.recordID,
      kind: params.kind,
      templateID,
      recipients,
      templateData: {
        account_number: record.account_number,
        account_name: record.account_name ?? "",
        previous_status: record.previous_status ?? "",
        new_status: record.status_name,
        status: record.status_name,
        reason: record.reason,
        deleted_on: new Date(record.created_at).toISOString().slice(0, 10),
      },
    });

    return {
      recordID: params.recordID,
      kind: params.kind,
      templateID,
      recipients,
    };
  };

  /**
   * Sends one of the deletion letters to an arbitrary address, to prove the path.
   *
   * Published onto the same SMTP queue the real sends use, with the same template.
   * That is the whole value of it — a test that went out through a different
   * mechanism would prove that mechanism works and say nothing about this one.
   *
   * Recorded nowhere. It is not something that happened to a member, and putting it
   * on a deletion record would make the audit trail claim a letter was sent to
   * someone who was never involved.
   *
   * Throws on failure rather than swallowing it: unlike a real send, the caller is
   * standing there waiting to be told whether it worked.
   */
  const SendTestMail = async (params: {
    templateID: string;
    to: string[];
    accountNumber?: string;
    accountName?: string | null;
    statusName?: string | null;
  }) => {
    const templateData = {
      account_number: params.accountNumber ?? "TEST-0000",
      account_name: params.accountName || "Test Member",
      previous_status: "Active",
      new_status: params.statusName || "Expired/Cancelled-Subito",
      status: params.statusName || "Expired/Cancelled-Subito",
      reason: "Test send from the admin console",
      deleted_on: new Date().toISOString().slice(0, 10),
    };

    /*
     * One message per address, not one message to several.
     *
     * A single send with three entries in `to` puts all three in the header, so
     * every tester sees the others' addresses. Separate messages also mean one bad
     * address fails on its own instead of taking the batch with it.
     */
    const queue = getMailQueue();
    const sent: string[] = [];
    const failed: Array<{ to: string; message: string }> = [];

    for (const address of params.to) {
      try {
        await queue.publish(params.templateID, {
          to: [{ email: address }],
          templateData,
        });
        sent.push(address);
      } catch (err) {
        failed.push({
          to: address,
          message: err instanceof Error ? err.message : String(err),
        });
      }
    }

    if (sent.length === 0) {
      throw new Error(
        failed[0]?.message ?? "The broker accepted none of the addresses",
      );
    }

    return { templateID: params.templateID, sent, failed };
  };

  /**
   * Puts a retired account back.
   *
   * Same ordering as the retirement, for the same reason: Viewpoint is the system of
   * record and is written first, and the local mirror follows only if it accepted.
   *
   * Cancels the pending 30-day mail as part of the same step. An account that is
   * live again must not receive a follow-up telling the member it was closed, and
   * leaving that to a separate call is leaving it to be forgotten.
   *
   * Sessions are not restored — they were deleted, and the member signs in again.
   */
  const ReactivateAccount = async (params: {
    recordID: string;
    performedBy: string | null;
  }) => {
    const record = await repo.FindDeletionRecordByID(params.recordID);
    if (!record) {
      throw new Error("No such deletion record");
    }
    if (record.reactivated_at) {
      throw new Error("This account has already been reactivated");
    }

    /*
     * The account goes back to whatever it held before, and nothing else.
     *
     * Not a fixed "Active": an account that was Suspended when someone retired it
     * should come back Suspended, and restoring it to Active would quietly grant it
     * more than it had. And when the old status was never captured — Viewpoint was
     * unreachable at deletion time — this refuses rather than guessing. Guessing
     * here means writing a status to the system of record on no evidence.
     */
    if (!record.previous_status_id) {
      throw new Error(
        "This record does not say what status the account held before it was retired, so it cannot be restored automatically. Set the status in Viewpoint directly.",
      );
    }
    const extStatusID = String(record.previous_status_id);

    const restored = await repo.FindAccountStatusByExtID(extStatusID);
    if (!restored) {
      throw new Error(
        `No local status maps to Viewpoint status ${extStatusID}, so the account cannot be restored`,
      );
    }

    const before = await SearchByAccountNumber(record.account_number);
    if (!before.viewpoint.found) {
      throw new Error(
        `Viewpoint has no account ${record.account_number}, or was unreachable`,
      );
    }

    // Viewpoint first. A throw here means nothing local has changed.
    await viewpoint().updateAccountStatus({
      AccountID: record.account_number,
      AccountStatusID: Number(extStatusID),
    });

    const localResult = await repo.ApplyAccountReactivation({
      memberIDs: before.local.members.map((member) => member.memberID),
      userIDs: [...new Set(before.local.members.map((member) => member.userID))],
      statusID: String(restored.id),
    });

    const viewpointLogError = await writeViewpointLog({
      accountNumber: record.account_number,
      subject: `Account reactivated via admin console — ${restored.name}`,
      comment: [
        `Status restored to ${restored.name}.`,
        `Reactivated by: ${params.performedBy ?? "unknown"}.`,
        `Originally retired on ${new Date(record.created_at).toISOString().slice(0, 10)}: ${record.reason}`,
        record.followup_edm_status === "pending"
          ? "The pending data-deletion confirmation email has been cancelled."
          : "",
      ]
        .filter(Boolean)
        .join(" "),
    });

    await repo.MarkReactivated({
      recordID: params.recordID,
      reactivatedBy: params.performedBy,
      restoredStatusName: restored.name,
    });

    const after = await SearchByAccountNumber(record.account_number);

    return {
      recordID: params.recordID,
      accountNumber: record.account_number,
      restoredStatus: {
        id: String(restored.id),
        name: restored.name,
        extID: extStatusID,
      },
      followupCancelled: record.followup_edm_status === "pending",
      /** Non-null means the Viewpoint log note did not land. */
      viewpointLogError,
      local: localResult,
      after,
    };
  };

  return {
    SearchByAccountNumber,
    SearchByEmail,
    ListTargetStatuses,
    GetFollowupDays,
    SaveFollowupDays,
    ListRecords,
    ListRetiredAccounts,
    RetireAccount,
    ReactivateAccount,
    SendRecordMailNow,
    SendTestMail,
  };
};

export type TAccountDeletionServices = ReturnType<typeof AccountDeletionServices>;
