import { ok, type CommandContext } from "@tailor-platform/erp-kit/core";
import { describe, expect, it, vi } from "vitest";
import { createMockDb } from "../../../testing/index";
import type { Transaction } 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 { db, spies } = createMockDb<Transaction>();
    spies.select
      .mockReturnValueOnce(pendingAssigneeUser) // load assignee
      .mockReturnValueOnce(inProgressStep1) // load step
      .mockReturnValueOnce(pendingRequest) // load request
      .mockReturnValueOnce([{ ...pendingAssigneeUser, status: "APPROVED" }]) // post-update step assignees
      .mockReturnValueOnce(undefined); // no next step
    spies.update.mockReturnValue({ ...pendingRequest, status: "APPROVED" });

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

    expect(result.ok).toBe(true);
    if (result.ok) {
      expect(result.value.approvalRequest.status).toBe("APPROVED");
    }
    expect(spies.insert).toHaveBeenCalled(); // ApprovalDecision row written
  });

  it("approves an intermediate step and activates the next step by transitioning it to IN_PROGRESS", async () => {
    const { db, spies } = createMockDb<Transaction>();
    spies.select
      .mockReturnValueOnce(pendingAssigneeUser)
      .mockReturnValueOnce(inProgressStep1)
      .mockReturnValueOnce(pendingRequest)
      .mockReturnValueOnce([{ ...pendingAssigneeUser, status: "APPROVED" }])
      .mockReturnValueOnce(pendingStep2) // next step exists
      .mockReturnValueOnce([{ ...pendingAssigneeUser, userId: "user-director-1", roleId: null }]); // user-only on next step

    const result = await run(db, { 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(spies.set).toHaveBeenCalledWith(expect.objectContaining({ status: "IN_PROGRESS" }));
  });

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

    spies.select
      .mockReturnValueOnce(pendingAssigneeUser)
      .mockReturnValueOnce(inProgressStep1)
      .mockReturnValueOnce(pendingRequest)
      .mockReturnValueOnce([{ ...pendingAssigneeUser, status: "APPROVED" }])
      .mockReturnValueOnce(pendingStep2)
      .mockReturnValueOnce([pendingAssigneeRoleRow]); // role-only on next step

    const result = await run(db, { approvalStepAssigneeId: pendingAssigneeUser.id }, ctx, {
      listUsersByRole,
    });

    expect(result.ok).toBe(true);
    expect(listUsersByRole).toHaveBeenCalledWith(
      db,
      { roleId: pendingAssigneeRoleRow.roleId },
      ctx,
    );
    // Inserted decision row + expanded users
    expect(spies.insert).toHaveBeenCalledTimes(2);
    // Inserted user rows include both expanded users
    expect(spies.values).toHaveBeenCalledWith(
      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(spies.delete).toHaveBeenCalled();
  });

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

    spies.select
      .mockReturnValueOnce(peer1)
      .mockReturnValueOnce(inProgressStep1) // minimumApprovals = 1
      .mockReturnValueOnce(pendingRequest)
      .mockReturnValueOnce([{ ...peer1, status: "APPROVED" }, peer2]) // post-update
      .mockReturnValueOnce(undefined);
    spies.update.mockReturnValue({ ...pendingRequest, status: "APPROVED" });

    const result = await run(db, { 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 { db, spies } = createMockDb<Transaction>();
    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" };

    spies.select
      .mockReturnValueOnce(peer2)
      .mockReturnValueOnce(stepMin2)
      .mockReturnValueOnce(pendingRequest)
      .mockReturnValueOnce([peer1, { ...peer2, status: "APPROVED" }, peer3]) // 2 of 3 approved
      .mockReturnValueOnce(undefined);
    spies.update.mockReturnValue({ ...pendingRequest, status: "APPROVED" });

    const result = await run(db, { 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 { db, spies } = createMockDb<Transaction>();
    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,
    };

    spies.select
      .mockReturnValueOnce(optionalActor)
      .mockReturnValueOnce(stepMin1)
      .mockReturnValueOnce(pendingRequest)
      .mockReturnValueOnce([{ ...optionalActor, status: "APPROVED" }, requiredOther]); // required still pending

    const result = await run(db, { 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 = spies.set.mock.calls.filter(
      (call) => (call[0] 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 { db, spies } = createMockDb<Transaction>();
    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,
    };

    spies.select
      .mockReturnValueOnce(requiredActor)
      .mockReturnValueOnce(stepMin2)
      .mockReturnValueOnce(pendingRequest)
      .mockReturnValueOnce([{ ...requiredActor, status: "APPROVED" }, optionalOther]); // count=1 < 2

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

    expect(result.ok).toBe(true);
    const approvedCalls = spies.set.mock.calls.filter(
      (call) => (call[0] 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 { db, spies } = createMockDb<Transaction>();
    spies.select
      .mockReturnValueOnce(memberA)
      .mockReturnValueOnce(inProgressStep1)
      .mockReturnValueOnce(pendingRequest)
      .mockReturnValueOnce([
        { ...memberA, status: "APPROVED" },
        { ...memberB, status: "APPROVED" },
        memberC, // still PENDING
      ]);

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

    expect(result.ok).toBe(true);
    const approvedCalls = spies.set.mock.calls.filter(
      (call) => (call[0] 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 { db, spies } = createMockDb<Transaction>();
    spies.select
      .mockReturnValueOnce(memberA)
      .mockReturnValueOnce(inProgressStep1)
      .mockReturnValueOnce(pendingRequest)
      .mockReturnValueOnce([
        { ...memberA, status: "APPROVED" },
        memberB, // still PENDING
        memberC, // still PENDING
      ])
      .mockReturnValueOnce(undefined); // no next step
    spies.update.mockReturnValue({ ...pendingRequest, status: "APPROVED" });

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

    expect(result.ok).toBe(true);
    const approvedCalls = spies.set.mock.calls.filter(
      (call) => (call[0] 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 { db, spies } = createMockDb<Transaction>();
    spies.select
      .mockReturnValueOnce(memberA)
      .mockReturnValueOnce(inProgressStep1)
      .mockReturnValueOnce(pendingRequest)
      .mockReturnValueOnce([{ ...memberA, status: "APPROVED" }, memberB])
      .mockReturnValueOnce(undefined);
    spies.update.mockReturnValue({ ...pendingRequest, status: "APPROVED" });

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

    expect(result.ok).toBe(true);
    const approvedCalls = spies.set.mock.calls.filter(
      (call) => (call[0] 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 { db, spies } = createMockDb<Transaction>();
    spies.select
      .mockReturnValueOnce(pendingAssigneeUser)
      .mockReturnValueOnce(inProgressStep1)
      .mockReturnValueOnce(pendingRequest)
      .mockReturnValueOnce([{ ...pendingAssigneeUser, status: "APPROVED" }])
      .mockReturnValueOnce(undefined);
    spies.update.mockReturnValue({ ...pendingRequest, status: "APPROVED" });

    await run(db, { approvalStepAssigneeId: pendingAssigneeUser.id }, ctx, {
      listUsersByRole: makeListUsersByRole([]),
    });

    expect(spies.values).toHaveBeenCalledWith(
      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 { db, spies } = createMockDb<Transaction>();
    spies.select
      .mockReturnValueOnce(pendingAssigneeUser)
      .mockReturnValueOnce(inProgressStep1)
      .mockReturnValueOnce(pendingRequest)
      .mockReturnValueOnce([{ ...pendingAssigneeUser, status: "APPROVED" }])
      .mockReturnValueOnce(undefined);
    spies.update.mockReturnValue({ ...pendingRequest, status: "APPROVED" });

    await run(db, { approvalStepAssigneeId: pendingAssigneeUser.id, comment: "LGTM" }, ctx, {
      listUsersByRole: makeListUsersByRole([]),
    });

    expect(spies.values).toHaveBeenCalledWith(expect.objectContaining({ comment: "LGTM" }));
  });

  it("throws NOT_ACTIVE_ASSIGNEE when the actor is not an active assignee on the step", async () => {
    const { db, spies } = createMockDb<Transaction>();
    spies.select
      .mockReturnValueOnce({ ...pendingAssigneeUser, userId: "someone-else" })
      .mockReturnValueOnce(inProgressStep1)
      .mockReturnValueOnce(pendingRequest);

    const result = await run(db, { 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 { db, spies } = createMockDb<Transaction>();
    spies.select
      .mockReturnValueOnce({ ...pendingAssigneeUser, status: "APPROVED" })
      .mockReturnValueOnce(inProgressStep1)
      .mockReturnValueOnce(pendingRequest);

    const result = await run(db, { 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 { db, spies } = createMockDb<Transaction>();
      spies.select
        .mockReturnValueOnce(pendingAssigneeUser)
        .mockReturnValueOnce(inProgressStep1)
        .mockReturnValueOnce(terminalRequest);

      const result = await run(db, { 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 { db, spies } = createMockDb<Transaction>();
    const selfRequest = { ...pendingRequest, requesterId: ctx.actorId };
    spies.select
      .mockReturnValueOnce(pendingAssigneeUser)
      .mockReturnValueOnce(inProgressStep1)
      .mockReturnValueOnce(selfRequest);

    const result = await run(db, { 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 { db, spies } = createMockDb<Transaction>();
    spies.select
      .mockReturnValueOnce(pendingAssigneeUser)
      .mockReturnValueOnce(inProgressStep1)
      .mockReturnValueOnce(pendingRequest)
      .mockReturnValueOnce([{ ...pendingAssigneeUser, status: "APPROVED" }])
      .mockReturnValueOnce(pendingStep2)
      .mockReturnValueOnce([pendingAssigneeRoleRow]); // role-only on next step

    const result = await run(db, { 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 { db, spies } = createMockDb<Transaction>();
    const nextStepWithQuorum3 = { ...pendingStep2, minimumApprovals: 3 };
    spies.select
      .mockReturnValueOnce(pendingAssigneeUser)
      .mockReturnValueOnce(inProgressStep1)
      .mockReturnValueOnce(pendingRequest)
      .mockReturnValueOnce([{ ...pendingAssigneeUser, status: "APPROVED" }])
      .mockReturnValueOnce(nextStepWithQuorum3)
      .mockReturnValueOnce([pendingAssigneeRoleRow]); // role-only on next step

    const result = await run(db, { 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);
    }
  });
});
