import type { NextFunction, Request, Response } from 'express';
import type { Logger } from 'nestjs-pino';
import type { AuditWriter } from '../audit/audit.service';
import { createAbsoluteTimeoutMiddleware } from './absolute-timeout.middleware';
import type { UserSessionIndexService } from './user-session-index.service';

const NOW = 1_000_000_000_000; // arbitrary stable epoch ms

interface SessionStub {
  user?: {
    oid: string;
    tid: string;
    username: string;
    displayName: string;
    amr: readonly string[];
  };
  createdAt?: number;
  absoluteExpiresAt?: number;
  destroy: jest.Mock;
}

function makeSession(opts?: Partial<SessionStub>): SessionStub {
  return {
    destroy: jest.fn((cb: (err?: Error) => void) => cb()),
    ...opts,
  };
}

function makeReq(session?: SessionStub, sessionID = 'session-abc'): Request {
  return { session, sessionID } as unknown as Request;
}

function makeRes() {
  return {
    clearCookie: jest.fn().mockReturnThis(),
  } as unknown as Response & { clearCookie: jest.Mock };
}

function makeIndex(): UserSessionIndexService & { add: jest.Mock; remove: jest.Mock } {
  return {
    add: jest.fn().mockResolvedValue(undefined),
    remove: jest.fn().mockResolvedValue(undefined),
  } as unknown as UserSessionIndexService & { add: jest.Mock; remove: jest.Mock };
}

function makeAudit(): AuditWriter & { sessionExpired: jest.Mock } {
  return {
    sessionExpired: jest.fn().mockResolvedValue(undefined),
  } as unknown as AuditWriter & { sessionExpired: jest.Mock };
}

function makeLogger() {
  return {
    log: jest.fn(),
    warn: jest.fn(),
    error: jest.fn(),
    debug: jest.fn(),
  } as unknown as Logger & {
    log: jest.Mock;
    warn: jest.Mock;
    error: jest.Mock;
  };
}

const USER = {
  oid: 'user-oid',
  tid: 'tenant-id',
  username: 'jane@apf.example',
  displayName: 'Jane Doe',
  amr: ['pwd', 'mfa'],
  roles: [],
};

describe('absoluteTimeout middleware', () => {
  it('passes through when there is no session at all', async () => {
    const middleware = createAbsoluteTimeoutMiddleware(
      makeIndex(),
      makeLogger(),
      makeAudit(),
      () => NOW,
    );
    const req = makeReq(undefined);
    const res = makeRes();
    const next: NextFunction = jest.fn();

    await middleware(req, res, next);

    expect(next).toHaveBeenCalledTimes(1);
    expect(res.clearCookie).not.toHaveBeenCalled();
  });

  it('passes through when the session has no user (anonymous request hit a session-touching route)', async () => {
    const session = makeSession();
    const middleware = createAbsoluteTimeoutMiddleware(
      makeIndex(),
      makeLogger(),
      makeAudit(),
      () => NOW,
    );
    const res = makeRes();
    const next: NextFunction = jest.fn();

    await middleware(makeReq(session), res, next);

    expect(next).toHaveBeenCalledTimes(1);
    expect(session.destroy).not.toHaveBeenCalled();
  });

  it('passes through when absoluteExpiresAt is unset (pre-hardening session)', async () => {
    const session = makeSession({ user: USER });
    const middleware = createAbsoluteTimeoutMiddleware(
      makeIndex(),
      makeLogger(),
      makeAudit(),
      () => NOW,
    );
    const res = makeRes();
    const next: NextFunction = jest.fn();

    await middleware(makeReq(session), res, next);

    expect(next).toHaveBeenCalledTimes(1);
    expect(session.destroy).not.toHaveBeenCalled();
  });

  it('passes through when the session is still within the ceiling', async () => {
    const session = makeSession({
      user: USER,
      createdAt: NOW - 60_000,
      absoluteExpiresAt: NOW + 60_000,
    });
    const middleware = createAbsoluteTimeoutMiddleware(
      makeIndex(),
      makeLogger(),
      makeAudit(),
      () => NOW,
    );
    const res = makeRes();
    const next: NextFunction = jest.fn();

    await middleware(makeReq(session), res, next);

    expect(next).toHaveBeenCalledTimes(1);
    expect(session.destroy).not.toHaveBeenCalled();
    expect(res.clearCookie).not.toHaveBeenCalled();
  });

  it('destroys the session, removes from the index, clears the cookie, audits expiry, and still calls next() when past the ceiling', async () => {
    const session = makeSession({
      user: USER,
      createdAt: NOW - 13 * 60 * 60 * 1000,
      absoluteExpiresAt: NOW - 60 * 60 * 1000,
    });
    const index = makeIndex();
    const logger = makeLogger();
    const audit = makeAudit();
    const middleware = createAbsoluteTimeoutMiddleware(index, logger, audit, () => NOW);
    const res = makeRes();
    const next: NextFunction = jest.fn();

    await middleware(makeReq(session, 'session-abc'), res, next);

    expect(session.destroy).toHaveBeenCalledTimes(1);
    expect(index.remove).toHaveBeenCalledWith(USER.oid, 'session-abc');
    expect(res.clearCookie).toHaveBeenCalledWith('portal_session', { path: '/' });
    expect(res.clearCookie).toHaveBeenCalledWith('portal_csrf', { path: '/' });
    expect(audit.sessionExpired).toHaveBeenCalledWith({
      actor: { oid: USER.oid },
      sessionId: 'session-abc',
      reason: 'absolute',
      ageMs: 13 * 60 * 60 * 1000,
    });
    expect(logger.log).toHaveBeenCalledWith(
      expect.objectContaining({
        event: 'session.absolute_timeout',
        userId: USER.oid,
        sessionId: 'session-abc',
      }),
      'AbsoluteTimeout',
    );
    expect(next).toHaveBeenCalledTimes(1);
  });

  it('logs but does not throw when destroy() fails — still clears cookie + index + audits + calls next()', async () => {
    const session = makeSession({
      user: USER,
      createdAt: NOW - 13 * 60 * 60 * 1000,
      absoluteExpiresAt: NOW - 60 * 60 * 1000,
    });
    session.destroy.mockImplementationOnce((cb: (err?: Error) => void) =>
      cb(new Error('redis down')),
    );
    const index = makeIndex();
    const logger = makeLogger();
    const audit = makeAudit();
    const middleware = createAbsoluteTimeoutMiddleware(index, logger, audit, () => NOW);
    const res = makeRes();
    const next: NextFunction = jest.fn();

    await middleware(makeReq(session, 'session-abc'), res, next);

    expect(logger.error).toHaveBeenCalledWith(
      expect.objectContaining({
        event: 'session.absolute_timeout.destroy_failed',
        sessionId: 'session-abc',
      }),
      'AbsoluteTimeout',
    );
    expect(index.remove).toHaveBeenCalledWith(USER.oid, 'session-abc');
    expect(audit.sessionExpired).toHaveBeenCalled();
    expect(res.clearCookie).toHaveBeenCalled();
    expect(next).toHaveBeenCalledTimes(1);
  });
});
