import { auditContext } from '../audit/audit-actor';
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { ClassValidatorPipe } from '@api/pipes/validation.pipe';
import { AdminController } from './admin.controller';
import { UpdateAdminDto } from './admin.dto';
import { AdminRepository } from './admin.repository';
import { AdminService } from './admin.service';

//* 가짜 리포지터리 — 쓰기 호출을 기록한다. "거부했다" 는 주장은 쓰기 0회로만 증명된다.
const ROWS = [
  { admin: { admId: 1, admLoginId: 'rsteam' }, auth: { athId: 1 } },
  { admin: { admId: 2, admLoginId: 'other' }, auth: { athId: 1 } },
];
const fakeRepo = () => ({
  whereClause: jest.fn(async () => undefined),
  orderByClause: jest.fn(async () => undefined),
  getList: jest.fn(async () => ROWS),
  count: jest.fn(async () => ROWS.length),
  getDetail: jest.fn(async () => ROWS[0]),
  create: jest.fn(async () => undefined),
  update: jest.fn(async () => undefined),
  delete: jest.fn(async () => undefined),
});

const DESIGNATED = (admId: number) => ({
  state: 'designated' as const,
  admId,
  loginId: 'rsteam',
  createdAt: '2026-01-02T03:04:05',
});
/** 가짜 판정 서비스 — 지정 행과 "요청자가 최고관리자인가" 를 따로 준다. */
const fakeSuper = (designation: unknown, superIds: number[] = []) => ({
  readDesignation: jest.fn(async () => {
    if (designation instanceof Error) throw designation;
    return designation as any;
  }),
  isSuperAdmin: jest.fn(async (a: any) => superIds.includes(Number(a?.admId))),
});

const build = (designation: unknown, superIds: number[] = []) => {
  const repo = fakeRepo();
  const superAdmin = fakeSuper(designation, superIds);
  return {
    repo,
    superAdmin,
    service: new AdminService(repo as any, superAdmin as any),
  };
};

//* 감사 맥락은 이 스펙의 축이 아니다 — 최고관리자 보호만 본다. 값은 한 번 만들어 전건에 같은 것을
//*   넘기고, 저장소 호출 단언도 그 값을 그대로 확인한다(인자를 잃으면 감사 행이 비므로 축이 아니어도 잰다).
const CTX = auditContext({} as any);

const EDIT = { athId: 1, admName: 'x', admPhone: '01012345678', admEmail: '' };
const codeOf = (e: unknown) => (e as ForbiddenException).getResponse() as any;

describe('최고관리자 계정 보호', () => {
  it('protect: 최고관리자가 아닌 관리자가 최고관리자 계정을 수정하면 E0827 로 거부하고 쓰지 않는다', async () => {
    const { repo, service } = build(DESIGNATED(1), []);
    const err = await service
      .updateAdmin(1, EDIT as any, { role: 'admin', admId: 2 }, CTX)
      .catch((e) => e);

    expect(err).toBeInstanceOf(ForbiddenException);
    expect(codeOf(err).code).toBe('E0827');
    expect(repo.update).toHaveBeenCalledTimes(0);
  });

  it('protect: 최고관리자가 아닌 관리자가 최고관리자 계정을 섞어 삭제하면 E0827 로 거부하고 지우지 않는다', async () => {
    const { repo, service } = build(DESIGNATED(1), []);
    const err = await service
      .deleteAdmin({ ids: [2, 1] } as any, { role: 'admin', admId: 2 }, CTX)
      .catch((e) => e);

    expect(err).toBeInstanceOf(ForbiddenException);
    expect(codeOf(err).code).toBe('E0827');
    expect(repo.delete).toHaveBeenCalledTimes(0);
  });

  it('protect: 지정 행은 최고관리자 본인이 지우려 해도 E0828 로 막는다', async () => {
    //* 공용 계정 1개라 "최고관리자만 지울 수 있다" 는 곧 "자기를 지운다" 이고, 그러면 지정을
    //*   되살릴 화면 경로가 없다. 섞어 삭제도 같다 — 목록에 들어 있기만 하면 요청 전체가 거부된다.
    for (const ids of [[1], [2, 1]]) {
      const { repo, service } = build(DESIGNATED(1), [1]);
      const err = await service
        .deleteAdmin({ ids } as any, { role: 'admin', admId: 1 }, CTX)
        .catch((e) => e);

      expect(err).toBeInstanceOf(ForbiddenException);
      expect(codeOf(err).code).toBe('E0828');
      expect(repo.delete).toHaveBeenCalledTimes(0);
    }
  });

  it('protect: 최고관리자는 자기 아닌 계정을 지울 수 있다', async () => {
    const { repo, service } = build(DESIGNATED(1), [1]);
    await service.deleteAdmin({ ids: [2, 3] } as any, { role: 'admin', admId: 1 }, CTX);
    expect(repo.delete).toHaveBeenCalledWith([2, 3], CTX);
  });

  it('protect: 최고관리자는 최고관리자 계정을 수정할 수 있다', async () => {
    const { repo, service } = build(DESIGNATED(1), [1]);
    await service.updateAdmin(1, EDIT as any, { role: 'admin', admId: 1 }, CTX);
    expect(repo.update).toHaveBeenCalledTimes(1);
  });

  it('protect: 최고관리자가 아닌 계정의 수정·삭제는 요청자와 무관하게 그대로 된다', async () => {
    const { repo, superAdmin, service } = build(DESIGNATED(1), []);
    await service.updateAdmin(2, EDIT as any, { role: 'admin', admId: 3 }, CTX);
    await service.deleteAdmin({ ids: [2, 3] } as any, { role: 'admin', admId: 3 }, CTX);

    expect(repo.update).toHaveBeenCalledTimes(1);
    expect(repo.delete).toHaveBeenCalledTimes(1);
    //* 보호 대상이 아니면 요청자 판정을 **묻지 않는다** — 관리자 관리에 DB 왕복을 더 늘리지 않는다.
    expect(superAdmin.isSuperAdmin).toHaveBeenCalledTimes(0);
  });

  it('protect: 표시 칸이 없거나 지정 행이 없으면 보호 대상이 없어 수정이 그대로 된다', async () => {
    for (const d of [{ state: 'no-column' as const }, { state: 'none' as const }]) {
      const { repo, service } = build(d, []);
      await service.updateAdmin(1, EDIT as any, { role: 'admin', admId: 2 }, CTX);
      expect(repo.update).toHaveBeenCalledTimes(1);
    }
  });

  it('protect: 지정 조회가 칸 없음 밖의 이유로 실패하면 수정·삭제를 쓰지 않고 실패한다', async () => {
    const boom = Object.assign(new Error('read ECONNRESET'), {
      code: 'ECONNRESET',
    });
    const { repo, service } = build(boom, [1]);

    await expect(
      service.updateAdmin(1, EDIT as any, { role: 'admin', admId: 1 }, CTX),
    ).rejects.toBe(boom);
    await expect(
      service.deleteAdmin({ ids: [1] } as any, { role: 'admin', admId: 1 }, CTX),
    ).rejects.toBe(boom);
    expect(repo.update).toHaveBeenCalledTimes(0);
    expect(repo.delete).toHaveBeenCalledTimes(0);
  });

  it('wiring: 수정·삭제 핸들러가 요청자 신원을 서비스에 넘긴다', async () => {
    //* 경로 번호는 **문자열**로 온다 — 신원이 핸들러에서 보호 한 자리까지 실제로 가는지를 이 형으로 본다.
    const denied = build(DESIGNATED(1), []);
    const deniedCtl = new AdminController(denied.service);
    const err = await deniedCtl
      .updateAdmin('1' as any, EDIT as any, {
        admin: { role: 'admin', admId: 2 },
      } as any)
      .catch((e) => e);
    expect(codeOf(err).code).toBe('E0827');
    const delErr = await deniedCtl
      .deleteAdmin({ ids: ['1'] } as any, {
        admin: { role: 'admin', admId: 2 },
      } as any)
      .catch((e) => e);
    expect(codeOf(delErr).code).toBe('E0827');
    expect(denied.repo.update).toHaveBeenCalledTimes(0);
    expect(denied.repo.delete).toHaveBeenCalledTimes(0);

    const allowed = build(DESIGNATED(1), [1]);
    const allowedCtl = new AdminController(allowed.service);
    await allowedCtl.updateAdmin('1' as any, EDIT as any, {
      admin: { role: 'admin', admId: 1 },
    } as any);
    //* 삭제는 지정 행 밖을 쓴다 — 지정 행은 요청자와 무관하게 E0828 이다(위 protect 케이스).
    await allowedCtl.deleteAdmin({ ids: ['2'] } as any, {
      admin: { role: 'admin', admId: 1 },
    } as any);
    expect(allowed.repo.update).toHaveBeenCalledTimes(1);
    expect(allowed.repo.delete).toHaveBeenCalledTimes(1);
  });
});

describe('최고관리자 표시와 입력', () => {
  it('display: 목록과 상세는 지정 행에만 admIsSuper 참을 싣고 지정 조회가 실패하면 전부 거짓으로 선다', async () => {
    const ok = build(DESIGNATED(1), []);
    const list = await ok.service.getAdminList({ page: 1 } as any);
    expect(list.list.map((r: any) => r.admin.admIsSuper)).toEqual([true, false]);
    expect((await ok.service.getDetail(1))!.admin.admIsSuper).toBe(true);

    const none = build({ state: 'none' as const }, []);
    expect(
      (await none.service.getAdminList({ page: 1 } as any)).list.map(
        (r: any) => r.admin.admIsSuper,
      ),
    ).toEqual([false, false]);

    //* 조회가 깨져도 목록은 선다 — 표시는 화장이고 보호와 달리 던지지 않는다.
    const broken = build(new Error('read ECONNRESET'), []);
    const brokenList = await broken.service.getAdminList({ page: 1 } as any);
    expect(brokenList.list.map((r: any) => r.admin.admIsSuper)).toEqual([
      false,
      false,
    ]);
    expect((await broken.service.getDetail(1))!.admin.admIsSuper).toBe(false);
  });

  it('dto: 수정 본문의 표시 칸은 파이프가 거부하고 생성·수정 쓰기는 표시 칸을 쓰지 않는다', async () => {
    const pipe = new ClassValidatorPipe();
    const meta = { type: 'body' as const, metatype: UpdateAdminDto };
    //* 대조 — 칸이 없는 같은 본문은 통과한다. 이게 없으면 아래 400 이 "본문이 원래 틀렸다" 와 구분되지 않는다.
    await expect(pipe.transform({ ...EDIT }, meta)).resolves.toBeDefined();
    await expect(
      pipe.transform({ ...EDIT, admIsSuper: 1 }, meta),
    ).rejects.toBeInstanceOf(BadRequestException);

    //* 쓰기 축 — 리포지터리가 실제로 만드는 values·set 키에 표시 칸이 없다.
    const captured: Record<string, unknown>[] = [];
    //* 감사 병합 이후 생성·수정은 **트랜잭션 안**에서 돌고 생성은 `$returningId()` 를 읽는다.
    //*   더블이 그 모양을 따라가지 않으면 이 it 은 축(표시 칸 부재)에 닿기 전에 죽는다.
    const drizzle: any = {
      transaction: async (cb: any) => await cb(drizzle),
      select: () => ({
        from: () => ({ where: () => ({ limit: async () => [{ athId: 1 }] }) }),
      }),
      insert: () => ({
        values: (v: any) => {
          captured.push(v);
          return { $returningId: async () => [{ admId: 1 }] };
        },
      }),
      update: () => ({
        set: (v: any) => {
          captured.push(v);
          return { where: async () => undefined };
        },
      }),
    };
    //* 감사 서비스는 표에 닿지 않는 더블이다 — 이 it 의 축은 **쓰기 본문에 표시 칸이 실리지 않는다**
    //*   하나뿐이고, 감사 행은 같은 트랜잭션의 다른 문장이라 그 축을 지나지 않는다.
    const repo = new AdminRepository(drizzle, { get: () => 'k' } as any, {
      appendSecurity: async () => {},
    } as any);
    await repo.create(
      {
        athId: 1,
        admLoginId: 'new',
        admPassword: 'Passw0rd!',
        admName: 'n',
        admPhone: '01000000000',
        admEmail: '',
      } as any,
      CTX,
    );
    //* 실 `update` 는 admPassword 가 undefined 면 bcrypt 에서 던진다(기존 동작 — 화면은 늘 빈 문자열을 보낸다).
    await repo.update(1, { ...EDIT, admPassword: '' } as any, CTX);

    expect(captured).toHaveLength(2);
    for (const written of captured) {
      expect(Object.keys(written)).not.toContain('admIsSuper');
      expect(Object.keys(written)).not.toContain('adm_is_super');
    }
  });
});
