import type { Request, Response } from 'express';
import type { Logger } from 'nestjs-pino';
import type { AuditWriter } from '../audit/audit.service';
import type { UserSessionIndexService } from '../session/user-session-index.service';
import type { UserDirectoryService } from '../users/user-directory.service';
import type { AuthenticatedUser } from './auth.service';
import { SessionEstablisher } from './session-establisher.service';

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

function makeReqStub(opts?: { sessionID?: string; sessionUser?: AuthenticatedUser }): Request {
  const session: Record<string, unknown> = {
    save: jest.fn((cb: (err?: Error) => void) => cb()),
    destroy: jest.fn((cb: (err?: Error) => void) => cb()),
  };
  if (opts?.sessionUser !== undefined) {
    session['user'] = opts.sessionUser;
  }
  return {
    session,
    sessionID: opts?.sessionID ?? 'sid-test',
  } as unknown as Request;
}

function makeResStub(): Response & { cookie: jest.Mock; clearCookie: jest.Mock } {
  const res = {
    cookie: jest.fn(),
    clearCookie: jest.fn(),
  };
  res.cookie.mockReturnValue(res);
  res.clearCookie.mockReturnValue(res);
  return res as unknown as Response & typeof res;
}

function makeLoggerStub() {
  return {
    log: jest.fn(),
    warn: jest.fn(),
    error: jest.fn(),
  };
}

interface Fixture {
  est: SessionEstablisher;
  index: { add: jest.Mock; remove: jest.Mock; list: jest.Mock };
  audit: { signIn: jest.Mock; signOut: jest.Mock };
  directory: { recordSignIn: jest.Mock };
  logger: ReturnType<typeof makeLoggerStub>;
}

function makeFixture(): Fixture {
  const index = {
    add: jest.fn().mockResolvedValue(undefined),
    remove: jest.fn().mockResolvedValue(undefined),
    list: jest.fn().mockResolvedValue([]),
  };
  const audit = {
    signIn: jest.fn().mockResolvedValue(undefined),
    signOut: jest.fn().mockResolvedValue(undefined),
  };
  const directory = {
    recordSignIn: jest.fn().mockResolvedValue(undefined),
  };
  const logger = makeLoggerStub();
  const est = new SessionEstablisher(
    logger as unknown as Logger,
    index as unknown as UserSessionIndexService,
    audit as unknown as AuditWriter,
    directory as unknown as UserDirectoryService,
  );
  return { est, index, audit, directory, logger };
}

describe('SessionEstablisher.establish', () => {
  it('writes user + createdAt + absoluteExpiresAt + csrfToken + mfaVerifiedAt on the session', async () => {
    const { est } = makeFixture();
    const req = makeReqStub();
    const res = makeResStub();
    const before = Date.now();
    await est.establish({ user: USER, req, res, surface: 'user' });
    const after = Date.now();
    const sess = (req as unknown as { session: Record<string, unknown> }).session;
    expect(sess['user']).toEqual(USER);
    expect(sess['createdAt']).toBeGreaterThanOrEqual(before);
    expect(sess['createdAt']).toBeLessThanOrEqual(after);
    // createdAt + mfaVerifiedAt share a clock reading.
    expect(sess['mfaVerifiedAt']).toBe(sess['createdAt']);
    expect(sess['absoluteExpiresAt']).toBeGreaterThan(sess['createdAt'] as number);
    expect(typeof sess['csrfToken']).toBe('string');
    expect((sess['csrfToken'] as string).length).toBeGreaterThan(20);
  });

  it('saves the session before returning (no race with the controller redirect)', async () => {
    const { est } = makeFixture();
    const req = makeReqStub();
    const res = makeResStub();
    await est.establish({ user: USER, req, res, surface: 'user' });
    const sess = (req as unknown as { session: { save: jest.Mock } }).session;
    expect(sess.save).toHaveBeenCalledTimes(1);
  });

  it('mirrors the CSRF token to a JS-readable cookie', async () => {
    const { est } = makeFixture();
    const req = makeReqStub();
    const res = makeResStub();
    await est.establish({ user: USER, req, res, surface: 'user' });
    const sess = (req as unknown as { session: Record<string, unknown> }).session;
    expect(res.cookie).toHaveBeenCalledWith(
      expect.stringMatching(/csrf/),
      sess['csrfToken'],
      expect.objectContaining({ httpOnly: false }),
    );
  });

  it('adds the session id to the user_sessions index', async () => {
    const { est, index } = makeFixture();
    const req = makeReqStub({ sessionID: 'sid-7' });
    const res = makeResStub();
    await est.establish({ user: USER, req, res, surface: 'user' });
    expect(index.add).toHaveBeenCalledWith(USER.oid, 'sid-7');
  });

  it('emits the auth.sign_in audit row', async () => {
    const { est, audit } = makeFixture();
    const req = makeReqStub({ sessionID: 'sid-7' });
    const res = makeResStub();
    await est.establish({ user: USER, req, res, surface: 'user' });
    expect(audit.signIn).toHaveBeenCalledWith({ actor: USER, sessionId: 'sid-7' });
  });

  it('records the user in the directory after the audit write (ADR-0020)', async () => {
    const { est, audit, directory } = makeFixture();
    const req = makeReqStub({ sessionID: 'sid-7' });
    const res = makeResStub();
    await est.establish({ user: USER, req, res, surface: 'user' });
    expect(directory.recordSignIn).toHaveBeenCalledWith({
      oid: USER.oid,
      tid: USER.tid,
      username: USER.username,
      displayName: USER.displayName,
      audience: 'workforce',
    });
    // Order: audit MUST succeed first (blocking per ADR-0013).
    // Compare the recorded `mock.invocationCallOrder` — Jest
    // assigns a monotonic counter to every call across all mocks.
    const auditOrder = audit.signIn.mock.invocationCallOrder[0] ?? Infinity;
    const directoryOrder = directory.recordSignIn.mock.invocationCallOrder[0] ?? 0;
    expect(auditOrder).toBeLessThan(directoryOrder);
  });

  it('does NOT call the directory when the audit write fails (audit-first invariant)', async () => {
    const { est, audit, directory } = makeFixture();
    audit.signIn.mockRejectedValueOnce(new Error('audit_writer denied'));
    const req = makeReqStub();
    const res = makeResStub();
    await expect(est.establish({ user: USER, req, res, surface: 'user' })).rejects.toThrow();
    expect(directory.recordSignIn).not.toHaveBeenCalled();
  });

  it('writes the directory entry under the v1 hardcoded `workforce` audience (single-audience era)', async () => {
    // Pins the ADR-0008 dual-audience design's v1 simplification:
    // every signed-in user is workforce. The day External ID
    // ships, the SessionEstablisher will read audience from the
    // session/claims; the assertion will need to be updated then.
    const { est, directory } = makeFixture();
    const req = makeReqStub();
    const res = makeResStub();
    await est.establish({ user: USER, req, res, surface: 'admin' });
    expect(directory.recordSignIn).toHaveBeenCalledWith(
      expect.objectContaining({ audience: 'workforce' }),
    );
  });

  it('logs the success event with the surface tag', async () => {
    const { est, logger } = makeFixture();
    const req = makeReqStub();
    const res = makeResStub();
    await est.establish({ user: USER, req, res, surface: 'admin' });
    expect(logger.log).toHaveBeenCalledWith(
      expect.objectContaining({
        event: 'auth.signed_in',
        surface: 'admin',
        oid: USER.oid,
      }),
      'AuthCallback',
    );
  });

  it('propagates audit failures (blocking per ADR-0013)', async () => {
    const { est, audit } = makeFixture();
    audit.signIn.mockRejectedValueOnce(new Error('audit_writer denied'));
    const req = makeReqStub();
    const res = makeResStub();
    await expect(est.establish({ user: USER, req, res, surface: 'user' })).rejects.toThrow(
      'audit_writer denied',
    );
  });
});

describe('SessionEstablisher.destroy', () => {
  it('removes from index + audits signOut + destroys session when actor is set', async () => {
    const { est, index, audit } = makeFixture();
    const req = makeReqStub({ sessionID: 'sid-9', sessionUser: USER });
    await est.destroy({ actor: USER, req });
    expect(index.remove).toHaveBeenCalledWith(USER.oid, 'sid-9');
    expect(audit.signOut).toHaveBeenCalledWith({ actor: USER, sessionId: 'sid-9' });
    const sess = (req as unknown as { session: { destroy: jest.Mock } }).session;
    expect(sess.destroy).toHaveBeenCalledTimes(1);
  });

  it('still destroys the session for anonymous sign-outs but skips index + audit', async () => {
    const { est, index, audit } = makeFixture();
    const req = makeReqStub();
    await est.destroy({ actor: undefined, req });
    expect(index.remove).not.toHaveBeenCalled();
    expect(audit.signOut).not.toHaveBeenCalled();
    const sess = (req as unknown as { session: { destroy: jest.Mock } }).session;
    expect(sess.destroy).toHaveBeenCalledTimes(1);
  });

  it('logs but does not propagate when req.session.destroy fails (Redis hiccup)', async () => {
    const { est, logger } = makeFixture();
    const req = makeReqStub();
    (req as unknown as { session: { destroy: jest.Mock } }).session.destroy = jest.fn(
      (cb: (err?: Error) => void) => cb(new Error('redis down')),
    );
    await expect(est.destroy({ actor: USER, req })).resolves.toBeUndefined();
    expect(logger.error).toHaveBeenCalledWith(
      expect.objectContaining({ event: 'session.destroy_failed' }),
      'AuthLogout',
    );
  });

  it('propagates audit failures from signOut (blocking per ADR-0013)', async () => {
    const { est, audit } = makeFixture();
    audit.signOut.mockRejectedValueOnce(new Error('audit_writer denied'));
    const req = makeReqStub({ sessionUser: USER });
    await expect(est.destroy({ actor: USER, req })).rejects.toThrow('audit_writer denied');
  });
});
