import { BadRequestException } from '@nestjs/common';
import { Workbook } from 'exceljs';
import { AuditContext } from '../audit/audit.entity';
import { AUDIT_REASON } from '../audit/audit-reason';
import { reasonLabel } from '../audit-log/audit-log.vocab';
import { AuditService } from '../audit/audit.service';
import { MoneyService } from './money.service';

/**
 * **개입 7경로는 사유 유형 없이 저장되지 않는다** — 계획 M8 D1·D4 · Task 2.
 *
 * 경로 **각각**에 세 케이스를 둔다: 게이트 `#3` 이 판정 호출을 하나씩 우회한 사본을 일곱 번 돌리므로,
 * 한 경로만 덮으면 나머지 여섯의 우회가 이 spec 을 지난다. 거절은 **트랜잭션 전**이어야 한다 — 트랜잭션
 * 안에서 거절하면 거절 사실은 같아도 판정과 적재가 한 콜백에 섞인다(D1). 그래서 `runInTransaction` 이
 * 불리지 않았음을 함께 단언한다.
 *
 * 감사 층은 실물이다. 행의 `audReasonCode` 는 `AuditService.appendMoney` 가 이벤트에서 옮겨 쓴 값이다.
 */

const ctx: AuditContext = {
  actor: {
    role: 'ADMIN',
    refId: 19,
    refLabel: 'admin-a',
    orgPath: null,
    personId: null,
  },
  requestId: 'req-reason',
  ip: '192.0.2.10',
  userAgent: 'test-agent',
};

const MEMBER = {
  memId: 4211,
  memLoginId: 'user-a',
  agcId: 5,
  memMoney: 1_000_000,
  memPurchaseMoney: 0,
  memAgencyMoney: 1_000_000,
};

const AGENCY = { agcId: 5, agcCode: 'AGC1', agcMoney: 100_000_000 };

const REQUEST = {
  mcrId: 77,
  agcId: 5,
  mcrAmount: 50_000,
  mcrStatus: 'REQUESTED',
  mcrTitle: 't',
  mcrReason: null,
};

function harness() {
  const rows: Record<string, unknown>[] = [];
  const tx = {
    insert: () => ({
      values: async (row: Record<string, unknown>) => {
        rows.push(row);
      },
    }),
  };
  const runInTransaction = jest.fn(async (fn: (t: unknown) => unknown) =>
    fn(tx),
  );
  const audit = new AuditService({
    setContext: jest.fn(),
    info: jest.fn(),
    error: jest.fn(),
  } as any);
  const base: Record<string, unknown> = {
    runInTransaction,
    getAgencyById: async () => ({ ...AGENCY }),
    getAgencyByIdForUpdate: async () => ({ ...AGENCY }),
    getMemberById: async () => ({ ...MEMBER }),
    getMemberByIdForUpdate: async () => ({ ...MEMBER }),
    getMemberByLoginId: async () => ({ ...MEMBER }),
    getMemberByLoginIdAndAgcId: async () => ({ ...MEMBER }),
    getMemberByLoginIdAndAgcCode: async () => ({ ...MEMBER }),
    getMembersByAgcId: async () => [{ memId: MEMBER.memId }],
    getAllMembers: async () => [{ memId: MEMBER.memId }],
    isMemberInAgencyScope: async () => true,
    createIssue: async () => 4001,
    createGrant: async () => 5001,
    createWallet: async () => 6001,
    //* 회수 경로(기업 콘솔 회수 사례)가 차감할 기업머니 원천 하나.
    getSourcesForDeduct: async () => [
      { msrId: 7001, msrMoneyType: 'AGENCY', msrRemainingAmount: 1_000_000 },
    ],
    getChargeRequestById: async () => ({ ...REQUEST }),
    approveChargeRequest: async () => ({
      processed: true,
      balanceBefore: AGENCY.agcMoney,
      newBalance: AGENCY.agcMoney + REQUEST.mcrAmount,
    }),
  };
  const repository = new Proxy({} as any, {
    get: (_t, k: string) => (k in base ? base[k] : async () => undefined),
  });
  const service = new MoneyService(
    repository,
    {} as any,
    {} as any,
    {} as any,
    audit,
  );
  return { service, rows, runInTransaction };
}

/** 두 행짜리 엑셀. 첫 행의 사유는 차 있고, 둘째 행(엑셀 3행)의 사유는 `detail` 이다. */
async function xlsx(header: unknown[], rows: unknown[][]): Promise<any> {
  const wb = new Workbook();
  const sheet = wb.addWorksheet('s');
  sheet.addRow(header);
  rows.forEach((r) => sheet.addRow(r));
  return { buffer: Buffer.from(await wb.xlsx.writeBuffer()) };
}

const issueFile = (detail: string) =>
  xlsx(
    ['구분', '회원아이디', '기업코드', '금액', '내용', '사유', '유효기간'],
    [
      ['일반', MEMBER.memLoginId, '', 1000, '내용', '사유', ''],
      ['일반', MEMBER.memLoginId, '', 500, '내용', detail, ''],
    ],
  );

const grantFile = (detail: string) =>
  xlsx(
    ['회원아이디', '금액', '내용', '사유', '유효기간'],
    [
      [MEMBER.memLoginId, 1000, '내용', '사유', ''],
      [MEMBER.memLoginId, 500, '내용', detail, ''],
    ],
  );

type Run = (
  s: MoneyService,
  reasonCode: unknown,
  detail: string,
) => Promise<unknown>;

/** 경로마다 [이름, 행위, 호출]. `detail` 은 그 경로의 상세 사유 칸이다(엑셀은 둘째 행의 사유 칸). */
const PATHS: [string, string, Run][] = [
  [
    'createIssue',
    'money.issue',
    (s, reasonCode, detail) =>
      s.createIssue(
        19,
        {
          agcId: 5,
          mniTargetType: 'SPECIFIC',
          mniMemberType: 'NORMAL',
          memberIds: [MEMBER.memId],
          mniType: 'EARN',
          mniTitle: 'title',
          mniReason: detail,
          mniAmount: 1000,
          reasonCode,
        } as any,
        ctx,
      ),
  ],
  [
    'createCorpMoney',
    'money.corp.charge',
    (s, reasonCode, detail) =>
      s.createCorpMoney(
        19,
        {
          agcId: 5,
          mcpAmount: 10_000,
          mcpTitle: 't',
          mcpReason: detail,
          reasonCode,
        } as any,
        ctx,
      ),
  ],
  [
    'processChargeRequest',
    'money.charge-request.approve',
    (s, reasonCode, detail) =>
      s.processChargeRequest(
        19,
        {
          mcrId: REQUEST.mcrId,
          action: 'APPROVE',
          adminMemo: detail,
          reasonCode,
        } as any,
        ctx,
      ),
  ],
  [
    'createGrant',
    'money.grant',
    (s, reasonCode, detail) =>
      s.createGrant(
        19,
        {
          agcId: 5,
          mngTargetType: 'SPECIFIC',
          memberIds: [MEMBER.memId],
          mngType: 'GRANT',
          mngTitle: 'title',
          mngReason: detail,
          mngAmount: 1000,
          reasonCode,
        } as any,
        ctx,
      ),
  ],
  [
    'createGrantSingle',
    'money.grant.single',
    (s, reasonCode, detail) =>
      s.createGrantSingle(
        19,
        {
          agcId: 5,
          memId: MEMBER.memId,
          mngAmount: 1000,
          mngTitle: 'title',
          mngReason: detail,
          mngHasExpiry: 0,
          reasonCode,
        } as any,
        ctx,
      ),
  ],
  [
    'uploadIssue',
    'money.issue.upload',
    async (s, reasonCode, detail) =>
      s.uploadIssue(19, await issueFile(detail), reasonCode, ctx),
  ],
  [
    'uploadGrant',
    'money.grant.upload',
    async (s, reasonCode, detail) =>
      s.uploadGrant(19, await grantFile(detail), 5, reasonCode, ctx),
  ],
];

describe.each(PATHS)('%s — 사유 유형 판정', (_name, action, run) => {
  it('사유 유형이 없으면 트랜잭션 전에 거절하고 행이 없다', async () => {
    const { service, rows, runInTransaction } = harness();
    await expect(run(service, undefined, '상세')).rejects.toThrow(
      new BadRequestException('사유 유형을 선택해주세요.'),
    );
    expect(runInTransaction).not.toHaveBeenCalled();
    expect(rows).toHaveLength(0);
  });

  it('모르는 사유 유형도 트랜잭션 전에 거절한다', async () => {
    const { service, rows, runInTransaction } = harness();
    await expect(run(service, 'REFUND', '상세')).rejects.toThrow(
      BadRequestException,
    );
    expect(runInTransaction).not.toHaveBeenCalled();
    expect(rows).toHaveLength(0);
  });

  it('기타(OTHER) 인데 상세 사유가 비면 트랜잭션 전에 거절하고 행이 없다', async () => {
    const { service, rows, runInTransaction } = harness();
    await expect(run(service, 'OTHER', '  ')).rejects.toThrow(
      /사유 유형이 기타이면 상세 사유를 입력해주세요/,
    );
    expect(runInTransaction).not.toHaveBeenCalled();
    expect(rows).toHaveLength(0);
  });

  it('기타(OTHER) 에 상세 사유가 있으면 저장되고 행에 그 코드가 실린다', async () => {
    const { service, rows } = harness();
    await run(service, 'OTHER', '상세');
    expect(rows).toHaveLength(1);
    expect(rows[0]).toMatchObject({
      audAction: action,
      audReasonCode: 'OTHER',
    });
  });

  it('사유 유형이 있으면 감사 행에 그 코드가 실린다', async () => {
    const { service, rows } = harness();
    await run(service, 'CORRECTION', '');
    expect(rows).toHaveLength(1);
    expect(rows[0]).toMatchObject({
      audAction: action,
      audReasonCode: 'CORRECTION',
    });
  });
});

describe('엑셀 일괄 — 업로드당 유형 하나 · 거절 문장이 빈 행을 말한다(D4)', () => {
  it('엑셀 발행 미리보기도 사유 유형이 없으면 거절한다 — 확인 모달 전에 보인다', async () => {
    const { service, runInTransaction } = harness();
    await expect(
      service.uploadIssue(19, await issueFile('사유'), undefined, ctx, true),
    ).rejects.toThrow(new BadRequestException('사유 유형을 선택해주세요.'));
    expect(runInTransaction).not.toHaveBeenCalled();
  });

  it('엑셀 발행 미리보기는 사유가 맞으면 통과하고 적용하지 않는다', async () => {
    const { service, runInTransaction } = harness();
    const out: any = await service.uploadIssue(
      19,
      await issueFile('사유'),
      'EVENT',
      ctx,
      true,
    );
    expect(out.dryRun).toBe(true);
    expect(runInTransaction).not.toHaveBeenCalled();
  });

  it.each([
    [
      'uploadIssue',
      (s: MoneyService, f: any) => s.uploadIssue(19, f, 'OTHER', ctx),
    ],
    [
      'uploadGrant',
      (s: MoneyService, f: any) => s.uploadGrant(19, f, 5, 'OTHER', ctx),
    ],
  ] as [string, (s: MoneyService, f: any) => Promise<unknown>][])(
    '%s 는 기타일 때 사유가 빈 행 번호를 거절 문장에 싣는다',
    async (name, call) => {
      const { service } = harness();
      const file =
        name === 'uploadIssue' ? await issueFile('') : await grantFile('');
      await expect(call(service, file)).rejects.toThrow(
        new BadRequestException(
          '사유 유형이 기타이면 상세 사유를 입력해주세요: 3행',
        ),
      );
    },
  );
});

/**
 * **기업·조직관리자(corp) 콘솔은 사유 유형을 고르지 않는다** — 2026-10 요청. 서버가 방향으로 정한다:
 * 지급=배분(CORP_DISTRIBUTE) · 회수=회수(CORP_RECOVER) · 엑셀에 둘이 섞이면 배분·회수.
 * 관리자 경로의 '사유 유형 없으면 거절'(위 describe.each)은 그대로다 — scope 가 있을 때만 갈린다.
 */
describe('기업 콘솔(scope) 지급·회수 — 사유 유형 없이 배분/회수로 저장', () => {
  const corpCtx: AuditContext = {
    ...ctx,
    actor: {
      role: 'CORP',
      refId: 5,
      refLabel: 'corp-a',
      orgPath: null,
      personId: null,
    },
  };
  const scope = {
    agcId: 5,
    actorType: 'CORP_SUPER' as const,
    actorMemId: null,
  };
  const grantDto = (mngType: string, reasonCode?: string) =>
    ({
      agcId: 5,
      mngTargetType: 'SPECIFIC',
      memberIds: [MEMBER.memId],
      mngType,
      mngTitle: 'title',
      mngAmount: 1000,
      reasonCode,
    }) as any;

  it.each([
    ['GRANT', 'CORP_DISTRIBUTE'],
    ['GRANT_RECOVERY', 'CORP_RECOVER'],
  ])(
    '일괄 %s 는 사유 유형 없이 저장되고 감사 행에 %s 가 실린다',
    async (mngType, code) => {
      const { service, rows } = harness();
      await service.createGrant(null, grantDto(mngType), corpCtx, scope);
      expect(rows).toHaveLength(1);
      expect(rows[0]).toMatchObject({
        audAction: 'money.grant',
        audReasonCode: code,
      });
    },
  );

  it('기업이 사유 유형을 보내도 서버가 정한 값으로 덮는다', async () => {
    const { service, rows } = harness();
    await service.createGrant(null, grantDto('GRANT', 'EVENT'), corpCtx, scope);
    expect(rows[0]).toMatchObject({ audReasonCode: 'CORP_DISTRIBUTE' });
  });

  it.each([
    [1000, 'CORP_DISTRIBUTE'],
    [-1000, 'CORP_RECOVER'],
  ])('단건 금액 %d 는 %s 로 저장된다', async (mngAmount, code) => {
    const { service, rows } = harness();
    await service.createGrantSingle(
      null,
      {
        agcId: 5,
        memId: MEMBER.memId,
        mngAmount,
        mngTitle: 'title',
        mngHasExpiry: 0,
      } as any,
      corpCtx,
      scope,
    );
    expect(rows[0]).toMatchObject({
      audAction: 'money.grant.single',
      audReasonCode: code,
    });
  });

  it.each([
    [[1000, 500], 'CORP_DISTRIBUTE'],
    [[-1000, -500], 'CORP_RECOVER'],
    [[1000, -500], 'CORP_DISTRIBUTE_RECOVER'],
  ])(
    '엑셀 행 금액 %j 는 사유 유형 없이 %s 로 저장된다',
    async (amounts, code) => {
      const { service, rows } = harness();
      const file = await xlsx(
        ['회원아이디', '금액', '내용', '사유', '유효기간'],
        amounts.map((a) => [MEMBER.memLoginId, a, '내용', '', '']),
      );
      await service.uploadGrant(null, file, 5, undefined, corpCtx, scope);
      expect(rows[0]).toMatchObject({
        audAction: 'money.grant.upload',
        audReasonCode: code,
      });
    },
  );

  it('관리자 선택지(AUDIT_REASON)에는 기업 콘솔 사유가 섞이지 않고, 감사 로그는 라벨로 읽는다', () => {
    expect(AUDIT_REASON.map((r) => r.code)).not.toEqual(
      expect.arrayContaining(['CORP_DISTRIBUTE']),
    );
    expect(reasonLabel('CORP_DISTRIBUTE')).toBe('배분');
    expect(reasonLabel('CORP_RECOVER')).toBe('회수');
    expect(reasonLabel('CORP_DISTRIBUTE_RECOVER')).toBe('배분·회수');
  });
});
