/**
 * OIDC Auth Provider Implementation
 * Documentation: documentation/features/audiobookshelf-integration.md
 */

import { Issuer, Client, generators } from 'openid-client';
import {
  IAuthProvider,
  UserInfo,
  AuthTokens,
  LoginInitiation,
  CallbackParams,
  AuthResult,
} from './IAuthProvider';
import { getConfigService } from '@/lib/services/config.service';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
import { getBaseUrl } from '@/lib/utils/url';
import { getSchedulerService } from '@/lib/services/scheduler.service';
import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger';

const logger = RMABLogger.create('OIDCAuth');

// In-memory storage for OIDC flow state (temporary until callback completes)
// In production, this could be replaced with Redis for multi-instance support
interface OIDCFlowState {
  state: string;
  nonce: string;
  codeVerifier: string;
  timestamp: number;
}

const flowStateCache = new Map<string, OIDCFlowState>();
const FLOW_STATE_TTL = 10 * 60 * 1000; // 10 minutes

export class OIDCAuthProvider implements IAuthProvider {
  type: 'oidc' = 'oidc';
  private configService = getConfigService();
  private encryptionService = getEncryptionService();
  private client: Client | null = null;

  /**
   * Get or create OIDC client
   */
  private async getClient(): Promise<Client> {
    if (this.client) return this.client;

    const issuerUrl = await this.configService.get('oidc.issuer_url');
    const clientId = await this.configService.get('oidc.client_id');
    const clientSecret = await this.configService.get('oidc.client_secret');

    if (!issuerUrl || !clientId || !clientSecret) {
      throw new Error('OIDC is not fully configured');
    }

    // Discover OIDC endpoints
    const issuer = await Issuer.discover(issuerUrl);

    // Create client
    this.client = new issuer.Client({
      client_id: clientId,
      client_secret: clientSecret,
      redirect_uris: [await this.getRedirectUri()],
      response_types: ['code'],
    });

    return this.client;
  }

  /**
   * Get redirect URI for OAuth callback
   */
  private async getRedirectUri(): Promise<string> {
    const baseUrl = getBaseUrl();
    return `${baseUrl}/api/auth/oidc/callback`;
  }

  /**
   * Initiate OIDC login flow
   */
  async initiateLogin(): Promise<LoginInitiation> {
    try {
      const client = await this.getClient();
      // Clean up expired states first
      this.cleanupExpiredStates();

      const state = generators.state();
      const nonce = generators.nonce();
      const codeVerifier = generators.codeVerifier();
      const codeChallenge = generators.codeChallenge(codeVerifier);

      // Store state in memory cache
      flowStateCache.set(state, {
        state,
        nonce,
        codeVerifier,
        timestamp: Date.now(),
      });

      // Only request 'groups' scope when group-based features are configured
      const accessMethod = await this.configService.get('oidc.access_control_method');
      const adminClaimEnabled = await this.configService.get('oidc.admin_claim_enabled');
      const needsGroups = accessMethod === 'group_claim' || adminClaimEnabled === 'true';
      const scope = needsGroups ? 'openid profile email groups' : 'openid profile email';

      // Generate authorization URL
      const redirectUrl = client.authorizationUrl({
        scope,
        state,
        nonce,
        code_challenge: codeChallenge,
        code_challenge_method: 'S256',
      });

      return {
        redirectUrl,
        state,
      };
    } catch (error) {
      logger.error('Failed to initiate login', { error: error instanceof Error ? error.message : String(error) });
      throw new Error('Failed to initiate OIDC authentication');
    }
  }

  /**
   * Handle OIDC callback
   */
  async handleCallback(params: CallbackParams): Promise<AuthResult> {
    try {
      const { code, state, error } = params;

      if (error) {
        return {
          success: false,
          error: `OIDC provider error: ${error}`,
        };
      }

      if (!code || !state) {
        return {
          success: false,
          error: 'Missing authorization code or state',
        };
      }

      // Retrieve stored flow state
      const flowState = flowStateCache.get(state);
      if (!flowState) {
        return {
          success: false,
          error: 'Invalid or expired state parameter',
        };
      }

      // Clean up state after retrieval
      flowStateCache.delete(state);

      const client = await this.getClient();
      const redirectUri = await this.getRedirectUri();

      logger.debug('Exchanging code for tokens', {
        redirectUri,
        hasCode: !!code,
        hasState: !!state,
        stateMatches: state === flowState.state,
      });

      // Exchange code for tokens
      const tokenSet = await client.callback(
        redirectUri,
        { code, state },
        {
          code_verifier: flowState.codeVerifier,
          nonce: flowState.nonce,
          state: flowState.state,
        }
      );

      if (!tokenSet.access_token) {
        return {
          success: false,
          error: 'Failed to obtain access token',
        };
      }

      // Get user info from OIDC provider
      const userinfo = await client.userinfo(tokenSet.access_token);

      if (!userinfo.sub) {
        return {
          success: false,
          error: 'Invalid user info from OIDC provider',
        };
      }

      // Check access control
      const hasAccess = await this.checkAccessControl(userinfo);
      if (!hasAccess) {
        return {
          success: false,
          error: 'You do not have access to this application',
        };
      }

      // Map OIDC claims to UserInfo
      const username = (userinfo.preferred_username || userinfo.email || userinfo.sub) as string;
      const email = userinfo.email as string | undefined;
      const avatarUrl = userinfo.picture as string | undefined;

      // Check admin role from claims
      const isAdminFromClaim = await this.checkAdminClaim(userinfo);

      // Check if admin approval required
      const accessMethod = await this.configService.get('oidc.access_control_method');
      if (accessMethod === 'admin_approval') {
        const existingUser = await this.findUserByOIDCSubject(userinfo.sub);

        if (!existingUser) {
          // Check if this is the first user - they should bypass approval
          const userCount = await prisma.user.count();
          const isFirstUser = userCount === 0;

          if (!isFirstUser) {
            // Not the first user - create pending user requiring approval
            await this.createPendingUser(userinfo.sub, username, email, avatarUrl);
            return {
              success: false,
              requiresApproval: true,
            };
          }
          // First user - continue to create them as approved admin (bypass approval)
        }

        if (existingUser?.registrationStatus === 'pending_approval') {
          return {
            success: false,
            requiresApproval: true,
          };
        }

        if (existingUser?.registrationStatus === 'rejected') {
          return {
            success: false,
            error: 'Your account has been rejected by an administrator',
          };
        }
      }

      // Create or update user
      const result = await this.createOrUpdateUser(
        userinfo.sub,
        username,
        email,
        avatarUrl,
        isAdminFromClaim
      );

      // Generate JWT tokens
      const tokens = await this.generateTokens(result.userInfo);

      return {
        success: true,
        user: result.userInfo,
        tokens,
        isFirstLogin: result.isFirstLogin,
      };
    } catch (error) {
      logger.error('Callback failed', { error: error instanceof Error ? error.message : String(error) });
      return {
        success: false,
        error: error instanceof Error ? error.message : 'Authentication failed',
      };
    }
  }

  /**
   * Check if user has access to the application
   */
  private async checkAccessControl(userinfo: any): Promise<boolean> {
    const method = await this.configService.get('oidc.access_control_method');

    switch (method) {
      case 'open':
        return true;

      case 'group_claim': {
        const claimName = (await this.configService.get('oidc.access_group_claim')) || 'groups';
        const requiredGroup = await this.configService.get('oidc.access_group_value');

        if (!requiredGroup) {
          logger.error('Group claim access control enabled but no required group configured');
          return false;
        }

        const userGroups = userinfo[claimName] || [];
        if (Array.isArray(userGroups)) {
          return userGroups.includes(requiredGroup);
        }
        return userGroups === requiredGroup;
      }

      case 'allowed_list': {
        const allowedEmailsStr = await this.configService.get('oidc.allowed_emails');
        const allowedUsernamesStr = await this.configService.get('oidc.allowed_usernames');

        const allowedEmails = allowedEmailsStr ? JSON.parse(allowedEmailsStr) : [];
        const allowedUsernames = allowedUsernamesStr ? JSON.parse(allowedUsernamesStr) : [];

        return (
          allowedEmails.includes(userinfo.email) ||
          allowedUsernames.includes(userinfo.preferred_username)
        );
      }

      case 'admin_approval':
        // Admin approval check happens in handleCallback
        return true;

      default:
        // If no method specified, default to open access
        return true;
    }
  }

  /**
   * Check if user should be granted admin role from OIDC claims
   */
  private async checkAdminClaim(userinfo: any): Promise<boolean> {
    const enabled = await this.configService.get('oidc.admin_claim_enabled');
    if (enabled !== 'true') {
      return false;
    }

    const claimName = (await this.configService.get('oidc.admin_claim_name')) || 'groups';
    const claimValue = await this.configService.get('oidc.admin_claim_value');

    if (!claimValue) {
      return false;
    }

    const userClaims = userinfo[claimName] || [];

    if (Array.isArray(userClaims)) {
      return userClaims.includes(claimValue);
    }

    return userClaims === claimValue;
  }

  /**
   * Find user by OIDC subject
   */
  private async findUserByOIDCSubject(oidcSubject: string) {
    return await prisma.user.findFirst({
      where: {
        oidcSubject,
        authProvider: 'oidc',
      },
    });
  }

  /**
   * Create pending user (for admin approval flow)
   */
  private async createPendingUser(
    oidcSubject: string,
    username: string,
    email: string | undefined,
    avatarUrl: string | undefined
  ) {
    const providerName = await this.configService.get('oidc.provider_name');

    await prisma.user.create({
      data: {
        plexId: oidcSubject, // Use oidcSubject as unique identifier
        plexUsername: username,
        plexEmail: email || null,
        role: 'user',
        isSetupAdmin: false,
        avatarUrl: avatarUrl || null,
        authProvider: 'oidc',
        oidcSubject,
        oidcProvider: providerName || 'unknown',
        registrationStatus: 'pending_approval',
        lastLoginAt: new Date(),
      },
    });
  }

  /**
   * Create or update user in database
   */
  private async createOrUpdateUser(
    oidcSubject: string,
    username: string,
    email: string | undefined,
    avatarUrl: string | undefined,
    isAdminFromClaim: boolean
  ): Promise<{ userInfo: UserInfo; isFirstLogin: boolean }> {
    const providerName = await this.configService.get('oidc.provider_name');

    // Check if this is the first user (should be promoted to admin)
    const userCount = await prisma.user.count();
    const isFirstUser = userCount === 0;
    const role = isFirstUser || isAdminFromClaim ? 'admin' : 'user';

    // Create or update user
    const user = await prisma.user.upsert({
      where: { plexId: oidcSubject },
      create: {
        plexId: oidcSubject, // Use oidcSubject as plexId for unique constraint
        plexUsername: username,
        plexEmail: email || null,
        role,
        isSetupAdmin: isFirstUser,
        avatarUrl: avatarUrl || null,
        authProvider: 'oidc',
        oidcSubject,
        oidcProvider: providerName || 'unknown',
        registrationStatus: 'approved',
        lastLoginAt: new Date(),
      },
      update: {
        plexUsername: username,
        plexEmail: email || null,
        avatarUrl: avatarUrl || null,
        oidcProvider: providerName || 'unknown',
        registrationStatus: 'approved',
        lastLoginAt: new Date(),
        // Update role if admin claim is present
        ...(isAdminFromClaim && { role: 'admin' }),
      },
    });

    // Track if we need to trigger initial jobs
    let shouldTriggerJobs = false;

    // If this is the first user, trigger initial jobs (Audible refresh + Library scan)
    // This happens after OIDC-only setup where no admin was created during wizard
    if (isFirstUser) {
      logger.info('First OIDC user created - triggering initial jobs');

      // Check if initial jobs have already been run (avoid duplicate runs)
      const initialJobsRun = await this.configService.get('system.initial_jobs_run');

      if (initialJobsRun !== 'true') {
        shouldTriggerJobs = true;

        // Trigger jobs in background (don't block authentication)
        this.triggerInitialJobs().catch(err => {
          logger.error('Failed to trigger initial jobs', { error: err instanceof Error ? err.message : String(err) });
        });
      }
    }

    return {
      userInfo: {
        id: user.id,
        username: user.plexUsername,
        email: user.plexEmail || undefined,
        avatarUrl: user.avatarUrl || undefined,
        role: user.role,
        authProvider: 'oidc',
      },
      isFirstLogin: isFirstUser && shouldTriggerJobs,
    };
  }

  /**
   * Trigger initial jobs (Audible refresh + Library scan) after first OIDC login
   * This is called automatically when the first user logs in via OIDC after setup
   */
  private async triggerInitialJobs(): Promise<void> {
    try {
      const schedulerService = getSchedulerService();

      // Get scheduled jobs by type
      const audibleJob = await prisma.scheduledJob.findFirst({
        where: { type: 'audible_refresh' },
      });

      const libraryJob = await prisma.scheduledJob.findFirst({
        where: { type: 'plex_library_scan' },
      });

      logger.info('Triggering initial jobs...');

      // Trigger Audible refresh
      if (audibleJob) {
        await schedulerService.triggerJobNow(audibleJob.id);
        logger.info('Triggered Audible refresh job');
      } else {
        logger.warn('Audible refresh job not found');
      }

      // Trigger Library scan
      if (libraryJob) {
        await schedulerService.triggerJobNow(libraryJob.id);
        logger.info('Triggered Library scan job');
      } else {
        logger.warn('Library scan job not found');
      }

      // Mark initial jobs as run
      await prisma.configuration.upsert({
        where: { key: 'system.initial_jobs_run' },
        update: { value: 'true' },
        create: { key: 'system.initial_jobs_run', value: 'true' },
      });

      logger.info('Initial jobs triggered successfully');
    } catch (error) {
      logger.error('Error triggering initial jobs', { error: error instanceof Error ? error.message : String(error) });
      throw error;
    }
  }

  /**
   * Generate JWT access and refresh tokens
   */
  private async generateTokens(userInfo: UserInfo): Promise<AuthTokens> {
    const accessToken = generateAccessToken({
      sub: userInfo.id,
      plexId: userInfo.id, // For backwards compatibility
      username: userInfo.username,
      role: userInfo.role || 'user',
    });

    const refreshToken = generateRefreshToken(userInfo.id);

    return {
      accessToken,
      refreshToken,
    };
  }

  /**
   * Refresh JWT tokens
   */
  async refreshToken(refreshToken: string): Promise<AuthTokens | null> {
    // JWT refresh is handled by existing JWT utilities
    // This method is a placeholder for future implementation
    return null;
  }

  /**
   * Validate user has access
   */
  async validateAccess(userInfo: UserInfo): Promise<boolean> {
    try {
      // Check if user exists and is approved
      const user = await prisma.user.findUnique({
        where: { id: userInfo.id },
      });

      if (!user || user.authProvider !== 'oidc') {
        return false;
      }

      if (user.registrationStatus === 'pending_approval' || user.registrationStatus === 'rejected') {
        return false;
      }

      return true;
    } catch (error) {
      logger.error('Access validation failed', { error: error instanceof Error ? error.message : String(error) });
      return false;
    }
  }

  /**
   * Clean up expired flow states
   */
  private cleanupExpiredStates(): void {
    const now = Date.now();
    for (const [state, flowState] of flowStateCache.entries()) {
      if (now - flowState.timestamp > FLOW_STATE_TTL) {
        flowStateCache.delete(state);
      }
    }
  }
}
