import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import type { Server } from 'node:http';
import request from 'supertest';
import { RolesGuard } from '@/casl/roles.guard';
import { JwtAuthGuard } from '@/modules/auth/jwt-auth.guard';
import { PrismaService } from '@/prisma/prisma.service';
import { MobileSystemNotificationsController } from './mobile-system-notifications.controller';
import { MobileSystemNotificationsService } from './mobile-system-notifications.service';
import { SystemNotificationsController } from './system-notifications.controller';
import { SystemNotificationsService } from './system-notifications.service';

jest.mock('@/generated/prisma/client', () => ({}));
jest.mock('@/prisma/prisma.service', () => ({ PrismaService: class {} }));
jest.mock('@/casl/roles.guard', () => ({ RolesGuard: class {} }));
jest.mock('@/modules/auth/jwt-auth.guard', () => ({ JwtAuthGuard: class {} }));

type Row = {
  id: number;
  receiverUserId: number | null;
  receiverStakeholderId: number | null;
  createdAt: Date;
  deletedAt: Date | null;
  isRead: boolean;
  readAt: Date | null;
};
type View = { notificationId: number; userId: number; viewedAt: Date };
let views: View[];
type Where = {
  AND?: Where[];
  OR?: Where[];
  id?: number;
  receiverUserId?: number;
  receiverStakeholderId?: { in: number[] };
  createdAt?: { gt: Date };
  deletedAt?: null;
  isRead?: boolean;
  mobileViews?: { none: { userId: number; viewedAt: { lte: Date } } };
};

// In-memory DB adapter: exercise real controllers and services without
// connecting to or modifying the application's shared database.
function matches(row: Row, where: Where): boolean {
  return (
    (!where.AND || where.AND.every((part) => matches(row, part))) &&
    (!where.OR || where.OR.some((part) => matches(row, part))) &&
    (where.id === undefined || row.id === where.id) &&
    (where.receiverUserId === undefined ||
      row.receiverUserId === where.receiverUserId) &&
    (!where.receiverStakeholderId ||
      where.receiverStakeholderId.in.includes(
        row.receiverStakeholderId ?? -1,
      )) &&
    (!where.createdAt || row.createdAt > where.createdAt.gt) &&
    (!where.mobileViews ||
      !views.some(
        (view) =>
          view.notificationId === row.id &&
          view.userId === where.mobileViews!.none.userId &&
          view.viewedAt <= where.mobileViews!.none.viewedAt.lte,
      )) &&
    (where.deletedAt === undefined || row.deletedAt === null) &&
    (where.isRead === undefined || row.isRead === where.isRead)
  );
}

describe('Mobile 7-day retention and MIS isolation over HTTP', () => {
  const now = new Date('2026-10-07T05:00:00Z');
  const cutoff = now.getTime() - 7 * 24 * 60 * 60 * 1000;
  const mobile = '/api/v1/mobile/system-notifications';
  const mis = '/api/v1/system-notifications';
  let app: INestApplication<Server>;
  let rows: Row[];
  const db = {
    mobileNotificationView: {
      createMany: jest.fn(({ data: [create] }: { data: View[] }) => {
        const existing = views.find(
          (view) =>
            view.notificationId === create.notificationId &&
            view.userId === create.userId,
        );
        if (existing) return Promise.resolve({ count: 0 });
        views.push(create);
        return Promise.resolve({ count: 1 });
      }),
      findUnique: jest.fn(
        ({
          where: { notificationId_userId: key },
        }: {
          where: {
            notificationId_userId: { notificationId: number; userId: number };
          };
        }) =>
          Promise.resolve(
            views.find(
              (view) =>
                view.notificationId === key.notificationId &&
                view.userId === key.userId,
            ) ?? null,
          ),
      ),
    },
    stakeholderUser: {
      findMany: jest.fn().mockResolvedValue([{ stakeholderId: 3 }]),
    },
    systemNotification: {
      findMany: jest.fn(
        ({
          where,
          skip = 0,
          take = 20,
        }: {
          where: Where;
          skip?: number;
          take?: number;
        }) =>
          Promise.resolve(
            rows
              .filter((row) => matches(row, where))
              .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
              .slice(skip, skip + take),
          ),
      ),
      count: jest.fn(({ where }: { where: Where }) =>
        Promise.resolve(rows.filter((row) => matches(row, where)).length),
      ),
      findFirst: jest.fn(({ where }: { where: Where }) =>
        Promise.resolve(rows.find((row) => matches(row, where)) ?? null),
      ),
      update: jest.fn(
        ({ where, data }: { where: { id: number }; data: Partial<Row> }) => {
          const row = rows.find((item) => item.id === where.id)!;
          Object.assign(row, data);
          return Promise.resolve(row);
        },
      ),
      updateMany: jest.fn(
        ({ where, data }: { where: Where; data: Partial<Row> }) => {
          const selected = rows.filter((row) => matches(row, where));
          selected.forEach((row) => Object.assign(row, data));
          return Promise.resolve({ count: selected.length });
        },
      ),
    },
    $transaction: jest.fn((queries: Promise<unknown>[]) =>
      Promise.all(queries),
    ),
  };

  beforeAll(async () => {
    const module = await Test.createTestingModule({
      controllers: [
        MobileSystemNotificationsController,
        SystemNotificationsController,
      ],
      providers: [
        MobileSystemNotificationsService,
        SystemNotificationsService,
        { provide: PrismaService, useValue: db },
      ],
    })
      .overrideGuard(JwtAuthGuard)
      .useValue({ canActivate: () => true })
      .overrideGuard(RolesGuard)
      .useValue({ canActivate: () => true })
      .compile();
    app = module.createNestApplication();
    app.setGlobalPrefix('api/v1');
    app.use(
      (
        req: { headers: Record<string, unknown>; user?: { id: number } },
        _res: unknown,
        next: () => void,
      ) => {
        if (req.headers.authorization === 'Bearer test-user')
          req.user = { id: 5 };
        if (req.headers.authorization === 'Bearer second-user')
          req.user = { id: 6 };
        next();
      },
    );
    app.useGlobalPipes(
      new ValidationPipe({
        whitelist: true,
        forbidNonWhitelisted: true,
        transform: true,
      }),
    );
    await app.init();
  });

  beforeEach(() => {
    jest.spyOn(Date, 'now').mockReturnValue(now.getTime());
    jest.clearAllMocks();
    views = [
      { notificationId: 1, userId: 5, viewedAt: new Date(cutoff + 1) },
      { notificationId: 2, userId: 5, viewedAt: new Date(cutoff) },
      { notificationId: 3, userId: 5, viewedAt: new Date(cutoff - 1) },
    ];
    rows = [
      { id: 1, receiverUserId: 5, createdAt: new Date(cutoff + 1) },
      { id: 2, receiverUserId: 5, createdAt: new Date(cutoff) },
      { id: 3, receiverUserId: 5, createdAt: new Date(cutoff - 1) },
      { id: 4, receiverUserId: 99, createdAt: now },
      { id: 5, receiverUserId: null, receiverStakeholderId: 3, createdAt: now },
      { id: 6, receiverUserId: 5, createdAt: now, isRead: true },
      { id: 7, receiverUserId: 5, createdAt: now, deletedAt: now },
    ].map((row) => ({
      receiverStakeholderId: null,
      deletedAt: null,
      isRead: false,
      readAt: null,
      ...row,
    }));
  });
  afterEach(() => jest.restoreAllMocks());
  afterAll(async () => {
    await app.close();
  });

  it('expires exactly seven days after viewing while preserving shared MIS records', async () => {
    await request(app.getHttpServer())
      .get(mobile)
      .auth('test-user', { type: 'bearer' })
      .expect(200, {
        data: [rows[4], rows[5], rows[0]].map(
          (row) => JSON.parse(JSON.stringify(row)) as unknown,
        ),
        meta: { page: 1, limit: 20, total: 3, unreadCount: 2, totalPages: 1 },
      });
    await request(app.getHttpServer())
      .get(`${mis}/unread-count`)
      .auth('test-user', { type: 'bearer' })
      .expect(200, { count: 4 });
    expect(rows[1].deletedAt).toBeNull();
    expect(rows[2].deletedAt).toBeNull();
    expect(db.systemNotification.update).not.toHaveBeenCalled();
    expect(db.systemNotification.updateMany).not.toHaveBeenCalled();
  });

  it('uses the same expiry policy for the unread badge and pagination', async () => {
    await request(app.getHttpServer())
      .get(`${mobile}/unread-count`)
      .auth('test-user', { type: 'bearer' })
      .expect(200, { count: 2 });
    const response = await request(app.getHttpServer())
      .get(`${mobile}?page=2&limit=2&isRead=false`)
      .auth('test-user', { type: 'bearer' })
      .expect(200);
    expect(response.body).toEqual({
      data: [],
      meta: { page: 2, limit: 2, total: 2, unreadCount: 2, totalPages: 1 },
    });
  });

  it('denies expired, deleted, and another recipient details without affecting MIS access', async () => {
    for (const id of [2, 3, 4, 7]) {
      await request(app.getHttpServer())
        .get(`${mobile}/${id}`)
        .auth('test-user', { type: 'bearer' })
        .expect(404);
    }
    await request(app.getHttpServer())
      .get(`${mobile}/1`)
      .auth('test-user', { type: 'bearer' })
      .expect(200);
    await request(app.getHttpServer())
      .get(`${mis}/2`)
      .auth('test-user', { type: 'bearer' })
      .expect(200);
  });

  it('rejects expired individual read/delete actions', async () => {
    await request(app.getHttpServer())
      .patch(`${mobile}/2/read`)
      .auth('test-user', { type: 'bearer' })
      .expect(404);
    await request(app.getHttpServer())
      .delete(`${mobile}/3`)
      .auth('test-user', { type: 'bearer' })
      .expect(404);
    expect(db.systemNotification.update).not.toHaveBeenCalled();
    expect(db.mobileNotificationView.createMany).not.toHaveBeenCalled();
  });

  it('marks only current mobile notifications read through the bulk route', async () => {
    await request(app.getHttpServer())
      .patch(`${mobile}/read-all`)
      .auth('test-user', { type: 'bearer' })
      .expect(200, { updated: 2 });
    expect(rows[0].isRead).toBe(true);
    expect(rows[4].isRead).toBe(true);
    expect(rows[1].isRead).toBe(false);
    expect(rows[2].isRead).toBe(false);
    expect(rows[3].isRead).toBe(false);
    expect(db.mobileNotificationView.createMany).not.toHaveBeenCalled();
  });

  it('bulk deletion leaves expired MIS notifications intact', async () => {
    await request(app.getHttpServer())
      .delete(`${mobile}/all`)
      .auth('test-user', { type: 'bearer' })
      .expect(200, { deleted: 3 });
    expect(rows[0].deletedAt).toBeInstanceOf(Date);
    expect(rows[1].deletedAt).toBeNull();
    expect(rows[2].deletedAt).toBeNull();
    expect(rows[3].deletedAt).toBeNull();
  });

  it('rejects missing user, invalid query parameters, and nonnumeric IDs', async () => {
    await request(app.getHttpServer()).get(mobile).expect(401);
    await request(app.getHttpServer())
      .get(`${mobile}?limit=101`)
      .auth('test-user', { type: 'bearer' })
      .expect(400);
    await request(app.getHttpServer())
      .get(`${mobile}/invalid`)
      .auth('test-user', { type: 'bearer' })
      .expect(400);
    expect(db.systemNotification.findMany).not.toHaveBeenCalled();
    expect(db.systemNotification.findFirst).not.toHaveBeenCalled();
  });

  it('keeps old unopened notifications and starts the timer only on mobile View Detail', async () => {
    rows.push({
      ...rows[0],
      id: 8,
      createdAt: new Date('2025-01-01T00:00:00Z'),
    });
    const list = await request(app.getHttpServer())
      .get(mobile)
      .auth('test-user', { type: 'bearer' })
      .expect(200);
    expect((list.body as { data: Row[] }).data.map((row) => row.id)).toContain(
      8,
    );
    await request(app.getHttpServer())
      .get(`${mis}/8`)
      .auth('test-user', { type: 'bearer' })
      .expect(200);
    await request(app.getHttpServer())
      .patch(`${mobile}/8/read`)
      .auth('test-user', { type: 'bearer' })
      .expect(200);
    expect(views.some((view) => view.notificationId === 8)).toBe(false);
    await request(app.getHttpServer())
      .get(`${mobile}/8`)
      .auth('test-user', { type: 'bearer' })
      .expect(200);
    expect(views.find((view) => view.notificationId === 8)?.viewedAt).toEqual(
      now,
    );
    expect(rows.find((row) => row.id === 8)?.deletedAt).toBeNull();
  });

  it('does not restart the timer on repeated detail views and expires exactly at 168 hours', async () => {
    await request(app.getHttpServer())
      .get(`${mobile}/5`)
      .auth('test-user', { type: 'bearer' })
      .expect(200);
    jest
      .spyOn(Date, 'now')
      .mockReturnValue(now.getTime() + 3 * 24 * 60 * 60 * 1000);
    await request(app.getHttpServer())
      .get(`${mobile}/5`)
      .auth('test-user', { type: 'bearer' })
      .expect(200);
    expect(views.find((view) => view.notificationId === 5)?.viewedAt).toEqual(
      now,
    );
    jest
      .spyOn(Date, 'now')
      .mockReturnValue(now.getTime() + 7 * 24 * 60 * 60 * 1000);
    await request(app.getHttpServer())
      .get(`${mobile}/5`)
      .auth('test-user', { type: 'bearer' })
      .expect(404);
    await request(app.getHttpServer())
      .get(`${mis}/5`)
      .auth('test-user', { type: 'bearer' })
      .expect(200);
  });

  it('tracks detail views separately for users receiving the same stakeholder notification', async () => {
    views.push({ notificationId: 5, userId: 6, viewedAt: new Date(cutoff) });
    await request(app.getHttpServer())
      .get(`${mobile}/5`)
      .auth('second-user', { type: 'bearer' })
      .expect(404);
    await request(app.getHttpServer())
      .get(`${mobile}/5`)
      .auth('test-user', { type: 'bearer' })
      .expect(200);
    expect(views.filter((view) => view.notificationId === 5)).toHaveLength(2);
    expect(rows[4].isRead).toBe(false);
    expect(rows[4].deletedAt).toBeNull();
  });

  it('stores only one first-view time when detail requests arrive together', async () => {
    await Promise.all([
      request(app.getHttpServer())
        .get(`${mobile}/5`)
        .auth('test-user', { type: 'bearer' })
        .expect(200),
      request(app.getHttpServer())
        .get(`${mobile}/5`)
        .auth('test-user', { type: 'bearer' })
        .expect(200),
    ]);
    expect(
      views.filter((view) => view.notificationId === 5 && view.userId === 5),
    ).toHaveLength(1);
    expect(db.mobileNotificationView.createMany).toHaveBeenCalledWith({
      data: [{ notificationId: 5, userId: 5, viewedAt: now }],
      skipDuplicates: true,
    });
  });
});
