import { CanActivate, ExecutionContext, Injectable, UnauthorizedException, ForbiddenException, Logger } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { and, eq, isNull } from 'drizzle-orm';
import { hasPermission, Permission } from '@wms/shared';
import { DbService } from '@/common/db/db.service';
import { ApiUsageService } from './api-usage.service';
import { revokedSessions } from '@/modules/auth/auth.service';
import { apiKeys, featureFlags, tenants, sessions, users } from '@/db/schema';
import { getRequestContext } from '@/common/context/request-context';
import { TokenService } from './token.service';
import { IS_PUBLIC_KEY, PERMISSIONS_KEY, FEATURE_KEY, PLATFORM_ONLY_KEY, API_KEY_SCOPES_KEY } from './decorators';

/**
 * Single global guard that:
 *  1. authenticates (Bearer JWT for users/platform staff, X-API-Key for integrations)
 *  2. populates the request context (tenant, actor, permissions, warehouses)
 *  3. authorises (permissions, feature flags, platform-only, API scopes)
 */
@Injectable()
export class AuthGuard implements CanActivate {
  private readonly logger = new Logger(AuthGuard.name);

  constructor(
    private readonly reflector: Reflector,
    private readonly tokens: TokenService,
    private readonly db: DbService,
    private readonly apiUsage: ApiUsageService,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [context.getHandler(), context.getClass()]);
    const req = context.switchToHttp().getRequest<Request>();
    const ctx = getRequestContext();
    if (!ctx) throw new Error('Request context missing (middleware not registered)');

    if (isPublic) return true;

    const apiScopes = this.reflector.getAllAndOverride<string[] | undefined>(API_KEY_SCOPES_KEY, [context.getHandler(), context.getClass()]);
    const apiKeyHeader = req.headers['x-api-key'];
    const authHeader = req.headers.authorization;

    if (typeof apiKeyHeader === 'string' && apiKeyHeader.length > 0) {
      await this.authenticateApiKey(apiKeyHeader, apiScopes ?? []);
      await this.apiUsage.record(ctx.tenantId!);
    } else if (authHeader?.startsWith('Bearer ')) {
      this.authenticateBearer(authHeader.slice(7));
      if (ctx.actorType === 'USER') await this.assertSessionAlive(ctx.sessionId!, ctx.tenantId!);
    } else {
      throw new UnauthorizedException('Missing credentials');
    }

    // ---- authorisation ----
    const platformOnly = this.reflector.getAllAndOverride<boolean>(PLATFORM_ONLY_KEY, [context.getHandler(), context.getClass()]);
    if (platformOnly && ctx.actorType !== 'PLATFORM_ADMIN') throw new ForbiddenException('Platform staff only');

    const required = this.reflector.getAllAndOverride<Permission[] | undefined>(PERMISSIONS_KEY, [context.getHandler(), context.getClass()]);
    if (required?.length) {
      for (const p of required) {
        if (!hasPermission(ctx.permissions, p)) throw new ForbiddenException(`Missing permission: ${p}`);
      }
    }

    const feature = this.reflector.getAllAndOverride<string | undefined>(FEATURE_KEY, [context.getHandler(), context.getClass()]);
    if (feature && ctx.actorType !== 'PLATFORM_ADMIN' && !ctx.features.includes(feature)) {
      throw new ForbiddenException({ statusCode: 403, error: 'Forbidden', code: 'FEATURE_DISABLED', message: `Feature not enabled for this tenant: ${feature}` });
    }

    return true;
  }

  /**
   * Access tokens are short-lived but must still die with their session (logout, user disabled,
   * password change, tenant suspended). One indexed lookup per request, cached for 30 s per session.
   */
  private readonly sessionCache = new Map<string, number>();
  private async assertSessionAlive(sessionId: string, tenantId: string) {
    if (revokedSessions.has(sessionId)) throw new UnauthorizedException('Session revoked');
    const cached = this.sessionCache.get(sessionId);
    if (cached && cached > Date.now()) return;
    const row = await this.db.runBypass(async (tx) => {
      const [s] = await tx.select({ revokedAt: sessions.revokedAt, expiresAt: sessions.expiresAt, userStatus: users.status, tenantStatus: tenants.status }).from(sessions).innerJoin(users, eq(users.id, sessions.userId)).innerJoin(tenants, eq(tenants.id, users.tenantId)).where(eq(sessions.id, sessionId)).limit(1);
      return s;
    });
    if (!row || row.revokedAt || row.expiresAt < new Date() || row.userStatus !== 'ACTIVE') {
      revokedSessions.add(sessionId);
      throw new UnauthorizedException('Session revoked');
    }
    if (row.tenantStatus === 'SUSPENDED' || row.tenantStatus === 'CLOSED') throw new ForbiddenException({ statusCode: 403, error: 'Forbidden', code: 'TENANT_SUSPENDED', message: 'Tenant is not active' });
    void tenantId;
    if (this.sessionCache.size > 10000) this.sessionCache.clear();
    this.sessionCache.set(sessionId, Date.now() + 30_000);
  }

  private authenticateBearer(token: string) {
    const ctx = getRequestContext()!;
    let payload;
    try {
      payload = this.tokens.verifyAccessToken(token);
    } catch {
      throw new UnauthorizedException('Invalid or expired token');
    }
    ctx.actorId = payload.sub;
    ctx.actorName = payload.name;
    ctx.userId = payload.sub;
    ctx.permissions = payload.perm ?? [];
    ctx.warehouseIds = payload.wh ?? [];
    ctx.features = payload.feat ?? [];
    ctx.storerId = payload.storer ?? null;
    if (payload.typ === 'platform') {
      ctx.actorType = 'PLATFORM_ADMIN';
      ctx.tenantId = null;
      ctx.bypassRls = true;
      ctx.permissions = ['*'];
      ctx.warehouseIds = 'ALL';
    } else {
      ctx.actorType = 'USER';
      ctx.tenantId = payload.tid;
      ctx.bypassRls = false;
      ctx.sessionId = payload.sid;
    }
  }

  private async authenticateApiKey(raw: string, requiredScopes: string[]) {
    const ctx = getRequestContext()!;
    const hash = TokenService.hash(raw);
    const row = await this.db.runBypass(async (tx) => {
      const [k] = await tx.select().from(apiKeys).where(and(eq(apiKeys.keyHash, hash), isNull(apiKeys.revokedAt))).limit(1);
      if (!k) return null;
      const [t] = await tx.select({ id: tenants.id, status: tenants.status }).from(tenants).where(eq(tenants.id, k.tenantId)).limit(1);
      const flags = await tx.select({ key: featureFlags.key }).from(featureFlags).where(and(eq(featureFlags.tenantId, k.tenantId), eq(featureFlags.enabled, true)));
      // touch lastUsedAt (best effort, not awaited inside the tx for latency)
      await tx.update(apiKeys).set({ lastUsedAt: new Date() }).where(eq(apiKeys.id, k.id));
      return { key: k, tenant: t, features: flags.map((f) => f.key) };
    });
    if (!row || !row.tenant) throw new UnauthorizedException('Invalid API key');
    if (row.key.expiresAt && row.key.expiresAt < new Date()) throw new UnauthorizedException('API key expired');
    if (row.tenant.status === 'SUSPENDED' || row.tenant.status === 'CLOSED') throw new ForbiddenException('Tenant is not active');
    for (const s of requiredScopes) {
      if (!row.key.scopes.includes(s)) throw new ForbiddenException(`API key lacks scope ${s}`);
    }
    if (requiredScopes.length === 0) {
      // API keys may only call endpoints explicitly opened to them
      throw new ForbiddenException('API keys cannot access this endpoint');
    }
    ctx.actorType = 'API_KEY';
    ctx.actorId = row.key.id;
    ctx.actorName = row.key.name;
    ctx.tenantId = row.key.tenantId;
    ctx.storerId = row.key.storerId;
    ctx.permissions = scopesToPermissions(row.key.scopes);
    ctx.warehouseIds = 'ALL';
    ctx.features = row.features;
    ctx.bypassRls = false;
  }
}

function scopesToPermissions(scopes: string[]): string[] {
  const map: Record<string, string[]> = {
    'items:write': ['masterdata.read', 'masterdata.manage'],
    'items:read': ['masterdata.read'],
    'asn:write': ['inbound.read', 'inbound.manage'],
    'asn:read': ['inbound.read'],
    'orders:write': ['outbound.read', 'outbound.manage'],
    'orders:read': ['outbound.read'],
    'inventory:read': ['inventory.read'],
    'serials:read': ['inventory.read'],
    'billing:read': ['billing.read'],
    'webhooks:manage': ['integrations.read', 'integrations.manage'],
  };
  return Array.from(new Set(scopes.flatMap((s) => map[s] ?? [])));
}
