import { createDbService } from '@/server/services/db.js';
/**
 * Session attachment middleware.
 *
 * Runs on every request and:
 * 1. Reads the session cookie.
 * 2. Verifies the HMAC signature and DB row.
 * 3. Attaches `locals.session` and `locals.user`.
 * 4. Exposes `locals.requireUser()`, `locals.requireVendorV2()`,
 *    `locals.requireAdmin()`, `locals.requireCase()` helpers that throw typed errors.
 */

import { defineMiddleware } from 'astro:middleware';
// getDb dynamically imported at call sites — defers drizzle+neon+schema from cold-start.
import type { Session, User } from '@/server/auth/session.js';
// refreshSessionFromCookie dynamically imported at call site — defers drizzle+schema.
import {
  isHttpsRequest,
  parseMh,
  buildMh,
  setMhCookie,
  extractPhHint,
  buildCsrfCookieHeader,
  buildAccessCookie,
  ACCESS_TOKEN_TTL_SECS,
} from '@/server/auth/cookies.js';
import { computeCsrfToken } from '@/server/auth/csrf.js';
import { verifyAccessToken, signAccessToken } from '@/server/auth/tokens.js';
import { determineCaseRole } from '@/server/auth/access.js';
import { type CaseRole } from '@/server/auth/access.js';
// supportCases + vendorQueries dynamically imported inside fn bodies — defers drizzle+schema.
import type { TransactionCaseRow } from '@/server/db/queries/support-cases.js';
import type { getVendorByOwnerUser } from '@/server/db/queries/vendors.js';
// getCachedUser dynamically imported inside sessionMiddleware — defers drizzle+schema.
import { env as runtimeEnv } from '@/server/env';

/** Vendor row type — inferred from query return, exported for Locals type declarations. */
export type VendorRow = NonNullable<Awaited<ReturnType<typeof getVendorByOwnerUser>>>;
import type { MultidealEnv } from '@/server/env';
import { captureCaught } from '@/server/observability/capture.server';

// ---------------------------------------------------------------------------
// Typed errors
// ---------------------------------------------------------------------------

export class AuthRequiredError extends Error {
  readonly code = 'AUTH_REQUIRED' as const;
  readonly status = 401;
  constructor() {
    super('Authentication required');
    this.name = 'AuthRequiredError';
  }
}

export class NotFoundError extends Error {
  readonly code = 'NOT_FOUND' as const;
  readonly status = 404;
  constructor(message = 'Not found') {
    super(message);
    this.name = 'NotFoundError';
  }
}

export class ForbiddenError extends Error {
  readonly status: number;
  constructor(
    public readonly code:
      | 'VENDOR_REQUIRED'
      | 'VENDOR_SUSPENDED'
      | 'ADMIN_REQUIRED'
      | 'ACCOUNT_FROZEN'
      | 'CASE_ACCESS_DENIED',
    message: string,
  ) {
    super(message);
    this.name = 'ForbiddenError';
    this.status = 403;
  }
}

// ---------------------------------------------------------------------------
// Locals augmentation helpers
// ---------------------------------------------------------------------------

function makeRequireUser(session: Session | undefined, user: User | undefined) {
  return function requireUser(): { session: Session; user: User } {
    if (!session || !user) throw new AuthRequiredError();
    if (user.accountState === 'FROZEN') {
      throw new ForbiddenError('ACCOUNT_FROZEN', 'Account is frozen');
    }
    return { session, user };
  };
}

/**
 * requireVendorV2 factory — real vendor enforcement (B1+).
 *
 * - Calls requireUser (auth + FROZEN check).
 * - Direct DB query on every call (no cache — auth-critical state must be live).
 *   vendors.owner_user_id has an index; lookup cost is negligible.
 * - No vendor row → 403 VENDOR_REQUIRED (admins included — no implicit override).
 * - Vendor state FROZEN or BANNED → 403 VENDOR_SUSPENDED.
 * - Returns { session, user, vendor }.
 *
 * Exported so tests can import and exercise directly without going through middleware.
 */
export function makeRequireVendorV2(
  session: Session | undefined,
  user: User | undefined,
  env: MultidealEnv,
) {
  const requireUser = makeRequireUser(session, user);
  return async function requireVendorV2(): Promise<{
    session: Session;
    user: User;
    vendor: VendorRow;
  }> {
    const { session: s, user: u } = requireUser();
    const db = createDbService(env);
    const { getVendorByOwnerUser } = await import('@/server/db/queries/vendors.js');
    const vendor = await getVendorByOwnerUser(db, u.id);
    if (!vendor) {
      throw new ForbiddenError('VENDOR_REQUIRED', 'Vendor account required');
    }
    if (vendor.accountState === 'FROZEN' || vendor.accountState === 'BANNED') {
      throw new ForbiddenError(
        'VENDOR_SUSPENDED',
        `Vendor account ${vendor.accountState.toLowerCase()}`,
      );
    }
    return { session: s, user: u, vendor };
  };
}

function makeRequireAdmin(session: Session | undefined, user: User | undefined) {
  return function requireAdmin(): { session: Session; user: User } {
    if (!session || !user) throw new AuthRequiredError();
    if (!user.isAdmin) {
      throw new ForbiddenError('ADMIN_REQUIRED', 'Admin access required');
    }
    return { session, user };
  };
}

/**
 * requireCase(caseId) factory — case ownership helper.
 *
 * - Calls requireUser.
 * - Loads case by id (no cache — cases mutate frequently).
 * - Resolves vendor owner to determine 'vendor' role.
 * - Returns { session, user, case, role }.
 * - Throws NotFoundError if case missing.
 * - Throws ForbiddenError('CASE_ACCESS_DENIED') for strangers.
 */
export function makeRequireCase(
  session: Session | undefined,
  user: User | undefined,
  env: MultidealEnv,
) {
  const requireUser = makeRequireUser(session, user);
  return async function requireCase(
    caseId: string,
  ): Promise<{ session: Session; user: User; case: TransactionCaseRow; role: CaseRole }> {
    const { session: s, user: u } = requireUser();
    const db = createDbService(env);
    const { findById: findCaseById } = await import('@/server/db/queries/support-cases.js');
    const caseRow = await findCaseById(db, caseId);
    if (!caseRow) {
      throw new NotFoundError('Case not found');
    }
    // Resolve vendor owner for role check — load vendor row by PK, get ownerUserId.
    let vendorOwnerUserId: string | null = null;
    if (caseRow.vendorId) {
      const { findById: findVendorById } = await import('@/server/db/queries/vendors.js');
      const vendorRow = await findVendorById(db, caseRow.vendorId);
      vendorOwnerUserId = vendorRow?.ownerUserId ?? null;
    }
    const role = determineCaseRole(u, caseRow, vendorOwnerUserId);
    return { session: s, user: u, case: caseRow, role };
  };
}

/**
 * csrfToken getter factory.
 *
 * Returns session.csrfToken when session is present, throws AuthRequiredError otherwise.
 * Routes that only need the token for a read (not a mutation guard) use this
 * instead of reaching into locals.session directly.
 */
function makeCsrfTokenGetter(session: Session | undefined): () => string {
  return function csrfToken(): string {
    if (!session) throw new AuthRequiredError();
    return session.csrfToken;
  };
}

// ---------------------------------------------------------------------------
// Session skip prefixes
// Routes that authenticate via Bearer/CSRF/HMAC, not session cookie.
// Skip session verification entirely to avoid KV ops.
// Only include prefixes with real handlers (verified at implementation time).
// ---------------------------------------------------------------------------

const SESSION_SKIP_PREFIXES = ['/api/queues/', '/api/internal/scheduler/', '/api/health'] as const;

function shouldSkipSession(pathname: string): boolean {
  return SESSION_SKIP_PREFIXES.some((p) => pathname.startsWith(p));
}

/**
 * Routes that do NOT need the `isVendor` badge populated on locals.
 * Only HTML pages render the public site nav (which uses `isVendor`); API
 * routes never read it. Skipping the `hasActiveVendor` DB query on every
 * `/api/*` request shaves a Neon-HTTP roundtrip + Drizzle parse off the
 * per-request CPU floor — the single largest contributor to `/api/auth/session`
 * burning >10 ms on the Bundled tier.
 *
 * Per-route handlers that genuinely need vendor enforcement still call
 * `locals.requireVendorV2()` which performs its own query.
 */
function needsVendorBadge(pathname: string): boolean {
  return !pathname.startsWith('/api/');
}

/**
 * Routes that require vendor/affiliate data at SSR time.
 * Customer-facing routes load isVendor lazily via mh cookie client-side,
 * skipping 2 DB queries on cache miss saves ~4ms of CPU on Workers Free 10ms ceiling.
 *
 * API routes are excluded entirely (handled by needsVendorBadge).
 * Only /vendor/* and /admin/* need the vendor/affiliate fields populated at SSR.
 */
function needsVendorQuery(pathname: string): boolean {
  if (pathname.startsWith('/api/')) return false;
  return pathname.startsWith('/vendor/') || pathname.startsWith('/admin/');
}

// ---------------------------------------------------------------------------
// Middleware
// ---------------------------------------------------------------------------

export const sessionMiddleware = defineMiddleware(async (context, next) => {
  const env = runtimeEnv;

  // Initialize locals with unauthenticated state
  context.locals.session = undefined;
  context.locals.user = undefined;
  context.locals.isVendor = false;
  context.locals.isAffiliate = false;
  context.locals.requireUser = makeRequireUser(undefined, undefined);
  context.locals.requireVendorV2 = makeRequireVendorV2(undefined, undefined, env);
  context.locals.requireAdmin = makeRequireAdmin(undefined, undefined);
  context.locals.requireCase = makeRequireCase(undefined, undefined, env);
  context.locals.getCsrfToken = makeCsrfTokenGetter(undefined);

  const url = new URL(context.request.url);
  if (shouldSkipSession(url.pathname)) {
    return next();
  }

  const databaseUrl = env.DATABASE_URL;

  // -------------------------------------------------------------------------
  // JWT-only path (M9): verify HS256 access JWT, load user, populate locals.
  //
  // If multideal_at is absent, expired, or invalid → unauthenticated.
  // No legacy cookie fallback.
  // -------------------------------------------------------------------------
  if (env.JWT_SECRET && databaseUrl) {
    try {
      const accessJwt = context.cookies.get('multideal_at')?.value;
      if (accessJwt) {
        const secrets: string[] = [env.JWT_SECRET];
        if (env.JWT_SECRET_PREV) secrets.push(env.JWT_SECRET_PREV);
        const claims = await verifyAccessToken(accessJwt, secrets);

        if (claims) {
          const { createDbServiceScope } = await import('@/server/services/db.js');
          const dbScope = createDbServiceScope({ DATABASE_URL: databaseUrl });
          try {
            const db = dbScope.db;

          const cfContext = (
            context.locals as unknown as { cfContext?: { waitUntil(p: Promise<unknown>): void } }
          ).cfContext;
          const waitUntil =
            cfContext && typeof cfContext.waitUntil === 'function'
              ? cfContext.waitUntil.bind(cfContext)
              : undefined;

          const { getCachedUser } = await import('./user-cache.js');
          const cached = await getCachedUser(db, claims.sub, claims.mhv, {
            waitUntil,
            needsVendor: needsVendorQuery(url.pathname),
          });

          // sv is the single auth-revocation gate. mhv is NOT an auth signal.
          // When claims.sv is behind the DB sessionVersion the token is revoked:
          // skip this branch so control falls through to the RT path (which
          // re-mints with the current sv; a real revocation also revoked the
          // session row, so RT fails too -> /login).
          if (
            cached &&
            cached.user.accountState === 'ACTIVE' &&
            claims.sv >= cached.user.sessionVersion
          ) {
            // Read csrf_token from the readable (non-HttpOnly) cookie set at login.
            // Double-submit pattern: client echoes it in x-csrf-token header.
            // HMAC fallback: when csrf_token is absent (session pre-dates issuance,
            // cookie was cleared, etc.) compute a deterministic token from JWT
            // claims so POST API routes can still validate CSRF. The matching
            // client fallback reads mh.t, which is built with the same HMAC.
            const rawCsrfAt = context.cookies.get('csrf_token')?.value;
            const csrfToken =
              rawCsrfAt ||
              (env.JWT_SECRET
                ? await computeCsrfToken(env.JWT_SECRET, claims.sub, claims.iat)
                : '');

            // Build a session stub that satisfies App.SessionRow.
            // Fields not derivable from JWT claims use safe defaults — they are
            // not consumed on the hot path. `id` is a deterministic sentinel so
            // logs distinguish JWT-path sessions from DB-path sessions.
            const jwtSession: Session = {
              id: `jwt:${claims.sub}:${claims.iat.toString()}`,
              userId: claims.sub,
              csrfToken,
              userAgent: '',
              ipEncrypted: null,
              expiresAt: new Date(claims.exp * 1000),
              revokedAt: null,
              createdAt: new Date(claims.iat * 1000),
              refreshTokenHash: '',
              previousRefreshTokenHash: null,
              lastRefreshedAt: null,
              emailVerifiedAt: claims.evAt ? new Date(claims.evAt * 1000) : null,
              onboardingCompletedAt: claims.obAt ? new Date(claims.obAt * 1000) : null,
            };

            const user = cached.user;

            context.locals.session = jwtSession;
            context.locals.user = user;
            context.locals.csrfToken = csrfToken;
            context.locals.requireUser = makeRequireUser(jwtSession, user);
            context.locals.requireVendorV2 = makeRequireVendorV2(jwtSession, user, env);
            context.locals.requireAdmin = makeRequireAdmin(jwtSession, user);
            context.locals.requireCase = makeRequireCase(jwtSession, user, env);
            context.locals.getCsrfToken = makeCsrfTokenGetter(jwtSession);

            // Vendor badge — only needed on HTML pages (rendered in SiteNav);
            // skip for /api/* to keep the per-request CPU floor under the Bundled
            // tier ceiling.
            if (needsVendorBadge(url.pathname)) {
              context.locals.isVendor = cached.isVendor;
              context.locals.isAffiliate = cached.isAffiliate;
            }

            // Refresh mh cookie if missing/expired, and re-issue csrf_token when
            // absent (HTML pages only — skip /api/ to stay under CPU floor).
            if (needsVendorBadge(url.pathname)) {
              const mhRaw = context.cookies.get('mh')?.value;
              const existingMh = parseMh(mhRaw);
              const needsMhRefresh =
                !existingMh ||
                existingMh.c !== cached.user.mhVersion ||
                (needsVendorQuery(url.pathname) && (existingMh.v === 1) !== cached.isVendor);
              const needsCsrfReissue = !rawCsrfAt;
              // Re-mint the access token in place when the display version is stale.
              // This heals the token WITHOUT depending on RT rotation (the prior bug:
              // a hard-nav after RT rotated past its grace generation -> /login).
              const needsAtRemint = cached.mhStale;
              if (needsMhRefresh || needsCsrfReissue || needsAtRemint) {
                const response = await next();
                if (response.status === 101) return response;
                try {
                  const csrf = await computeCsrfToken(env.JWT_SECRET, claims.sub, claims.iat);
                  if (needsMhRefresh) {
                    const mhValue = buildMh(
                      {
                        displayName: user.displayName,
                        isAdmin: user.isAdmin,
                        isVendor: cached.isVendor,
                        isAffiliate: cached.isAffiliate,
                        phoneHint: cached.user.phoneHint ?? extractPhHint(mhRaw),
                      },
                      cached.user.mhVersion,
                      csrf,
                    );
                    setMhCookie(response.headers, mhValue);
                  }
                  if (needsCsrfReissue) {
                    response.headers.append(
                      'Set-Cookie',
                      buildCsrfCookieHeader(csrf, isHttpsRequest(context.request)),
                    );
                  }
                  if (needsAtRemint) {
                    const roles: string[] = cached.user.isAdmin ? ['admin'] : ['user'];
                    const evAt = cached.user.emailVerifiedAt
                      ? Math.floor(cached.user.emailVerifiedAt.getTime() / 1000)
                      : null;
                    const obAt = cached.user.onboardingCompletedAt
                      ? Math.floor(cached.user.onboardingCompletedAt.getTime() / 1000)
                      : null;
                    const freshAt = await signAccessToken(
                      {
                        sub: claims.sub,
                        sv: cached.user.sessionVersion,
                        mhv: cached.user.mhVersion,
                        roles,
                        evAt,
                        obAt,
                      },
                      env.JWT_SECRET,
                      ACCESS_TOKEN_TTL_SECS,
                    );
                    response.headers.append(
                      'Set-Cookie',
                      buildAccessCookie(freshAt, isHttpsRequest(context.request)),
                    );
                  }
                } catch (mhErr) {
                  captureCaught(mhErr, {
                    scope: 'server.middleware.session.mh-refresh',
                    severity: 'warning',
                  });
                }
                return response;
              }
            }
            return await next();
          }
          } finally {
            await dbScope.close();
          }
        }
      }
    } catch (err) {
      // JWT verification errors are non-fatal — treat as unauthenticated.
      captureCaught(err, { scope: 'server.middleware.session.jwt', severity: 'warning' });
    }

    // -------------------------------------------------------------------------
    // Inline RT refresh fallback (M9 SSR fix).
    //
    // multideal_at is absent/expired. Try multideal_rt (now Path=/ so middleware
    // can read it). On success, populate locals identically to the AT path and
    // set-cookie the new AT (+ rotated RT if race won) on the response.
    // -------------------------------------------------------------------------
    try {
      const rt = context.cookies.get('multideal_rt')?.value;
      if (rt) {
        const secureCookie = isHttpsRequest(context.request);
        const { createDbServiceScope } = await import('@/server/services/db.js');
        const dbScope = createDbServiceScope({ DATABASE_URL: databaseUrl });
        try {
          const db = dbScope.db;
        const { refreshSessionFromCookie } = await import('@/server/auth/session.js');
        const result = await refreshSessionFromCookie(db, rt, secureCookie, env.JWT_SECRET);

        if (result) {
          const cfContext = (
            context.locals as unknown as { cfContext?: { waitUntil(p: Promise<unknown>): void } }
          ).cfContext;
          const waitUntil =
            cfContext && typeof cfContext.waitUntil === 'function'
              ? cfContext.waitUntil.bind(cfContext)
              : undefined;

          const { getCachedUser: getCachedUser2 } = await import('./user-cache.js');
          const cached = await getCachedUser2(db, result.claims.sub, result.claims.mhv, {
            waitUntil,
            needsVendor: needsVendorQuery(url.pathname),
          });

          if (cached && cached.user.accountState === 'ACTIVE') {
            const rawCsrfRt = context.cookies.get('csrf_token')?.value;
            const csrfToken =
              rawCsrfRt ||
              (env.JWT_SECRET
                ? await computeCsrfToken(env.JWT_SECRET, result.claims.sub, result.claims.iat)
                : '');

            const jwtSession: Session = {
              id: `jwt:${result.claims.sub}:${result.claims.iat.toString()}`,
              userId: result.claims.sub,
              csrfToken,
              userAgent: '',
              ipEncrypted: null,
              expiresAt: new Date(result.claims.exp * 1000),
              revokedAt: null,
              createdAt: new Date(result.claims.iat * 1000),
              refreshTokenHash: '',
              previousRefreshTokenHash: null,
              lastRefreshedAt: null,
              emailVerifiedAt: result.claims.evAt ? new Date(result.claims.evAt * 1000) : null,
              onboardingCompletedAt: result.claims.obAt
                ? new Date(result.claims.obAt * 1000)
                : null,
            };

            const user = cached.user;

            context.locals.session = jwtSession;
            context.locals.user = user;
            context.locals.csrfToken = csrfToken;
            context.locals.requireUser = makeRequireUser(jwtSession, user);
            context.locals.requireVendorV2 = makeRequireVendorV2(jwtSession, user, env);
            context.locals.requireAdmin = makeRequireAdmin(jwtSession, user);
            context.locals.requireCase = makeRequireCase(jwtSession, user, env);
            context.locals.getCsrfToken = makeCsrfTokenGetter(jwtSession);

            // Skip vendor badge query on /api/* — see needsVendorBadge() rationale.
            if (needsVendorBadge(url.pathname)) {
              context.locals.isVendor = cached.isVendor;
              context.locals.isAffiliate = cached.isAffiliate;
            }

            const response = await next();
            // Skip header mutation for WS upgrade (101) — response.headers are immutable.
            if (response.status === 101) return response;
            response.headers.append('Set-Cookie', result.accessCookie);
            if (result.refreshCookie) {
              response.headers.append('Set-Cookie', result.refreshCookie);
            }
            // Re-issue csrf_token when absent (all routes) and refresh mh (HTML only).
            const rtCsrfMissing = !rawCsrfRt;
            const rtNeedsHtml = needsVendorBadge(url.pathname);
            if (rtCsrfMissing || rtNeedsHtml) {
              try {
                const csrf = await computeCsrfToken(
                  env.JWT_SECRET,
                  result.claims.sub,
                  result.claims.iat,
                );
                if (rtCsrfMissing) {
                  response.headers.append('Set-Cookie', buildCsrfCookieHeader(csrf, secureCookie));
                }
                if (rtNeedsHtml) {
                  const existingMhRaw = context.cookies.get('mh')?.value;
                  const mhValue = buildMh(
                    {
                      displayName: cached.user.displayName,
                      isAdmin: cached.user.isAdmin,
                      isVendor: cached.isVendor,
                      isAffiliate: cached.isAffiliate,
                      phoneHint: cached.user.phoneHint ?? extractPhHint(existingMhRaw),
                    },
                    cached.user.mhVersion,
                    csrf,
                  );
                  setMhCookie(response.headers, mhValue);
                }
              } catch (mhErr) {
                captureCaught(mhErr, {
                  scope: 'server.middleware.session.mh-refresh-rt',
                  severity: 'warning',
                });
              }
            }
            return response;
          }
        }
        } finally {
          await dbScope.close();
        }
      }
    } catch (err) {
      captureCaught(err, {
        scope: 'server.middleware.session.refresh-inline',
        severity: 'warning',
      });
    }
  }

  return next();
});
