// auth/authorization/authorization.class.ts

import { base64urlDecode, getMachineId, randomUUID } from '@frontmcp/utils';

import { TokenLeakDetectedError } from '../errors/auth-internal.errors';
import type { ProviderSnapshot } from '../session/session.types';
import type { SessionJwtPayload, TransportProtocol, TransportSession } from '../session/transport-session.types';
import { encryptJson } from '../session/utils/session-crypto.utils';
import type {
  AuthMode,
  Authorization,
  AuthorizationCreateCtx,
  AuthorizedPrompt,
  AuthorizedTool,
  AuthUser,
  LLMSafeAuthContext,
} from './authorization.types';

/**
 * Base Authorization class - represents authenticated user context
 * Subclasses implement mode-specific behavior (Public, Transparent, Orchestrated)
 */
export abstract class AuthorizationBase implements Authorization {
  readonly id: string;
  abstract readonly mode: AuthMode;
  readonly isAnonymous: boolean;
  readonly user: AuthUser;
  readonly claims?: Record<string, unknown>;
  readonly expiresAt?: number;
  readonly scopes: string[];
  readonly authorizedProviders: Record<string, ProviderSnapshot>;
  readonly authorizedProviderIds: string[];
  readonly authorizedApps: Record<string, { id: string; toolIds: string[] }>;
  readonly authorizedAppIds: string[];
  readonly authorizedTools: Record<string, AuthorizedTool>;
  readonly authorizedToolIds: string[];
  readonly authorizedPrompts: Record<string, AuthorizedPrompt>;
  readonly authorizedPromptIds: string[];
  readonly authorizedResources: string[];

  /** The original bearer token (for transparent mode) */
  protected readonly token?: string;

  /** Active transport sessions for this authorization */
  readonly #sessions: Map<string, TransportSession> = new Map();

  /** Creation timestamp */
  readonly createdAt: number;

  protected constructor(ctx: AuthorizationCreateCtx) {
    this.id = ctx.id;
    this.isAnonymous = ctx.isAnonymous;
    this.user = ctx.user;
    this.claims = ctx.claims;
    this.expiresAt = ctx.expiresAt;
    this.scopes = ctx.scopes ?? [];
    this.token = ctx.token;
    this.createdAt = Date.now();

    // Initialize authorization projections
    this.authorizedProviders = ctx.authorizedProviders ?? {};
    this.authorizedProviderIds = ctx.authorizedProviderIds ?? Object.keys(this.authorizedProviders);
    this.authorizedApps = ctx.authorizedApps ?? {};
    this.authorizedAppIds = ctx.authorizedAppIds ?? Object.keys(this.authorizedApps);
    this.authorizedTools = ctx.authorizedTools ?? {};
    this.authorizedToolIds = ctx.authorizedToolIds ?? Object.keys(this.authorizedTools);
    this.authorizedPrompts = ctx.authorizedPrompts ?? {};
    this.authorizedPromptIds = ctx.authorizedPromptIds ?? Object.keys(this.authorizedPrompts);
    this.authorizedResources = ctx.authorizedResources ?? [];
  }

  /**
   * Create a new transport session for this authorization
   * @param protocol - Transport protocol (sse, streamable-http, etc.)
   * @param fingerprint - Optional client fingerprint for tracking
   */
  createTransportSession(protocol: TransportProtocol, fingerprint?: string): TransportSession {
    const sessionId = randomUUID();

    const session: TransportSession = {
      id: sessionId,
      authorizationId: this.id,
      protocol,
      createdAt: Date.now(),
      expiresAt: this.expiresAt,
      nodeId: getMachineId(),
      clientFingerprint: fingerprint,
    };

    this.#sessions.set(session.id, session);
    return session;
  }

  /**
   * Get existing transport session by ID
   */
  getTransportSession(sessionId: string): TransportSession | undefined {
    return this.#sessions.get(sessionId);
  }

  /**
   * Get all active transport sessions
   */
  getAllSessions(): TransportSession[] {
    return Array.from(this.#sessions.values());
  }

  /**
   * Remove a transport session
   */
  removeTransportSession(sessionId: string): boolean {
    return this.#sessions.delete(sessionId);
  }

  /**
   * Get count of active sessions
   */
  get sessionCount(): number {
    return this.#sessions.size;
  }

  /**
   * Abstract: Get access token for a provider
   * Implementation varies by mode:
   * - Public: throws (no tokens)
   * - Transparent: returns the original bearer token
   * - Orchestrated: retrieves from vault/store
   */
  abstract getToken(providerId?: string): Promise<string>;

  /**
   * Check if a scope is granted
   */
  hasScope(scope: string): boolean {
    return this.scopes.includes(scope);
  }

  /**
   * Check if all scopes are granted
   */
  hasAllScopes(scopes: string[]): boolean {
    return scopes.every((s) => this.scopes.includes(s));
  }

  /**
   * Check if any scope is granted
   */
  hasAnyScope(scopes: string[]): boolean {
    return scopes.some((s) => this.scopes.includes(s));
  }

  /**
   * Check if a tool is authorized
   */
  canAccessTool(toolId: string): boolean {
    return toolId in this.authorizedTools || this.authorizedToolIds.includes(toolId);
  }

  /**
   * Check if a prompt is authorized
   */
  canAccessPrompt(promptId: string): boolean {
    return promptId in this.authorizedPrompts || this.authorizedPromptIds.includes(promptId);
  }

  /**
   * Check if an app is authorized.
   * Used for progressive authorization to determine if tools from this app can be executed.
   * @param appId - App ID to check
   */
  isAppAuthorized(appId: string): boolean {
    return appId in this.authorizedApps || this.authorizedAppIds.includes(appId);
  }

  /**
   * Build URL for progressive/incremental authorization.
   * Used when a tool requires authorization for an app that was skipped during initial auth.
   * @param appId - App ID that requires authorization
   * @param baseUrl - Base URL of the server
   */
  getProgressiveAuthUrl(appId: string, baseUrl: string): string {
    return `${baseUrl}/oauth/authorize?app=${encodeURIComponent(appId)}&mode=incremental`;
  }

  /**
   * Check if the authorization is expired
   */
  isExpired(): boolean {
    if (!this.expiresAt) return false;
    return Date.now() > this.expiresAt;
  }

  /**
   * Get time until expiration in milliseconds
   * Returns undefined if no expiration, negative if expired
   */
  getTimeToExpiry(): number | undefined {
    if (!this.expiresAt) return undefined;
    return this.expiresAt - Date.now();
  }

  /**
   * Convert a transport session to encrypted session JWT
   * This is what gets sent in the Mcp-Session-Id header
   */
  toSessionJwt(session: TransportSession): string {
    const payload: SessionJwtPayload = {
      sid: session.id,
      aid: this.id,
      proto: session.protocol,
      nid: session.nodeId,
      iat: Math.floor(Date.now() / 1000),
      exp: this.expiresAt ? Math.floor(this.expiresAt / 1000) : undefined,
    };
    return encryptJson(payload);
  }

  /**
   * Convert to LLM-safe context (no tokens exposed)
   */
  toLLMSafeContext(session: TransportSession): LLMSafeAuthContext {
    return {
      authorizationId: this.id,
      sessionId: session.id,
      mode: this.mode,
      isAnonymous: this.isAnonymous,
      user: {
        sub: this.user.sub,
        name: this.user.name,
      },
      scopes: this.scopes,
      authorizedToolIds: this.authorizedToolIds,
      authorizedPromptIds: this.authorizedPromptIds,
    };
  }

  /**
   * Check if a base64url segment decodes to a JWT-like header (contains "alg").
   */
  private static isJwtHeader(segment: string): boolean {
    try {
      const bytes = base64urlDecode(segment);
      const text = new TextDecoder().decode(bytes);
      const parsed = JSON.parse(text);
      return typeof parsed === 'object' && parsed !== null && typeof parsed.alg === 'string';
    } catch {
      return false;
    }
  }

  /**
   * Validate that no tokens are leaked in data
   * Throws if JWT pattern detected
   */
  static validateNoTokenLeakage(data: unknown): void {
    const json = JSON.stringify(data);

    // Detect any three-segment base64url pattern (JWT-like: seg1.seg2.seg3)
    // Uses indexOf('.') scan to avoid polynomial regex while not requiring 'eyJ' prefix
    const B64URL = /^[A-Za-z0-9_-]+$/;
    const MIN_SEG = 2; // minimum segment length to reduce false positives

    let dotIdx = json.indexOf('.');
    while (dotIdx !== -1) {
      // Walk backwards from dot to find seg1 start
      let seg1Start = dotIdx;
      while (seg1Start > 0 && /[A-Za-z0-9_-]/.test(json[seg1Start - 1])) seg1Start--;
      const seg1Len = dotIdx - seg1Start;

      if (seg1Len >= MIN_SEG) {
        // Walk forward from dot to find seg2 end (next dot or non-base64url char)
        let seg2End = dotIdx + 1;
        while (seg2End < json.length && /[A-Za-z0-9_-]/.test(json[seg2End])) seg2End++;
        const seg2Len = seg2End - (dotIdx + 1);

        if (seg2Len >= MIN_SEG && seg2End < json.length && json[seg2End] === '.') {
          // Found second dot — walk forward to find seg3 end
          const dot2 = seg2End;
          let seg3End = dot2 + 1;
          while (seg3End < json.length && /[A-Za-z0-9_-]/.test(json[seg3End])) seg3End++;
          const seg3Len = seg3End - (dot2 + 1);

          if (seg3Len >= MIN_SEG) {
            const seg1 = json.substring(seg1Start, dotIdx);
            const seg2 = json.substring(dotIdx + 1, dot2);
            const seg3 = json.substring(dot2 + 1, seg3End);
            if (B64URL.test(seg1) && B64URL.test(seg2) && B64URL.test(seg3) && AuthorizationBase.isJwtHeader(seg1)) {
              throw new TokenLeakDetectedError('JWT pattern detected in LLM context data');
            }
          }
        }
      }
      dotIdx = json.indexOf('.', dotIdx + 1);
    }

    // Detect sensitive field names
    const sensitiveFields = ['access_token', 'refresh_token', 'id_token', 'tokenEnc', 'secretRefId'];
    for (const field of sensitiveFields) {
      if (json.includes(`"${field}"`)) {
        throw new TokenLeakDetectedError(`Sensitive field "${field}" found in data`);
      }
    }
  }
}
