import { randomBytes } from 'node:crypto';
import { Module } from '@nestjs/common';
import { RedisStore } from 'connect-redis';
import expressSession from 'express-session';
import type { CookieOptions } from 'express';
import { Logger } from 'nestjs-pino';
import { assertSessionEncryptionKey } from '../config/check-session-encryption-key';
import { assertSessionSecret } from '../config/check-session-secret';
import { RedisModule } from '../redis/redis.module';
import { REDIS_CLIENT, type Redis } from '../redis/redis.token';
import { AuditWriter } from '../audit/audit.service';
import { createAbsoluteTimeoutMiddleware } from './absolute-timeout.middleware';
import { adminSessionCookieName } from './admin-session-cookie';
import { adaptIoredisForConnectRedis } from './ioredis-connect-redis-adapter';
import { SessionDecryptError, decrypt, encrypt } from './session-crypto';
import { readSessionTimeouts, sessionCookieName, sessionCookieOptions } from './session-cookie';
import {
  ADMIN_SESSION_MIDDLEWARE,
  SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE,
  SESSION_MIDDLEWARE,
  type RequestHandler,
} from './session.token';
// Side-effect import: brings the `req.session.user` declaration
// merging into the compile graph for every consumer of this module.
import './session.types';
import { UserSessionIndexService } from './user-session-index.service';

/**
 * Build a configured `express-session` middleware. Shared between
 * the user-portal middleware ({@link SESSION_MIDDLEWARE}) and the
 * admin-portal middleware ({@link ADMIN_SESSION_MIDDLEWARE}) per
 * ADR-0020 §"Sessions — distinct from `portal-shell`".
 *
 * The two surfaces differ only on the cookie name and Redis key
 * prefix; the TTL policy, encryption key, signing secret, session-id
 * entropy, and error-handling all come from the same source so a
 * future hardening (e.g. tighter idle TTL on admin) is a parameter
 * change rather than a divergent code path.
 */
function buildSessionMiddleware(
  redis: Redis,
  logger: Logger,
  opts: { cookieName: string; redisKeyPrefix: string; surfaceTag: 'user' | 'admin' },
): RequestHandler {
  const secret = assertSessionSecret();
  const key = assertSessionEncryptionKey();
  const timeouts = readSessionTimeouts();

  const store = new RedisStore({
    // `connect-redis` v9 was rewritten against the `node-redis` v4
    // command surface; the adapter shims `ioredis` to look the same
    // for the handful of commands the store actually calls.
    client: adaptIoredisForConnectRedis(redis) as unknown as never,
    prefix: opts.redisKeyPrefix,
    ttl: timeouts.idleSeconds,
    serializer: {
      stringify: (sess) => encrypt(JSON.stringify(sess), key),
      parse: (payload) => {
        try {
          return JSON.parse(decrypt(payload, key));
        } catch (err) {
          // Tamper, wrong key, or unknown version. The phase-2
          // audit pipeline (ADR-0013) will turn this into a
          // first-class audit event; for now we surface a
          // structured Pino log so ops can spot it. The
          // `surface` tag lets dashboards split user-portal
          // decrypt failures from admin-portal ones.
          logger.warn(
            {
              event: 'session.decrypt_failed',
              surface: opts.surfaceTag,
              reason: err instanceof SessionDecryptError ? err.message : String(err),
            },
            'session',
          );
          throw err;
        }
      },
    },
  });

  const cookie: CookieOptions = sessionCookieOptions(timeouts.idleSeconds);

  return expressSession({
    name: opts.cookieName,
    secret,
    // `crypto.randomBytes(32).toString('base64url')` ⇒ 256
    // bits of entropy in the session id, per ADR-0010
    // §"Confirmation".
    genid: () => randomBytes(32).toString('base64url'),
    store,
    // `resave: false` — RedisStore.touch refreshes the TTL on
    // every request; no need to rewrite the payload.
    resave: false,
    // `saveUninitialized: false` — don't create empty session
    // keys for unauthenticated visitors browsing public
    // routes. The session is born when the OIDC callback
    // populates it.
    saveUninitialized: false,
    // `rolling: true` — the cookie's `expires` slides forward
    // on every response, matching the sliding-idle policy.
    rolling: true,
    cookie,
  });
}

/**
 * Session module — wires `express-session` with a `connect-redis`
 * store on top of the shared `ioredis` client, with AES-256-GCM
 * encryption applied to the JSON payload before it lands in Redis.
 *
 * Two middlewares are provided, path-routed in `main.ts`:
 *
 *   - {@link SESSION_MIDDLEWARE} — user-portal. Cookie
 *     `portal_session` / `__Host-portal_session`. Redis prefix
 *     `session:`. Bound to every path except `/api/admin/*`.
 *
 *   - {@link ADMIN_SESSION_MIDDLEWARE} — admin-portal per ADR-0020.
 *     Cookie `portal_admin_session` / `__Host-portal_admin_session`.
 *     Redis prefix `session:admin:`. Bound to `/api/admin/*` only.
 *
 * The {@link SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE} (ADR-0010 §"TTL
 * policy") and {@link UserSessionIndexService} are shared across
 * both surfaces — the absolute-timeout check reads `req.session`
 * which the dispatch has already resolved to one surface or the
 * other; the user-session index is keyed by Entra `oid`, so an
 * admin sign-in and a user-portal sign-in by the same person
 * naturally land in different Redis sets only because the session
 * id namespaces differ.
 */
@Module({
  imports: [RedisModule],
  providers: [
    UserSessionIndexService,
    {
      provide: SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE,
      inject: [UserSessionIndexService, Logger, AuditWriter],
      useFactory: (
        index: UserSessionIndexService,
        logger: Logger,
        audit: AuditWriter,
      ): RequestHandler => createAbsoluteTimeoutMiddleware(index, logger, audit),
    },
    {
      provide: SESSION_MIDDLEWARE,
      inject: [REDIS_CLIENT, Logger],
      useFactory: (redis: Redis, logger: Logger): RequestHandler =>
        buildSessionMiddleware(redis, logger, {
          cookieName: sessionCookieName(),
          redisKeyPrefix: 'session:',
          surfaceTag: 'user',
        }),
    },
    {
      provide: ADMIN_SESSION_MIDDLEWARE,
      inject: [REDIS_CLIENT, Logger],
      useFactory: (redis: Redis, logger: Logger): RequestHandler =>
        buildSessionMiddleware(redis, logger, {
          cookieName: adminSessionCookieName(),
          redisKeyPrefix: 'session:admin:',
          surfaceTag: 'admin',
        }),
    },
  ],
  exports: [
    SESSION_MIDDLEWARE,
    ADMIN_SESSION_MIDDLEWARE,
    SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE,
    UserSessionIndexService,
  ],
})
export class SessionModule {}
