import { AccountDeletionRepository } from "@/internal/account-deletion/account-deletion.repository";
import { AccountDeletionServices } from "@/internal/account-deletion/account-deletion.service";
import type { RepositoryContext } from "@/internal/datastore/repository";
import { DELETION_STATUS_EXT_IDS } from "@/internal/account-deletion/account-deletion.repository";
import {
  DELETION_REGIONS,
  isDeletionRegion,
  type DeletionRegion,
} from "@/internal/account-deletion/deletion-templates";
import { logError } from "@/lib/logger";
import { error as errorResponse, success } from "@/lib/response";
import type { Context } from "hono";
import { z } from "zod";

/**
 * Account deletion — super admin only, every handler.
 *
 * The role middleware on the route governs which module the caller may reach; this
 * check governs the action itself. Both are needed: "delete" on the promo module is
 * a privilege several people hold, and retiring a member account in the system of
 * record is not something it should imply.
 */
const isSuperAdmin = (c: Context): boolean =>
  c.get("isAdminConsoleSuperAdmin") === true;

/*
 * 404 rather than 403, matching the Viewpoint controller: the console's coreClient
 * treats every 403 as a dead session and logs the user out, so an honest "you may
 * not do this" would throw them to the sign-in page.
 */
const forbidden = (c: Context) => errorResponse(c, "Not found", 404);

const servicesFor = (c: Context) =>
  AccountDeletionServices({
    AccountDeletionRepository: AccountDeletionRepository(c as RepositoryContext),
  });

const SearchSchema = z
  .object({
    accountNumber: z.string().trim().min(1).optional(),
    email: z.string().trim().email().optional(),
  })
  .refine((value) => Boolean(value.accountNumber) !== Boolean(value.email), {
    message: "Provide exactly one of accountNumber or email",
  });

const ListSchema = z.object({
  page: z.number().int().min(1).default(1),
  // Clamped: the history is unbounded and an unbounded page size turns one
  // request into a full-table read.
  pageSize: z.number().int().min(1).max(100).default(25),
  search: z.string().trim().optional(),
});

const ConfigSchema = z.object({
  /*
   * Bounded here as well as in the service.
   *
   * 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.
   */
  followupDays: z.number().int().min(1).max(365),
});

const SendNowSchema = z.object({
  recordID: z.string().trim().uuid(),
  kind: z.enum(["notice", "followup"]),
});

const TestSendSchema = z.object({
  templateID: z.string().trim().min(1),
  /*
   * Several addresses at a time, capped.
   *
   * A test usually goes to the operator plus whoever asked for it, and making that
   * three separate submissions is three chances to pick the wrong template. Ten is
   * well past any real need and keeps a mistyped paste from becoming a send storm.
   */
  to: z.array(z.string().trim().email()).min(1).max(10),
  /** Substituted into the sample data, so the test letter reads like a real one. */
  accountNumber: z.string().trim().optional(),
  accountName: z.string().trim().optional(),
  statusName: z.string().trim().optional(),
});

const ReactivateSchema = z.object({
  recordID: z.string().trim().uuid(),
});

const ApplySchema = z.object({
  accountNumber: z.string().trim().min(1),
  statusID: z.string().trim().min(1),
  reason: z.string().trim().min(10, "Give a reason of at least 10 characters"),
  killLogin: z.boolean().default(true),
  revokeSessions: z.boolean().default(true),
  softDelete: z.boolean().default(false),
  /*
   * The two EDMs, or nothing for the region's own pair.
   *
   * Empty string and absent both mean "use the region's" — a Select that has been
   * cleared sends "", and treating that as a template id would send nothing at all.
   */
  noticeTemplateID: z.string().trim().optional(),
  followupTemplateID: z.string().trim().optional(),
  /*
   * Which region's letters to send.
   *
   * Required, and not defaulted here. The console sends back the region it showed
   * the operator; defaulting a missing one to "others" server-side would send the
   * least specific letter to a member whose region was simply lost in transit, and
   * that is a compliance question rather than a cosmetic one.
   */
  region: z.string().trim().refine(isDeletionRegion, {
    message: "Unknown region",
  }),
});

export const searchAccountsHandler = async (c: Context) => {
  if (!isSuperAdmin(c)) return forbidden(c);
  try {
    const parsed = SearchSchema.safeParse(await c.req.json());
    if (!parsed.success) {
      return errorResponse(
        c,
        parsed.error.issues[0]?.message ?? "Invalid search",
        400,
      );
    }

    const services = servicesFor(c);
    const results = parsed.data.accountNumber
      ? [await services.SearchByAccountNumber(parsed.data.accountNumber)]
      : await services.SearchByEmail(parsed.data.email!);

    return success(
      c,
      { items: results, total: results.length },
      "Accounts retrieved successfully",
    );
  } catch (err) {
    logError(err);
    return errorResponse(c, "Failed to search accounts", 500, err);
  }
};

export const listDeletionStatusesHandler = async (c: Context) => {
  if (!isSuperAdmin(c)) return forbidden(c);
  try {
    const statuses = await servicesFor(c).ListTargetStatuses();

    /*
     * Which of the two expected statuses the member database did not have.
     *
     * Reported rather than left to a short dropdown. A list with one entry looks
     * like a working list, so without this an operator sees "Invalid" alone and has
     * no reason to suspect the other one is missing rather than deliberately absent.
     */
    const found = new Set(
      statuses.map((status) => String(status.ext_account_status_id ?? "").trim()),
    );
    const missing = DELETION_STATUS_EXT_IDS.filter((id) => !found.has(id));

    return success(
      c,
      {
        missingExtStatusIDs: missing,
        items: statuses.map((status) => ({
          id: String(status.id),
          name: status.name,
          extAccountStatusID: status.ext_account_status_id,
          isActive: status.is_active,
          /*
           * A status with no Viewpoint id cannot be applied — the write to the
           * system of record would have nothing to send. Flagged rather than
           * filtered out so the console can say why it is unselectable instead of
           * silently omitting a status the operator expected to see.
           */
          applicable: Boolean(status.ext_account_status_id),
        })),
      },
      "Account statuses retrieved successfully",
    );
  } catch (err) {
    logError(err);
    return errorResponse(c, "Failed to fetch account statuses", 500, err);
  }
};

/**
 * Retires one account.
 *
 * The response carries the full before and after state, which the activity-log
 * middleware stores as the log's response payload — that record, keyed to the
 * account number through `entityId`, is the audit trail for the action.
 */
export const applyAccountDeletionHandler = async (c: Context) => {
  if (!isSuperAdmin(c)) return forbidden(c);
  try {
    const parsed = ApplySchema.safeParse(await c.req.json());
    if (!parsed.success) {
      return errorResponse(
        c,
        parsed.error.issues[0]?.message ?? "Invalid request",
        400,
      );
    }

    const input = parsed.data;

    // Makes the log row findable by account number on the activity-logs page.
    c.set("entityId", input.accountNumber);

    const services = servicesFor(c);
    const result = await services.RetireAccount({
      accountNumber: input.accountNumber,
      statusID: input.statusID,
      killLogin: input.killLogin,
      revokeSessions: input.revokeSessions,
      softDelete: input.softDelete,
      reason: input.reason,
      performedBy: (c.get("adminEmail") as string | undefined) ?? null,
      performedByID: (c.get("consoleUserId") as string | undefined) ?? null,
      noticeTemplateID: input.noticeTemplateID || null,
      followupTemplateID: input.followupTemplateID || null,
      region: input.region as DeletionRegion,
      followupDays: await services.GetFollowupDays(),
    });

    if (result.localError) {
      /*
       * 207: Viewpoint moved, the local mirror did not. Not a 500 — reporting a
       * plain failure would invite a retry against an account that is already
       * retired in the system of record.
       */
      return c.json(
        {
          success: false,
          message:
            "Viewpoint was updated but the member database was not. Re-run the sync for this account.",
          data: result,
        },
        207,
      );
    }

    return success(c, result, "Account deleted successfully");
  } catch (err) {
    logError(err);
    return errorResponse(
      c,
      err instanceof Error ? err.message : "Failed to delete the account",
      500,
      err,
    );
  }
};

/**
 * The history of everything retired through this page.
 *
 * Read from the console's own `account_deletion_records`, not from
 * `admin_activity_log`: the mail outcomes are written after the request that
 * created the row — the follow-up thirty days later — and a request log cannot
 * carry a result that did not exist when the request finished.
 */
export const listDeletionRecordsHandler = async (c: Context) => {
  if (!isSuperAdmin(c)) return forbidden(c);
  try {
    const parsed = ListSchema.safeParse(await c.req.json());
    if (!parsed.success) {
      return errorResponse(
        c,
        parsed.error.issues[0]?.message ?? "Invalid request",
        400,
      );
    }

    const { page, pageSize, search } = parsed.data;
    const { rows, total } = await servicesFor(c).ListRecords({
      page,
      pageSize,
      search: search || undefined,
    });

    return success(
      c,
      {
        items: rows.map((row) => ({
          id: String(row.id),
          accountNumber: row.account_number,
          accountName: row.account_name,
          statusName: row.status_name,
          previousStatus: row.previous_status,
          reason: row.reason,
          killLogin: row.kill_login,
          revokeSessions: row.revoke_sessions,
          softDelete: row.soft_delete,
          membersUpdated: row.members_updated,
          usersUpdated: row.users_updated,
          sessionsRevoked: row.sessions_revoked,
          localError: row.local_error,
          performedBy: row.performed_by,
          recipients: (Array.isArray(row.recipients)
            ? row.recipients
            : JSON.parse(String(row.recipients ?? "[]"))) as string[],
          noticeEdmStatus: row.notice_edm_status,
          noticeEdmAt: row.notice_edm_at,
          noticeEdmError: row.notice_edm_error,
          followupDueAt: row.followup_due_at,
          followupEdmStatus: row.followup_edm_status,
          followupEdmAt: row.followup_edm_at,
          followupEdmError: row.followup_edm_error,
          reactivatedAt: row.reactivated_at,
          reactivatedBy: row.reactivated_by,
          reactivatedToStatus: row.reactivated_to_status,
          region: row.region,
          followupDays: row.followup_days,
          viewpointLogStatus: row.viewpoint_log_status,
          viewpointLogError: row.viewpoint_log_error,
          noticeTemplateID: row.notice_template_id,
          followupTemplateID: row.followup_template_id,
          /*
           * Whether the Reactivate button is offered at all.
           *
           * A record with no prior status cannot be restored automatically — there
           * is nothing to restore it to — so the console must not offer an action
           * core will refuse. Decided here rather than in the UI so the two cannot
           * drift.
           */
          canReactivate:
            !row.reactivated_at && row.previous_status_id !== null,
          createdAt: row.created_at,
          updatedAt: row.updated_at,
        })),
        pagination: { page, pageSize, total },
      },
      "Account deletion records retrieved successfully",
    );
  } catch (err) {
    logError(err);
    return errorResponse(c, "Failed to list account deletion records", 500, err);
  }
};

/**
 * Puts a retired account back.
 *
 * Reverses the status in Viewpoint, restores the local record, and stands the
 * pending 30-day mail down in the same step — an account that is live again must
 * not receive a follow-up telling the member it was closed.
 */
export const reactivateAccountHandler = async (c: Context) => {
  if (!isSuperAdmin(c)) return forbidden(c);
  try {
    const parsed = ReactivateSchema.safeParse(await c.req.json());
    if (!parsed.success) {
      return errorResponse(
        c,
        parsed.error.issues[0]?.message ?? "Invalid request",
        400,
      );
    }

    const result = await servicesFor(c).ReactivateAccount({
      recordID: parsed.data.recordID,
      performedBy: (c.get("adminEmail") as string | undefined) ?? null,
    });

    c.set("entityId", result.accountNumber);

    return success(c, result, "Account reactivated successfully");
  } catch (err) {
    logError(err);
    return errorResponse(
      c,
      err instanceof Error ? err.message : "Failed to reactivate the account",
      500,
      err,
    );
  }
};

/**
 * The region catalogue: which two letters each region sends.
 *
 * Sent to the console so the picker can name the templates the chosen region will
 * use, rather than making the operator take it on trust that "India" resolves to the
 * India acknowledgement. The ids are the same constants the send uses, so the dialog
 * cannot describe one template and send another.
 */
export const getDeletionConfigHandler = async (c: Context) => {
  if (!isSuperAdmin(c)) return forbidden(c);
  return success(
    c,
    {
      regions: DELETION_REGIONS.map((entry) => ({
        region: entry.region,
        label: entry.label,
        acknowledgement: entry.acknowledgement,
        confirmation: entry.confirmation,
      })),
      // Read, not assumed: the dialog states this back to the operator, and a
      // hardcoded number here would go on saying 30 after the setting moved.
      followupDays: await servicesFor(c).GetFollowupDays(),
    },
    "Account deletion configuration retrieved successfully",
  );
};

/**
 * Sends one of a record's two emails now.
 *
 * A resend for the acknowledgement, or the confirmation brought forward. Uses the
 * template and addresses the record already holds — the point is to deliver what
 * this deletion promised, not to compose a new letter.
 */
export const sendRecordMailNowHandler = async (c: Context) => {
  if (!isSuperAdmin(c)) return forbidden(c);
  try {
    const parsed = SendNowSchema.safeParse(await c.req.json());
    if (!parsed.success) {
      return errorResponse(
        c,
        parsed.error.issues[0]?.message ?? "Invalid request",
        400,
      );
    }

    const result = await servicesFor(c).SendRecordMailNow(parsed.data);
    c.set("entityId", result.recordID);
    return success(c, result, "Email queued");
  } catch (err) {
    logError(err);
    return errorResponse(
      c,
      err instanceof Error ? err.message : "Failed to queue the email",
      500,
      err,
    );
  }
};

/**
 * Sends a deletion letter to an arbitrary address, to prove the path works.
 *
 * Goes onto the same SMTP queue as a real send, with the same template — a test
 * through any other route would prove the wrong thing. Nothing is recorded against
 * any account: no member was involved, and an audit trail saying otherwise would be
 * a lie.
 */
export const sendTestMailHandler = async (c: Context) => {
  if (!isSuperAdmin(c)) return forbidden(c);
  try {
    const parsed = TestSendSchema.safeParse(await c.req.json());
    if (!parsed.success) {
      return errorResponse(
        c,
        parsed.error.issues[0]?.message ?? "Invalid request",
        400,
      );
    }

    const result = await servicesFor(c).SendTestMail(parsed.data);
    return success(
      c,
      result,
      /*
       * Worded as "the broker has it", not "sent".
       *
       * The publish is confirmed, so this much is true. Whether it lands in an inbox
       * is the SMTP worker's and SendGrid's, and claiming delivery here would send
       * someone hunting in the wrong place when nothing arrives.
       */
      `The broker accepted it for ${result.sent.join(", ")}${
        result.failed.length
          ? `; ${result.failed.length} address(es) failed`
          : ""
      }. Delivery is the SMTP worker's and SendGrid's from here.`,
    );
  } catch (err) {
    logError(err);
    return errorResponse(
      c,
      err instanceof Error ? err.message : "Failed to queue the test email",
      500,
      err,
    );
  }
};

/**
 * The follow-up window, saved from the console.
 *
 * A thin wrapper over the same `console_settings` row the generic settings endpoint
 * writes, kept here so the deletion UI does not have to know the setting's key or
 * its value shape. Super admin only, like everything else on this controller.
 */
export const putDeletionConfigHandler = async (c: Context) => {
  if (!isSuperAdmin(c)) return forbidden(c);
  try {
    const parsed = ConfigSchema.safeParse(await c.req.json());
    if (!parsed.success) {
      return errorResponse(
        c,
        parsed.error.issues[0]?.message ?? "Invalid request",
        400,
      );
    }

    const saved = await servicesFor(c).SaveFollowupDays(
      parsed.data.followupDays,
      (c.get("adminEmail") as string | undefined) ?? null,
    );
    return success(
      c,
      { followupDays: saved },
      "Follow-up window saved. It applies to deletions from now on — mail already promised keeps its original date.",
    );
  } catch (err) {
    logError(err);
    return errorResponse(
      c,
      err instanceof Error ? err.message : "Failed to save the setting",
      500,
      err,
    );
  }
};

/**
 * The retired-account list.
 *
 * One row per account, drawn from the member database rather than from the console's
 * own records — an account retired directly in Viewpoint is retired, and hiding it
 * because this console did not do it is how a closure goes unnoticed for a month.
 *
 * Where a console record exists it is attached, which is what supplies the reason,
 * the operator and the two emails. Where none does, the row says so.
 */
export const listRetiredAccountsHandler = async (c: Context) => {
  if (!isSuperAdmin(c)) return forbidden(c);
  try {
    const parsed = ListSchema.safeParse(await c.req.json());
    if (!parsed.success) {
      return errorResponse(
        c,
        parsed.error.issues[0]?.message ?? "Invalid request",
        400,
      );
    }

    const { page, pageSize, search } = parsed.data;
    const { items, total } = await servicesFor(c).ListRetiredAccounts({
      page,
      pageSize,
      search: search || undefined,
    });

    return success(
      c,
      {
        items: items.map((item) => ({
          accountNumber: item.accountNumber,
          name: item.name,
          statusName: item.statusName,
          memberCount: item.memberCount,
          statusChangedAt: item.statusChangedAt,
          source: item.source,
          record: item.record
            ? {
                id: String(item.record.id),
                reason: item.record.reason,
                performedBy: item.record.performed_by,
                region: item.record.region,
                createdAt: item.record.created_at,
                noticeEdmStatus: item.record.notice_edm_status,
                followupEdmStatus: item.record.followup_edm_status,
                followupDueAt: item.record.followup_due_at,
                followupDays: item.record.followup_days,
                reactivatedAt: item.record.reactivated_at,
                canReactivate:
                  !item.record.reactivated_at &&
                  item.record.previous_status_id !== null,
              }
            : null,
        })),
        pagination: { page, pageSize, total },
      },
      "Retired accounts retrieved successfully",
    );
  } catch (err) {
    logError(err);
    return errorResponse(c, "Failed to list retired accounts", 500, err);
  }
};
