import { Workbook } from 'exceljs';
import { AuditContext } from '../audit/audit.entity';
import { AuditService } from '../audit/audit.service';
import { MoneyService } from './money.service';

/**
 * **`txDurationMs` 는 트랜잭션의 경과를 담고, 그 경과는 커밋 대기까지 포함한다** — 계획 M2 D4·D4a.
 *
 * 이 값의 쓸모는 *상한* 이라는 데 있다. 락은 `FOR UPDATE` 부터 커밋까지 잡히므로,
 * `runInTransaction` 이 **반환한 뒤**에 재야 그 구간을 위에서 감싼다. 시각 포착을 콜백
 * **안**으로 옮기거나 방출을 콜백 안에서 하면 커밋 대기가 빠지고, 그 순간 "항상 크거나
 * 같다" 는 D4 의 주장이 그 줄에서 거짓이 된다 — M3 는 과소보고된 상한 위에서
 * "INSERT 를 하나 더 넣어도 되는가" 를 판단하게 된다.
 *
 * **그래서 `> 0` 으로는 이 계약을 잡지 못한다.** 콜백 안에서 재도 `> 0` 은 참이다. 아래
 * 트랜잭션 스텁은 콜백이 끝난 **뒤에** {@link COMMIT_WAIT_MS} 만큼 쉬고, 각 케이스는
 * 방출된 값이 그 대기를 **포함하는지**를 단언한다.
 *
 * 여섯 경로 **각각**에 케이스를 두는 이유: 게이트 `#5` 가 호출 하나씩 4번째 인자를 `0` 으로
 * 바꾼 뮤턴트를 여섯 번 돌린다. 한 경로만 덮으면 나머지 다섯이 비어도 이 spec 이 안 떨어지고,
 * 그러면 그 다섯 경로의 결손을 아무도 못 본다.
 */

/** 콜백이 끝난 뒤 트랜잭션 스텁이 쉬는 시간 — 커밋 대기를 흉내 낸다. */
const COMMIT_WAIT_MS = 40;

/**
 * 방출값이 넘어야 하는 하한. `COMMIT_WAIT_MS` 보다 낮게 잡는 이유는 타이머·반올림의
 * 오차뿐이며, `0`(뮤턴트)·콜백 안 계측(≈0)과는 여전히 큰 격차가 있다.
 */
const MIN_OBSERVED_MS = 25;

const TX = { __tx: true } as any;
TX.insert = () => ({ values: async () => undefined });

const ctx: AuditContext = {
  actor: {
    role: 'ADMIN',
    refId: 19,
    refLabel: 'admin-a',
    orgPath: null,
    personId: null,
  },
  requestId: 'req-duration',
  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,
};

/**
 * 감사 층은 **실물**을 쓴다. mock 하면 방출 진입점을 무동작화한 뮤턴트가 안 잡힌다 —
 * `money-audit-tx.spec.ts` 가 같은 이유로 같은 선택을 한다.
 */
function harness(over: Record<string, unknown> = {}) {
  const info = jest.fn();
  const audit = new AuditService({ setContext: jest.fn(), info } as any);
  const base: Record<string, unknown> = {
    //* **콜백이 끝난 뒤** 쉰다. 이 대기가 곧 커밋 대기이며, 시각 포착이 콜백 밖에
    //*  있어야만 방출값이 그것을 담는다.
    runInTransaction: async (fn: (tx: unknown) => unknown) => {
      const out = await fn(TX);
      await new Promise((r) => setTimeout(r, COMMIT_WAIT_MS));
      return out;
    },
    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,
    getChargeRequestById: async () => ({ ...REQUEST }),
    approveChargeRequest: async () => ({
      processed: true,
      balanceBefore: AGENCY.agcMoney,
      newBalance: AGENCY.agcMoney + REQUEST.mcrAmount,
    }),
    ...over,
  };
  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,
  );
  const moneyLines = () =>
    info.mock.calls
      .map((c) => (c[0] as any).audit)
      .filter((a) => a.tier === 'T1');
  return { service, moneyLines };
}

/** 엑셀 업로드 두 경로의 입력. 헤더 한 줄과 데이터 한 줄이면 파싱을 통과한다. */
async function xlsx(header: unknown[], row: unknown[]): Promise<any> {
  const wb = new Workbook();
  const sheet = wb.addWorksheet('s');
  sheet.addRow(header);
  sheet.addRow(row);
  return { buffer: Buffer.from(await wb.xlsx.writeBuffer()) };
}

/**
 * 한 경로의 계약 — 방출된 값이 커밋 대기를 담고, 형태가 정수 ms 다.
 *
 * 정수까지 보는 이유: 소비자(`scripts/audit-rate.mjs`)가 모집단을 정수로 한정한다.
 * 소수를 내보내면 모든 줄이 그 모집단 밖으로 빠져 축이 배포 뒤에도 영구히 비고,
 * 그 결손은 이 파일이 아니면 아무도 안 본다.
 */
function expectDuration(line: Record<string, any>) {
  expect(line).toBeDefined();
  expect(typeof line.txDurationMs).toBe('number');
  expect(Number.isInteger(line.txDurationMs)).toBe(true);
  expect(line.txDurationMs).toBeGreaterThanOrEqual(MIN_OBSERVED_MS);
}

describe('txDurationMs — 여섯 트랜잭션 경로가 커밋 대기를 담은 상한을 낸다', () => {
  it('money.issue — createIssue', async () => {
    const { service, moneyLines } = harness();
    await service.createIssue(
      19,
      {
        agcId: 5,
        mniTargetType: 'SPECIFIC',
        mniMemberType: 'NORMAL',
        memberIds: [MEMBER.memId],
        mniType: 'EARN',
        mniTitle: 'title',
        mniReason: null,
        reasonCode: 'CORRECTION',
        mniAmount: 1000,
      } as any,
      ctx,
    );
    expectDuration(moneyLines()[0]);
  });

  it('money.corp.charge — createCorpMoney', async () => {
    const { service, moneyLines } = harness();
    await service.createCorpMoney(
      19,
      { agcId: 5, mcpAmount: 10_000, mcpTitle: 't', mcpReason: null, reasonCode: 'CORRECTION' } as any,
      ctx,
    );
    expectDuration(moneyLines()[0]);
  });

  it('money.grant — createGrant', async () => {
    const { service, moneyLines } = harness();
    await service.createGrant(
      19,
      {
        agcId: 5,
        mngTargetType: 'SPECIFIC',
        memberIds: [MEMBER.memId],
        mngType: 'GRANT',
        mngTitle: 'title',
        mngReason: null,
        reasonCode: 'CORRECTION',
        mngAmount: 1000,
      } as any,
      ctx,
    );
    expectDuration(moneyLines()[0]);
  });

  it('money.grant.single — createGrantSingle', async () => {
    const { service, moneyLines } = harness();
    await service.createGrantSingle(
      19,
      {
        agcId: 5,
        memId: MEMBER.memId,
        mngAmount: 1000,
        mngTitle: 'title',
        mngReason: null,
        reasonCode: 'CORRECTION',
        mngHasExpiry: 0,
      } as any,
      ctx,
    );
    expectDuration(moneyLines()[0]);
  });

  it('money.issue.upload — uploadIssue', async () => {
    const { service, moneyLines } = harness();
    const file = await xlsx(
      ['구분', '회원아이디', '기업코드', '금액', '내용', '사유', '유효기간'],
      ['일반', MEMBER.memLoginId, '', 1000, '내용', '사유', ''],
    );
    await service.uploadIssue(19, file, 'CORRECTION', ctx);
    expectDuration(moneyLines()[0]);
  });

  it('money.grant.upload — uploadGrant', async () => {
    const { service, moneyLines } = harness();
    const file = await xlsx(
      ['회원아이디', '금액', '내용', '사유', '유효기간'],
      [MEMBER.memLoginId, 1000, '내용', '사유', ''],
    );
    await service.uploadGrant(19, file, 5, 'CORRECTION', ctx);
    expectDuration(moneyLines()[0]);
  });

  it('money.charge-request.approve — processChargeRequest', async () => {
    const { service, moneyLines } = harness();
    await service.processChargeRequest(
      19,
      { mcrId: REQUEST.mcrId, action: 'APPROVE', reasonCode: 'CORRECTION' } as any,
      ctx,
    );
    expectDuration(moneyLines()[0]);
  });
});

/**
 * **상한의 방향을 고정한다.** 위 여섯 케이스는 "커밋 대기를 담았다" 를 말하고, 이 케이스는
 * 그 대기가 늘면 값도 함께 는다는 것을 말한다 — 상수 하나를 돌려주는 구현과 가른다.
 *
 * 벽시계 대신 **가짜 단조 시계**를 쓴다. 실제 대기 두 번을 서로 비교하면 짧은 쪽이 스케줄러
 * 지터로 부풀 때 상한 없이 떨어진다 — 대기를 시계 전진으로 바꾸면 값이 정확히 결정된다.
 */
describe('txDurationMs — 커밋 대기가 길어지면 값도 커진다', () => {
  let now = 0n;
  beforeEach(() => {
    now = 0n;
    jest.spyOn(process.hrtime, 'bigint').mockImplementation(() => now);
  });
  afterEach(() => jest.restoreAllMocks());

  it('대기를 3배로 늘리면 방출값도 정확히 3배다', async () => {
    const withWait = async (waitMs: number) => {
      const { service, moneyLines } = harness({
        runInTransaction: async (fn: (tx: unknown) => unknown) => {
          const out = await fn(TX);
          now += BigInt(waitMs) * 1_000_000n;
          return out;
        },
      });
      await service.createCorpMoney(
        19,
        { agcId: 5, mcpAmount: 10_000, mcpTitle: 't', mcpReason: null, reasonCode: 'CORRECTION' } as any,
        ctx,
      );
      return moneyLines()[0].txDurationMs as number;
    };
    expect(await withWait(COMMIT_WAIT_MS)).toBe(COMMIT_WAIT_MS);
    expect(await withWait(COMMIT_WAIT_MS * 3)).toBe(COMMIT_WAIT_MS * 3);
  });
});

/**
 * D4b — 트랜잭션이 **없는** 계층은 `null` 이다. 이 경로는 `runInTransaction` 을 안 타므로
 * 이 파일의 상한 계약이 적용되지 않는다는 사실을 값으로 남긴다. `0` 을 적으면 *빨랐다* 로
 * 읽혀 위 여섯 경로의 분포에 섞인다.
 */
describe('txDurationMs — 트랜잭션이 없는 계층은 null 이다', () => {
  it('T4 만료 처리는 null 을 싣는다', async () => {
    const info = jest.fn();
    const audit = new AuditService({ setContext: jest.fn(), info } as any);
    const base: Record<string, unknown> = {
      getExpiredSources: async () => [
        {
          msrId: 88,
          memId: MEMBER.memId,
          msrMoneyType: 'BASIC',
          msrRemainingAmount: 3000,
        },
      ],
      getMemberByIdForUpdate: async () => ({ ...MEMBER }),
      getMemberById: async () => ({ ...MEMBER }),
      createWallet: async () => 6002,
    };
    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,
    );
    await service.expireMoneys();
    const t4 = info.mock.calls
      .map((c) => (c[0] as any).audit)
      .filter((a) => a.tier === 'T4');
    expect(t4.length).toBeGreaterThan(0);
    for (const line of t4) expect(line.txDurationMs).toBeNull();
  });
});
