import { ok, type CommandContext } from "@tailor-platform/erp-kit/core";
import { createKyselyMock } from "@tailor-platform/sdk/vitest";
import { describe, expect, it, vi } from "vitest";
import type { Namespace } from "../generated/kysely-tailordb";
import {
  NotActiveAssigneeError,
  ParentRequestNotPendingError,
  RoleHasNoActiveMembersError,
  SelfDecisionNotAllowedError,
  StepQuorumUnreachableError,
} from "../lib/errors.generated";
import {
  approvedRequest,
  cancelledRequest,
  inProgressStep1,
  pendingAssigneeRoleRow,
  pendingAssigneeUser,
  pendingRequest,
  pendingStep2,
  rejectedRequest,
  withdrawnRequest,
} from "../testing/fixtures";
import { run } from "./approveApprovalStep";

const ctx: CommandContext = {
  actorId: "user-manager-1",
  permissions: ["approval:request:approveApprovalStep"],
};

const makeListUsersByRole = (users: Array<{ id: string }>) =>
  vi.fn().mockResolvedValue(ok({ users }));

describe("approveApprovalStep", () => {
  it("approves a single-assignee step and cascades the request to APPROVED when this is the last step", async () => {
    const mock = createKyselyMock<Namespace["main-db"]>();
    mock.enqueueResults(
      [pendingAssigneeUser], // load assignee
      [inProgressStep1], // load step
      [pendingRequest], // load request
      [{}], // insert decision
      [], // update assignee
      [{ ...pendingAssigneeUser, status: "APPROVED" }], // post-update step assignees
      [], // update step
      [], // no next step
      [{ ...pendingRequest, status: "APPROVED" }], // update request
    );

    const result = await mock.withTx((trx) =>
      run(trx, { approvalStepAssigneeId: pendingAssigneeUser.id }, ctx, {
        listUsersByRole: makeListUsersByRole([]),
      }),
    );

    expect(result.ok).toBe(true);
    if (result.ok) {
      expect(result.value.approvalRequest.status).toBe("APPROVED");
    }
    expect(mock.inserts.length).toBeGreaterThan(0); // ApprovalDecision row written
  });

  it("approves an intermediate step and activates the next step by transitioning it to IN_PROGRESS", async () => {
    const mock = createKyselyMock<Namespace["main-db"]>();
    mock.enqueueResults(
      [pendingAssigneeUser],
      [inProgressStep1],
      [pendingRequest],
      [{}], // insert decision
      [], // update assignee
      [{ ...pendingAssigneeUser, status: "APPROVED" }],
      [], // update step
      [pendingStep2], // next step exists
      [{ ...pendingAssigneeUser, userId: "user-director-1", roleId: null }], // user-only on next step
      [], // update next step
    );

    const result = await mock.withTx((trx) =>
      run(trx, { approvalStepAssigneeId: pendingAssigneeUser.id }, ctx, {
        listUsersByRole: makeListUsersByRole([]),
      }),
    );

    expect(result.ok).toBe(true);
    // Step gets approved, next step set to IN_PROGRESS — two updateTable calls beyond assignee update
    expect(mock.updates.some((u) => u.updateValues().status === "IN_PROGRESS")).toBe(true);
  });

  it("activates an already-expanded next step (resubmit restart) without re-expanding roles", async () => {
    const mock = createKyselyMock<Namespace["main-db"]>();
    // After a resubmit restart, the next step's role assignees were already
    // expanded in a prior round, so each row carries both userId and roleId.
    const expandedRow = {
      ...pendingAssigneeRoleRow,
      userId: "user-director-1",
      status: "PENDING" as const,
    };
    mock.enqueueResults(
      [pendingAssigneeUser], // load assignee
      [inProgressStep1], // load step
      [pendingRequest], // load request
      [{}], // insert decision
      [], // update assignee
      [{ ...pendingAssigneeUser, status: "APPROVED" }], // post-update step assignees
      [], // update step
      [pendingStep2], // next step exists (minimumApprovals = 1)
      [expandedRow], // already-expanded member on next step
      [], // update next step
    );

    const listUsersByRole = makeListUsersByRole([]);
    const result = await mock.withTx((trx) =>
      run(trx, { approvalStepAssigneeId: pendingAssigneeUser.id }, ctx, {
        listUsersByRole,
      }),
    );

    expect(result.ok).toBe(true);
    // The already-expanded row is counted toward quorum; no re-expansion occurs.
    expect(listUsersByRole).not.toHaveBeenCalled();
    expect(mock.updates.some((u) => u.updateValues().status === "IN_PROGRESS")).toBe(true);
    // Only the APPROVE decision row is inserted — no expanded assignee rows.
    expect(mock.inserts.length).toBe(1);
  });

  it("expands role-based assignees on the next step at activation time", async () => {
    const mock = createKyselyMock<Namespace["main-db"]>();
    const listUsersByRole = makeListUsersByRole([
      { id: "user-director-1" },
      { id: "user-director-2" },
    ]);

    mock.enqueueResults(
      [pendingAssigneeUser],
      [inProgressStep1],
      [pendingRequest],
      [{}], // insert decision
      [], // update assignee
      [{ ...pendingAssigneeUser, status: "APPROVED" }],
      [], // update step
      [pendingStep2],
      [pendingAssigneeRoleRow], // role-only on next step
      [], // insert expanded users
      [], // delete seed row
      [], // update next step
    );

    const result = await mock.withTx((trx) =>
      run(trx, { approvalStepAssigneeId: pendingAssigneeUser.id }, ctx, {
        listUsersByRole,
      }),
    );

    expect(result.ok).toBe(true);
    expect(listUsersByRole).toHaveBeenCalledWith(
      expect.anything(),
      { roleId: pendingAssigneeRoleRow.roleId },
      ctx,
    );
    // Inserted decision row + expanded users
    expect(mock.inserts).toHaveLength(2);
    // Inserted user rows include both expanded users
    expect(mock.inserts[1].insertRows()).toEqual(
      expect.arrayContaining([
        expect.objectContaining({
          userId: "user-director-1",
          roleId: pendingAssigneeRoleRow.roleId,
        }),
        expect.objectContaining({
          userId: "user-director-2",
          roleId: pendingAssigneeRoleRow.roleId,
        }),
      ]),
    );
    // Role-only row dropped
    expect(mock.deletes.length).toBeGreaterThan(0);
  });

  it("approves one of N assignees on a step with minimumApprovals = 1 and completes the step", async () => {
    const mock = createKyselyMock<Namespace["main-db"]>();
    const peer1 = { ...pendingAssigneeUser, id: "a1", userId: "user-manager-1" };
    const peer2 = { ...pendingAssigneeUser, id: "a2", userId: "user-manager-2" };

    mock.enqueueResults(
      [peer1],
      [inProgressStep1], // minimumApprovals = 1
      [pendingRequest],
      [{}], // insert decision
      [], // update assignee
      [{ ...peer1, status: "APPROVED" }, peer2], // post-update
      [], // update step
      [], // no next step
      [{ ...pendingRequest, status: "APPROVED" }], // update request
    );

    const result = await mock.withTx((trx) =>
      run(trx, { approvalStepAssigneeId: peer1.id }, ctx, {
        listUsersByRole: makeListUsersByRole([]),
      }),
    );

    expect(result.ok).toBe(true);
    if (result.ok) {
      expect(result.value.approvalRequest.status).toBe("APPROVED");
    }
  });

  it("approves the second of three peers on a step with minimumApprovals = 2 and completes the step", async () => {
    const mock = createKyselyMock<Namespace["main-db"]>();
    const stepMin2 = { ...inProgressStep1, minimumApprovals: 2 };
    const peer1 = {
      ...pendingAssigneeUser,
      id: "a1",
      userId: "user-manager-1",
      status: "APPROVED",
    };
    const peer2 = { ...pendingAssigneeUser, id: "a2", userId: "user-manager-1" }; // actor's row, still pending
    const peer3 = { ...pendingAssigneeUser, id: "a3", userId: "user-manager-3" };

    mock.enqueueResults(
      [peer2],
      [stepMin2],
      [pendingRequest],
      [{}], // insert decision
      [], // update assignee
      [peer1, { ...peer2, status: "APPROVED" }, peer3], // 2 of 3 approved
      [], // update step
      [], // no next step
      [{ ...pendingRequest, status: "APPROVED" }], // update request
    );

    const result = await mock.withTx((trx) =>
      run(trx, { approvalStepAssigneeId: peer2.id }, ctx, {
        listUsersByRole: makeListUsersByRole([]),
      }),
    );

    expect(result.ok).toBe(true);
    if (result.ok) {
      expect(result.value.approvalRequest.status).toBe("APPROVED");
    }
  });

  it("keeps the step IN_PROGRESS when the APPROVED count reaches minimumApprovals but a required = true assignee is still PENDING", async () => {
    const mock = createKyselyMock<Namespace["main-db"]>();
    const stepMin1 = { ...inProgressStep1, minimumApprovals: 1 };
    const optionalActor = {
      ...pendingAssigneeUser,
      id: "a1",
      userId: "user-manager-1",
      required: false,
    };
    const requiredOther = {
      ...pendingAssigneeUser,
      id: "a2",
      userId: "user-manager-2",
      required: true,
    };

    mock.enqueueResults(
      [optionalActor],
      [stepMin1],
      [pendingRequest],
      [{}], // insert decision
      [], // update assignee
      [{ ...optionalActor, status: "APPROVED" }, requiredOther], // required still pending
    );

    const result = await mock.withTx((trx) =>
      run(trx, { approvalStepAssigneeId: optionalActor.id }, ctx, {
        listUsersByRole: makeListUsersByRole([]),
      }),
    );

    expect(result.ok).toBe(true);
    // Only the assignee row gets APPROVED — step stays IN_PROGRESS, so set() runs exactly once
    const approvedCalls = mock.updates.filter(
      (u) => (u.updateValues() as { status?: string }).status === "APPROVED",
    );
    expect(approvedCalls).toHaveLength(1);
  });

  it("keeps the step IN_PROGRESS when all required = true assignees are APPROVED but the APPROVED count is below minimumApprovals", async () => {
    const mock = createKyselyMock<Namespace["main-db"]>();
    const stepMin2 = { ...inProgressStep1, minimumApprovals: 2 };
    const requiredActor = {
      ...pendingAssigneeUser,
      id: "a1",
      userId: "user-manager-1",
      required: true,
    };
    const optionalOther = {
      ...pendingAssigneeUser,
      id: "a2",
      userId: "user-manager-2",
      required: false,
    };

    mock.enqueueResults(
      [requiredActor],
      [stepMin2],
      [pendingRequest],
      [{}], // insert decision
      [], // update assignee
      [{ ...requiredActor, status: "APPROVED" }, optionalOther], // count=1 < 2
    );

    const result = await mock.withTx((trx) =>
      run(trx, { approvalStepAssigneeId: requiredActor.id }, ctx, {
        listUsersByRole: makeListUsersByRole([]),
      }),
    );

    expect(result.ok).toBe(true);
    const approvedCalls = mock.updates.filter(
      (u) => (u.updateValues() as { status?: string }).status === "APPROVED",
    );
    expect(approvedCalls).toHaveLength(1);
  });

  it("completes the step only when every member of a roleQuorum = ALL group is APPROVED (regardless of step.minimumApprovals)", async () => {
    // 3 member rows on a single role group with roleQuorum=ALL, minimumApprovals=1.
    // Only 2 of 3 are APPROVED after this approve → step should stay IN_PROGRESS.
    const memberA = {
      ...pendingAssigneeUser,
      id: "a-1",
      userId: "user-manager-1",
      roleId: "role-X",
      roleQuorum: "ALL",
    };
    const memberB = { ...memberA, id: "a-2", userId: "u-B" };
    const memberC = { ...memberA, id: "a-3", userId: "u-C" };
    const mock = createKyselyMock<Namespace["main-db"]>();
    mock.enqueueResults(
      [memberA],
      [inProgressStep1],
      [pendingRequest],
      [{}], // insert decision
      [], // update assignee
      [
        { ...memberA, status: "APPROVED" },
        { ...memberB, status: "APPROVED" },
        memberC, // still PENDING
      ],
    );

    const result = await mock.withTx((trx) =>
      run(trx, { approvalStepAssigneeId: memberA.id }, ctx, {
        listUsersByRole: makeListUsersByRole([]),
      }),
    );

    expect(result.ok).toBe(true);
    const approvedCalls = mock.updates.filter(
      (u) => (u.updateValues() as { status?: string }).status === "APPROVED",
    );
    // Only the assignee row was set to APPROVED, not the step
    expect(approvedCalls).toHaveLength(1);
  });

  it("completes the step as soon as one member of a roleQuorum = ANY group is APPROVED (subject to step.minimumApprovals)", async () => {
    // 3 members on a role group with roleQuorum=ANY, minimumApprovals=1.
    // 1 of 3 APPROVED is enough for ANY + threshold → step completes.
    const memberA = {
      ...pendingAssigneeUser,
      id: "a-1",
      userId: "user-manager-1",
      roleId: "role-X",
      roleQuorum: "ANY",
    };
    const memberB = { ...memberA, id: "a-2", userId: "u-B" };
    const memberC = { ...memberA, id: "a-3", userId: "u-C" };
    const mock = createKyselyMock<Namespace["main-db"]>();
    mock.enqueueResults(
      [memberA],
      [inProgressStep1],
      [pendingRequest],
      [{}], // insert decision
      [], // update assignee
      [
        { ...memberA, status: "APPROVED" },
        memberB, // still PENDING
        memberC, // still PENDING
      ],
      [], // update step
      [], // no next step
      [{ ...pendingRequest, status: "APPROVED" }], // update request
    );

    const result = await mock.withTx((trx) =>
      run(trx, { approvalStepAssigneeId: memberA.id }, ctx, {
        listUsersByRole: makeListUsersByRole([]),
      }),
    );

    expect(result.ok).toBe(true);
    const approvedCalls = mock.updates.filter(
      (u) => (u.updateValues() as { status?: string }).status === "APPROVED",
    );
    // Assignee + step + request all transitioned to APPROVED
    expect(approvedCalls.length).toBeGreaterThanOrEqual(2);
  });

  it("ignores roleQuorum when null and falls back to per-user required + step threshold semantics", async () => {
    // Role group with roleQuorum=null acts purely as a member pool;
    // step.minimumApprovals=1 with one APPROVED row should complete the step.
    const memberA = {
      ...pendingAssigneeUser,
      id: "a-1",
      userId: "user-manager-1",
      roleId: "role-X",
      roleQuorum: null,
    };
    const memberB = { ...memberA, id: "a-2", userId: "u-B" };
    const mock = createKyselyMock<Namespace["main-db"]>();
    mock.enqueueResults(
      [memberA],
      [inProgressStep1],
      [pendingRequest],
      [{}], // insert decision
      [], // update assignee
      [{ ...memberA, status: "APPROVED" }, memberB],
      [], // update step
      [], // no next step
      [{ ...pendingRequest, status: "APPROVED" }], // update request
    );

    const result = await mock.withTx((trx) =>
      run(trx, { approvalStepAssigneeId: memberA.id }, ctx, {
        listUsersByRole: makeListUsersByRole([]),
      }),
    );

    expect(result.ok).toBe(true);
    const approvedCalls = mock.updates.filter(
      (u) => (u.updateValues() as { status?: string }).status === "APPROVED",
    );
    expect(approvedCalls.length).toBeGreaterThanOrEqual(2);
  });

  it("writes one ApprovalDecision row per approve command, with both approvalRequestId and approvalStepId set", async () => {
    const mock = createKyselyMock<Namespace["main-db"]>();
    mock.enqueueResults(
      [pendingAssigneeUser],
      [inProgressStep1],
      [pendingRequest],
      [{}], // insert decision
      [], // update assignee
      [{ ...pendingAssigneeUser, status: "APPROVED" }],
      [], // update step
      [], // no next step
      [{ ...pendingRequest, status: "APPROVED" }], // update request
    );

    await mock.withTx((trx) =>
      run(trx, { approvalStepAssigneeId: pendingAssigneeUser.id }, ctx, {
        listUsersByRole: makeListUsersByRole([]),
      }),
    );

    expect(mock.inserts[0].insertValues()).toEqual(
      expect.objectContaining({
        approvalRequestId: pendingRequest.id,
        approvalStepId: inProgressStep1.id,
        decision: "APPROVE",
        decidedByUserId: ctx.actorId,
      }),
    );
  });

  it("accepts an optional comment and stores it on the decision row", async () => {
    const mock = createKyselyMock<Namespace["main-db"]>();
    mock.enqueueResults(
      [pendingAssigneeUser],
      [inProgressStep1],
      [pendingRequest],
      [{}], // insert decision
      [], // update assignee
      [{ ...pendingAssigneeUser, status: "APPROVED" }],
      [], // update step
      [], // no next step
      [{ ...pendingRequest, status: "APPROVED" }], // update request
    );

    await mock.withTx((trx) =>
      run(trx, { approvalStepAssigneeId: pendingAssigneeUser.id, comment: "LGTM" }, ctx, {
        listUsersByRole: makeListUsersByRole([]),
      }),
    );

    expect(mock.inserts[0].insertValues()).toEqual(expect.objectContaining({ comment: "LGTM" }));
  });

  it("throws NOT_ACTIVE_ASSIGNEE when the actor is not an active assignee on the step", async () => {
    const mock = createKyselyMock<Namespace["main-db"]>();
    mock.enqueueResults(
      [{ ...pendingAssigneeUser, userId: "someone-else" }],
      [inProgressStep1],
      [pendingRequest],
    );

    const result = await mock.withTx((trx) =>
      run(trx, { approvalStepAssigneeId: pendingAssigneeUser.id }, ctx, {
        listUsersByRole: makeListUsersByRole([]),
      }),
    );

    expect(result.ok).toBe(false);
    if (!result.ok) {
      expect(result.error).toBeInstanceOf(NotActiveAssigneeError);
    }
  });

  it("throws NOT_ACTIVE_ASSIGNEE when the StepAssignee row is already APPROVED", async () => {
    const mock = createKyselyMock<Namespace["main-db"]>();
    mock.enqueueResults(
      [{ ...pendingAssigneeUser, status: "APPROVED" }],
      [inProgressStep1],
      [pendingRequest],
    );

    const result = await mock.withTx((trx) =>
      run(trx, { approvalStepAssigneeId: pendingAssigneeUser.id }, ctx, {
        listUsersByRole: makeListUsersByRole([]),
      }),
    );

    expect(result.ok).toBe(false);
    if (!result.ok) {
      expect(result.error).toBeInstanceOf(NotActiveAssigneeError);
    }
  });

  it("throws PARENT_REQUEST_NOT_PENDING when the parent request has been WITHDRAWN, CANCELLED, APPROVED, or REJECTED", async () => {
    for (const terminalRequest of [
      withdrawnRequest,
      cancelledRequest,
      approvedRequest,
      rejectedRequest,
    ]) {
      const mock = createKyselyMock<Namespace["main-db"]>();
      mock.enqueueResults([pendingAssigneeUser], [inProgressStep1], [terminalRequest]);

      const result = await mock.withTx((trx) =>
        run(trx, { approvalStepAssigneeId: pendingAssigneeUser.id }, ctx, {
          listUsersByRole: makeListUsersByRole([]),
        }),
      );

      expect(result.ok).toBe(false);
      if (!result.ok) {
        expect(result.error).toBeInstanceOf(ParentRequestNotPendingError);
      }
    }
  });

  it("throws SELF_DECISION_NOT_ALLOWED when the actor is the requester of the parent request", async () => {
    const mock = createKyselyMock<Namespace["main-db"]>();
    const selfRequest = { ...pendingRequest, requesterId: ctx.actorId };
    mock.enqueueResults([pendingAssigneeUser], [inProgressStep1], [selfRequest]);

    const result = await mock.withTx((trx) =>
      run(trx, { approvalStepAssigneeId: pendingAssigneeUser.id }, ctx, {
        listUsersByRole: makeListUsersByRole([]),
      }),
    );

    expect(result.ok).toBe(false);
    if (!result.ok) {
      expect(result.error).toBeInstanceOf(SelfDecisionNotAllowedError);
    }
  });

  it("throws ROLE_HAS_NO_ACTIVE_MEMBERS when a role-based assignee on the next step expands to zero ACTIVE members at activation time", async () => {
    const mock = createKyselyMock<Namespace["main-db"]>();
    mock.enqueueResults(
      [pendingAssigneeUser],
      [inProgressStep1],
      [pendingRequest],
      [{}], // insert decision
      [], // update assignee
      [{ ...pendingAssigneeUser, status: "APPROVED" }],
      [], // update step
      [pendingStep2],
      [pendingAssigneeRoleRow], // role-only on next step
    );

    const result = await mock.withTx((trx) =>
      run(trx, { approvalStepAssigneeId: pendingAssigneeUser.id }, ctx, {
        listUsersByRole: makeListUsersByRole([]), // zero ACTIVE members
      }),
    );

    expect(result.ok).toBe(false);
    if (!result.ok) {
      expect(result.error).toBeInstanceOf(RoleHasNoActiveMembersError);
    }
  });

  it("throws STEP_QUORUM_UNREACHABLE when next-step role expansion yields fewer runtime assignees than nextStep.minimumApprovals", async () => {
    const mock = createKyselyMock<Namespace["main-db"]>();
    const nextStepWithQuorum3 = { ...pendingStep2, minimumApprovals: 3 };
    mock.enqueueResults(
      [pendingAssigneeUser],
      [inProgressStep1],
      [pendingRequest],
      [{}], // insert decision
      [], // update assignee
      [{ ...pendingAssigneeUser, status: "APPROVED" }],
      [], // update step
      [nextStepWithQuorum3],
      [pendingAssigneeRoleRow], // role-only on next step
      [], // insert expanded users
      [], // delete seed row
    );

    const result = await mock.withTx((trx) =>
      run(trx, { approvalStepAssigneeId: pendingAssigneeUser.id }, ctx, {
        listUsersByRole: makeListUsersByRole([{ id: "u1" }, { id: "u2" }]), // 2 < 3
      }),
    );

    expect(result.ok).toBe(false);
    if (!result.ok) {
      expect(result.error).toBeInstanceOf(StepQuorumUnreachableError);
    }
  });
});
