import { z } from "zod";
import { ApplicationError, type ApplicationRecord } from "./application-record";
import { retentionRecordSchema } from "./application-retention";
import {
  STAFF_NOTE_LIMIT,
  staffArchiveSchema,
  staffAssignmentSchema,
  staffNoteSchema,
  staffRetentionSchema,
  staffStatusSchema,
  staffView,
} from "./staff-model";
import type { ApplicationRepository } from "./application-repository";
import {
  documentMetadataSchema,
  staffDocumentKinds,
  staffDocumentLabels,
  type StaffDocumentKind,
} from "./application-schema";
import type { Assessment } from "./application-assessment";
import { RETENTION_POLICY_VERSION } from "./application-retention";
import {
  emptyStaffSection,
  staffCreateFailures,
  staffCreateSchema,
  staffDeclarationRecord,
} from "./staff-create";
import {
  lockedStaffDetails,
  parseStaffDetails,
  staffDetailFailures,
  staffDetailLabel,
  staffDetailSections,
  staffDetailValues,
  staffDetailsSchema,
  type StaffDetailSection,
} from "./staff-details";
const RECORD_BYTE_BUDGET = 900_000;
export function savedView(record: ApplicationRecord) {
  if (
    new TextEncoder().encode(JSON.stringify(record)).length > RECORD_BYTE_BUDGET
  )
    throw new ApplicationError(
      409,
      "This application record is too large to change. Contact the administrator.",
    );
  return { record, result: staffView(record) };
}
function editable(record: ApplicationRecord | null, revision: number) {
  if (!record) throw new ApplicationError(404, "Application not found.");
  if (record.deleting)
    throw new ApplicationError(
      409,
      "Deletion has begun; this record is locked.",
    );
  if (record.revision !== revision)
    throw new ApplicationError(
      409,
      "Another change was saved. Reload the record before editing.",
    );
  if (record.history.length >= 498)
    throw new ApplicationError(
      409,
      "The audit history limit has been reached. Contact the administrator.",
    );
  return record;
}
export async function changeStaffStatus(
  id: string,
  input: unknown,
  actor: string,
  repo: ApplicationRepository,
) {
  const parsed = staffStatusSchema.safeParse(input);
  if (!parsed.success)
    throw new ApplicationError(400, "Choose a valid status.");
  return repo.change(id, (current) => {
    const record = editable(current, parsed.data.revision),
      at = new Date().toISOString();
    if (record.archivedAt)
      throw new ApplicationError(
        409,
        "Restore this application before changing its status.",
      );
    if (!record.submittedAt)
      throw new ApplicationError(
        409,
        "This application has not been submitted. Its retention can still be reviewed.",
      );
    if (record.status !== parsed.data.status) {
      record.history.push({
        actor,
        at,
        field: "status",
        before: record.status,
        after: parsed.data.status,
      });
      record.status = parsed.data.status;
      if (
        record.status === "approved" &&
        record.retention.lifecycle.outcome !== "approved"
      ) {
        const before = JSON.stringify(record.retention.lifecycle);
        record.retention.lifecycle = {
          outcome: "approved",
          loanEndedAt: null,
          borrowerRelationshipEndedAt: null,
        };
        record.history.push({
          actor,
          at,
          field: "retention",
          before,
          after: JSON.stringify(record.retention.lifecycle),
        });
      }
      record.revision++;
    }
    return savedView(record);
  });
}
export async function changeStaffRetention(
  id: string,
  input: unknown,
  actor: string,
  repo: ApplicationRepository,
) {
  const parsed = staffRetentionSchema.safeParse(input);
  if (!parsed.success)
    throw new ApplicationError(
      400,
      "Check the retention details, basis and hold review date.",
    );
  return repo.change(id, (current) => {
    const record = editable(current, parsed.data.revision),
      value = parsed.data;
    if (
      record.retention.lifecycle.outcome !== "approved" &&
      (value.loanEndedAt || value.borrowerRelationshipEndedAt)
    )
      throw new ApplicationError(400, "Only approved loans have end dates.");
    const retention = retentionRecordSchema.safeParse({
      ...record.retention,
      classificationReviewed: value.classificationReviewed,
      hold: value.hold,
      lifecycle:
        record.retention.lifecycle.outcome === "approved"
          ? {
              outcome: "approved",
              loanEndedAt: value.loanEndedAt,
              borrowerRelationshipEndedAt: value.borrowerRelationshipEndedAt,
            }
          : { outcome: "unapproved" },
    });
    if (
      !retention.success ||
      [value.loanEndedAt, value.borrowerRelationshipEndedAt].some(
        (date) => date && Date.parse(date) > Date.now() + 86400000,
      ) ||
      (value.hold !== "none" &&
        value.nextReviewAt &&
        Date.parse(value.nextReviewAt) <= Date.now())
    )
      throw new ApplicationError(
        400,
        "Check the end dates and future hold review date.",
      );
    const before = JSON.stringify({
      retention: record.retention,
      review: record.retentionReview,
    });
    record.retention = retention.data;
    record.retentionReview = {
      basis: value.basis,
      nextReviewAt: value.nextReviewAt,
    };
    const after = JSON.stringify({
      retention: record.retention,
      review: record.retentionReview,
    });
    if (before !== after) {
      record.history.push({
        actor,
        at: new Date().toISOString(),
        field: "retention",
        before,
        after,
      });
      record.revision++;
    }
    return savedView(record);
  });
}
export async function changeStaffArchive(
  id: string,
  input: unknown,
  actor: string,
  repo: ApplicationRepository,
) {
  const parsed = staffArchiveSchema.safeParse(input);
  if (!parsed.success)
    throw new ApplicationError(
      400,
      "Choose whether to archive or restore this application.",
    );
  return repo.change(id, (current) => {
    const record = editable(current, parsed.data.revision);
    if (Boolean(record.archivedAt) !== parsed.data.archived) {
      const at = new Date().toISOString();
      record.history.push({
        actor,
        at,
        field: "archive",
        before: record.archivedAt ? "archived" : "active",
        after: parsed.data.archived ? "archived" : "active",
      });
      record.archivedAt = parsed.data.archived ? at : null;
      record.revision++;
    }
    return savedView(record);
  });
}
export async function changeStaffDetails(
  id: string,
  input: unknown,
  actor: string,
  repo: ApplicationRepository,
) {
  const parsed = staffDetailsSchema.safeParse(input);
  if (!parsed.success)
    throw new ApplicationError(400, "Check the details and try again.");
  const { section, values } = parsed.data;
  return repo.change(id, (current) => {
    const record = editable(current, parsed.data.revision);
    if (!record.submittedAt)
      throw new ApplicationError(
        409,
        "An unfinished application cannot be edited, because the applicant may still complete it.",
      );
    if (record.archivedAt)
      throw new ApplicationError(
        409,
        "Restore this application before editing its details.",
      );
    const before = staffDetailValues(record, section);
    if (Object.keys(values).some((key) => !Object.hasOwn(before, key)))
      throw new ApplicationError(400, "Check the details and try again.");
    const draft = { ...before, ...values };
    if (
      lockedStaffDetails(record, section, before).some(
        (key) => draft[key] !== before[key],
      )
    )
      throw new ApplicationError(
        400,
        "Details verified by Myinfo cannot be edited.",
      );
    const failures = staffDetailFailures(section, draft),
      failed = Object.keys(failures)[0];
    if (failed !== undefined)
      throw new ApplicationError(
        400,
        `${staffDetailLabel(section, failed)}: ${failures[failed]}`,
      );
    const data = parseStaffDetails(section, draft);
    if (!data)
      throw new ApplicationError(400, "Check the details and try again.");
    const changed = Object.keys(before).filter(
      (key) => before[key] !== data[key],
    );
    if (!changed.length) return savedView(record);
    if (record.history.length + changed.length > 498)
      throw new ApplicationError(
        409,
        "The audit history limit has been reached. Contact the administrator.",
      );
    const at = new Date().toISOString();
    for (const key of changed)
      record.history.push({
        actor,
        at,
        field: "details",
        before: JSON.stringify({ [`${section}.${key}`]: before[key] }),
        after: JSON.stringify({ [`${section}.${key}`]: data[key] }),
      });
    if (section === "applicant")
      record.fields = data as ApplicationRecord["fields"];
    else if (section === "particulars")
      record.particulars = { ...record.particulars, ...data };
    else if (section === "loanContext")
      record.loanContext = { ...record.loanContext, ...data };
    else record.assessment = data as Assessment;
    record.revision++;
    return savedView(record);
  });
}
export type StaffActor = {
  actor: string;
  author: string;
  team: readonly string[];
};
export async function changeStaffAssignment(
  id: string,
  input: unknown,
  staff: StaffActor,
  repo: ApplicationRepository,
) {
  const parsed = staffAssignmentSchema.safeParse(input);
  if (!parsed.success)
    throw new ApplicationError(400, "Choose a staff member, or unassigned.");
  const assignee = parsed.data.assignee;
  return repo.change(id, (current) => {
    const record = editable(current, parsed.data.revision);
    if (record.assignee !== assignee) {
      if (
        assignee &&
        !staff.team.some((member) => member.toLowerCase() === assignee)
      )
        throw new ApplicationError(400, "Choose a current staff member.");
      if (record.archivedAt)
        throw new ApplicationError(
          409,
          "Restore this application before changing its assignment.",
        );
      record.history.push({
        actor: staff.actor,
        at: new Date().toISOString(),
        field: "assignment",
        before: record.assignee ?? "unassigned",
        after: assignee ?? "unassigned",
      });
      record.assignee = assignee;
      record.revision++;
    }
    return savedView(record);
  });
}
export async function addStaffNote(
  id: string,
  input: unknown,
  staff: StaffActor,
  repo: ApplicationRepository,
) {
  const parsed = staffNoteSchema.safeParse(input);
  if (!parsed.success) {
    const issue = parsed.error.issues.find(
      (issue) =>
        issue.path[0] === "text" &&
        (issue.code === "custom" || issue.code === "too_big"),
    );
    throw new ApplicationError(400, issue?.message ?? "Write a note.");
  }
  return repo.change(id, (current) => {
    if (!current) throw new ApplicationError(404, "Application not found.");
    if (current.deleting)
      throw new ApplicationError(
        409,
        "Deletion has begun; this record is locked.",
      );
    if (current.notes.length >= STAFF_NOTE_LIMIT)
      throw new ApplicationError(
        409,
        `This application has reached the ${STAFF_NOTE_LIMIT}-note limit.`,
      );
    current.notes.push({
      id: crypto.randomUUID(),
      author: staff.author,
      at: new Date().toISOString(),
      text: parsed.data.text,
    });
    return savedView(current);
  });
}
export async function applyStaffChange(
  id: string,
  body: unknown,
  staff: StaffActor,
  repo: ApplicationRepository,
) {
  const parsed = z
    .object({
      action: z.enum([
        "status",
        "retention",
        "archive",
        "assignment",
        "note",
        "details",
      ]),
      value: z.unknown(),
    })
    .strict()
    .safeParse(body);
  if (!parsed.success) throw new ApplicationError(400, "Invalid change.");
  const { action, value } = parsed.data;
  if (action === "assignment")
    return changeStaffAssignment(id, value, staff, repo);
  if (action === "note") return addStaffNote(id, value, staff, repo);
  return {
    status: changeStaffStatus,
    retention: changeStaffRetention,
    archive: changeStaffArchive,
    details: changeStaffDetails,
  }[action](id, value, staff.actor, repo);
}
export const staffDocumentSchema = documentMetadataSchema
  .extend({
    kind: z.enum(staffDocumentKinds),
    sha256: z.string().regex(/^[a-f0-9]{64}$/),
  })
  .strict();
export function uploadableDocument(
  record: ApplicationRecord | null,
  kind: StaffDocumentKind,
) {
  if (!record) throw new ApplicationError(404, "Application not found.");
  if (record.deleting)
    throw new ApplicationError(
      409,
      "Deletion has begun; this record is locked.",
    );
  if (!record.submittedAt)
    throw new ApplicationError(
      409,
      "An unfinished application's documents come from the applicant.",
    );
  if (record.archivedAt)
    throw new ApplicationError(
      409,
      "Restore this application before adding documents.",
    );
  if (
    record.documents.some(
      (document) => document.kind === kind && document.generation,
    )
  )
    throw new ApplicationError(
      409,
      `${staffDocumentLabels[kind]} is already attached. Attached documents cannot be replaced.`,
    );
  return record;
}
export function attachVerifiedDocument(
  record: ApplicationRecord,
  ticket: string,
  generation: string,
  staff: StaffActor,
) {
  const document = record.documents.find(
    (document) => document.ticket === ticket && !document.generation,
  );
  if (!document)
    throw new ApplicationError(
      409,
      "This upload was replaced or already finished. Reload the record.",
    );
  if (record.history.length >= 498)
    throw new ApplicationError(
      409,
      "The audit history limit has been reached. Contact the administrator.",
    );
  document.generation = generation;
  document.uploadedBy = staff.author;
  record.history.push({
    actor: staff.actor,
    at: new Date().toISOString(),
    field: "document",
    before: "none",
    after: `${staffDocumentLabels[document.kind]}: ${document.name}`,
  });
  record.revision++;
  return savedView(record);
}
const hex = (bytes: ArrayBuffer | Uint8Array) =>
  Array.from(new Uint8Array(bytes), (byte) =>
    byte.toString(16).padStart(2, "0"),
  ).join("");
export async function createStaffApplication(
  input: unknown,
  staff: StaffActor,
  repo: ApplicationRepository,
) {
  const parsed = staffCreateSchema.safeParse(input);
  if (!parsed.success)
    throw new ApplicationError(
      400,
      "Check the application details and try again.",
    );
  const { requestId, declaration } = parsed.data,
    sections = {} as Record<StaffDetailSection, Record<string, string>>;
  for (const section of staffDetailSections) {
    const defaults = emptyStaffSection(section);
    if (
      Object.keys(parsed.data[section]).some(
        (key) => !Object.hasOwn(defaults, key),
      )
    )
      throw new ApplicationError(
        400,
        "Check the application details and try again.",
      );
    sections[section] = { ...defaults, ...parsed.data[section] };
  }
  const failures = staffCreateFailures({ ...sections, declaration }),
    failed = Object.keys(failures)[0];
  if (failed) {
    const [section, key] = failed.split(".");
    throw new ApplicationError(
      400,
      section === "declaration"
        ? failures[failed]
        : `${staffDetailLabel(section as StaffDetailSection, key)}: ${failures[failed]}`,
    );
  }
  const data = Object.fromEntries(
    staffDetailSections.map((section) => [
      section,
      parseStaffDetails(section, sections[section]),
    ]),
  ) as Record<StaffDetailSection, Record<string, string> | null>;
  const signed = staffDeclarationRecord(declaration, staff.author);
  if (
    !data.applicant ||
    !data.particulars ||
    !data.loanContext ||
    !data.assessment ||
    !signed
  )
    throw new ApplicationError(
      400,
      "Check the application details and try again.",
    );
  const fingerprint = hex(
    await crypto.subtle.digest(
      "SHA-256",
      new TextEncoder().encode(
        JSON.stringify({
          ...data,
          declaration: { ...signed, recordedBy: undefined },
        }),
      ),
    ),
  );
  const ownerDigest = hex(crypto.getRandomValues(new Uint8Array(32)));
  return repo.change(requestId, (current) => {
    if (current) {
      if (current.source === "staff" && current.fingerprint === fingerprint)
        return { record: current, result: staffView(current) };
      throw new ApplicationError(
        409,
        "This application was already created with different details. Start a new application.",
      );
    }
    const now = new Date().toISOString(),
      assessment = data.assessment as Assessment;
    const record: ApplicationRecord = {
      id: requestId,
      reference: `MC-${requestId.replaceAll("-", "").toUpperCase()}`,
      ownerDigest,
      fingerprint,
      fields: data.applicant as ApplicationRecord["fields"],
      particulars: data.particulars!,
      loanContext: data.loanContext!,
      declaration: signed,
      ...(Object.values(assessment).some(Boolean) ? { assessment } : {}),
      documents: [],
      createdAt: now,
      submittedAt: now,
      status: "new",
      revision: 1,
      deleting: false,
      archivedAt: null,
      notes: [],
      assignee: null,
      source: "staff",
      retention: {
        policyVersion: RETENTION_POLICY_VERSION,
        receivedAt: now,
        latestDocumentReceivedAt: now,
        lifecycle: { outcome: "unapproved" },
        classificationReviewed: false,
        hold: "none",
      },
      retentionReview: { basis: "Awaiting classification", nextReviewAt: null },
      notification: { state: "sent", leaseUntil: 0, attempts: 0 },
      history: [
        {
          actor: staff.actor,
          at: now,
          field: "created",
          before: "none",
          after: `Entered by ${staff.author}. Declarations signed on paper on ${declaration.signedOn}.`,
        },
      ],
    };
    return savedView(record);
  });
}
