/**
 * Vault Service
 *
 * Handles OpenBao Transit encryption with fallback to CryptoService.
 * Provides transparent encryption/decryption with auto-detection of ciphertext format.
 */

import { Injectable, Logger, OnModuleDestroy } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { CryptoService } from "../federation/crypto.service";
import {
  TransitKey,
  DEFAULT_OPENBAO_ADDR,
  APPROLE_CREDENTIALS_PATH,
  TOKEN_RENEWAL_THRESHOLD,
  TOKEN_TTL_SECONDS,
} from "./vault.constants";
import { readFile } from "fs/promises";

interface AppRoleCredentials {
  role_id: string;
  secret_id: string;
}

interface VaultAuthResponse {
  auth?: {
    client_token?: string;
    lease_duration?: number;
  };
}

interface TransitEncryptResponse {
  data?: {
    ciphertext?: string;
  };
}

interface TransitDecryptResponse {
  data?: {
    plaintext?: string;
  };
}

export interface VaultStatus {
  available: boolean;
  fallbackMode: boolean;
  endpoint: string;
}

@Injectable()
export class VaultService implements OnModuleDestroy {
  private readonly logger = new Logger(VaultService.name);
  private readonly openbaoAddr: string;
  private token: string | null = null;
  private tokenExpiry: number | null = null;
  private renewalTimer: NodeJS.Timeout | null = null;
  private isAvailable = false;

  constructor(
    private readonly config: ConfigService,
    private readonly cryptoService: CryptoService
  ) {
    this.openbaoAddr = this.config.get<string>("OPENBAO_ADDR") ?? DEFAULT_OPENBAO_ADDR;

    // Initialize asynchronously (don't block module initialization)
    this.initPromise = this.initialize().catch((error: unknown) => {
      const errorMsg = error instanceof Error ? error.message : "Unknown error";
      this.logger.warn(`OpenBao initialization failed: ${errorMsg}`);
      this.logger.log("Fallback mode enabled: using AES-256-GCM encryption");
      this.isAvailable = false;
    });
  }

  private initPromise: Promise<void>;

  /**
   * Initialize OpenBao connection and authenticate
   */
  private async initialize(): Promise<void> {
    try {
      await this.authenticate();
      this.isAvailable = true;
      this.logger.log(`OpenBao Transit encryption enabled (${this.openbaoAddr})`);
      this.scheduleTokenRenewal();
    } catch (error) {
      this.isAvailable = false;
      this.logger.warn("OpenBao unavailable, using fallback encryption");
      throw error;
    }
  }

  /**
   * Wait for initialization to complete (useful for testing)
   */
  async waitForInitialization(): Promise<void> {
    await this.initPromise;
  }

  /**
   * Fetch with timeout protection
   * Prevents indefinite hangs if OpenBao becomes unresponsive
   *
   * @param url - URL to fetch
   * @param options - Fetch options
   * @param timeoutMs - Timeout in milliseconds (default: 5000ms)
   * @returns Response
   * @throws Error if request times out or fails
   */
  private async fetchWithTimeout(
    url: string,
    options: RequestInit = {},
    timeoutMs = 5000
  ): Promise<Response> {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => {
      controller.abort();
    }, timeoutMs);

    try {
      const response = await fetch(url, {
        ...options,
        signal: controller.signal,
      });
      return response;
    } catch (error: unknown) {
      if (error instanceof Error && error.name === "AbortError") {
        throw new Error(`Request timeout after ${String(timeoutMs)}ms: ${url}`);
      }
      throw error;
    } finally {
      clearTimeout(timeoutId);
    }
  }

  /**
   * Authenticate using AppRole
   */
  private async authenticate(): Promise<void> {
    const credentials = await this.getAppRoleCredentials();

    const response = await this.fetchWithTimeout(`${this.openbaoAddr}/v1/auth/approle/login`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        role_id: credentials.role_id,
        secret_id: credentials.secret_id,
      }),
    });

    if (!response.ok) {
      throw new Error(
        `AppRole authentication failed: ${String(response.status)} ${response.statusText}`
      );
    }

    const data = (await response.json()) as VaultAuthResponse;

    if (!data.auth?.client_token || !data.auth.lease_duration) {
      throw new Error("AppRole authentication response missing required fields");
    }

    this.token = data.auth.client_token;
    this.tokenExpiry = Date.now() + data.auth.lease_duration * 1000;

    this.logger.log("AppRole authentication successful");
  }

  /**
   * Get AppRole credentials from file or environment
   */
  private async getAppRoleCredentials(): Promise<AppRoleCredentials> {
    // Try environment variables first
    const envRoleId = this.config.get<string>("OPENBAO_ROLE_ID");
    const envSecretId = this.config.get<string>("OPENBAO_SECRET_ID");

    if (envRoleId && envSecretId) {
      return {
        role_id: envRoleId,
        secret_id: envSecretId,
      };
    }

    // Try credentials file
    try {
      // eslint-disable-next-line security/detect-non-literal-fs-filename
      const fileContents = await readFile(APPROLE_CREDENTIALS_PATH, "utf-8");
      const credentials = JSON.parse(fileContents) as AppRoleCredentials;

      if (!credentials.role_id || !credentials.secret_id) {
        throw new Error("Credentials file missing required fields");
      }

      return credentials;
    } catch (error) {
      const errorMsg = error instanceof Error ? error.message : "Unknown error";
      throw new Error(
        `Failed to read AppRole credentials from ${APPROLE_CREDENTIALS_PATH}: ${errorMsg}. ` +
          "Set OPENBAO_ROLE_ID and OPENBAO_SECRET_ID environment variables as fallback."
      );
    }
  }

  /**
   * Schedule token renewal at 50% TTL
   */
  private scheduleTokenRenewal(): void {
    if (!this.tokenExpiry || !this.token) {
      return;
    }

    const ttl = this.tokenExpiry - Date.now();
    const renewalTime = ttl * TOKEN_RENEWAL_THRESHOLD;

    if (renewalTime <= 0) {
      // Token already expired or renewal threshold passed
      this.checkTokenRenewal().catch((error: unknown) => {
        const errorMsg = error instanceof Error ? error.message : "Unknown error";
        this.logger.error(`Token renewal failed: ${errorMsg}`);
      });
      return;
    }

    this.renewalTimer = setTimeout(() => {
      this.checkTokenRenewal().catch((error: unknown) => {
        const errorMsg = error instanceof Error ? error.message : "Unknown error";
        this.logger.error(`Token renewal failed: ${errorMsg}`);
      });
    }, renewalTime);
  }

  /**
   * Check if token needs renewal and renew if necessary
   */
  private async checkTokenRenewal(): Promise<void> {
    if (!this.token || !this.tokenExpiry) {
      return;
    }

    const remainingTtl = this.tokenExpiry - Date.now();
    const totalTtl = TOKEN_TTL_SECONDS * 1000;
    const threshold = totalTtl * TOKEN_RENEWAL_THRESHOLD;

    if (remainingTtl < threshold) {
      await this.renewToken();
    }
  }

  /**
   * Renew the current token
   */
  private async renewToken(): Promise<void> {
    if (!this.token) {
      throw new Error("No token to renew");
    }

    try {
      const response = await this.fetchWithTimeout(`${this.openbaoAddr}/v1/auth/token/renew-self`, {
        method: "POST",
        headers: {
          "X-Vault-Token": this.token,
          "Content-Type": "application/json",
        },
      });

      if (!response.ok) {
        // Token renewal failed, try to re-authenticate
        this.logger.warn("Token renewal failed, attempting re-authentication");
        await this.authenticate();
        this.scheduleTokenRenewal();
        return;
      }

      const data = (await response.json()) as VaultAuthResponse;

      if (!data.auth?.client_token || !data.auth.lease_duration) {
        throw new Error("Token renewal response missing required fields");
      }

      this.token = data.auth.client_token;
      this.tokenExpiry = Date.now() + data.auth.lease_duration * 1000;

      this.logger.log("Token renewed successfully");
      this.scheduleTokenRenewal();
    } catch (error: unknown) {
      const errorMsg = error instanceof Error ? error.message : "Unknown";
      this.logger.error(`Token renewal error: ${errorMsg}`);
      // Try to re-authenticate
      await this.authenticate();
      this.scheduleTokenRenewal();
    }
  }

  /**
   * Encrypt data using OpenBao Transit or fallback to CryptoService
   *
   * @param plaintext - Data to encrypt
   * @param key - Transit key to use
   * @returns Ciphertext with format prefix (vault:v1: or iv:tag:)
   */
  async encrypt(plaintext: string, key: TransitKey): Promise<string> {
    if (!plaintext) {
      throw new Error("Cannot encrypt empty string");
    }

    // Use fallback if OpenBao is unavailable
    if (!this.isAvailable || !this.token) {
      this.logger.error(
        `OpenBao unavailable for encryption (key: ${key}). Using fallback AES-256-GCM. ` +
          "This indicates an infrastructure problem that should be investigated."
      );
      return this.cryptoService.encrypt(plaintext);
    }

    try {
      // Encode plaintext to base64
      const encodedPlaintext = Buffer.from(plaintext).toString("base64");

      const response = await this.fetchWithTimeout(
        `${this.openbaoAddr}/v1/transit/encrypt/${key}`,
        {
          method: "POST",
          headers: {
            "X-Vault-Token": this.token,
            "Content-Type": "application/json",
          },
          body: JSON.stringify({ plaintext: encodedPlaintext }),
        }
      );

      if (!response.ok) {
        throw new Error(
          `Transit encrypt failed: ${String(response.status)} ${response.statusText}`
        );
      }

      const data = (await response.json()) as TransitEncryptResponse;

      if (!data.data?.ciphertext) {
        throw new Error("Transit encrypt response missing ciphertext");
      }

      return data.data.ciphertext;
    } catch (error: unknown) {
      const errorMsg = error instanceof Error ? error.message : "Unknown";
      this.logger.error(
        `Transit encryption failed for ${key}: ${errorMsg}. Using fallback AES-256-GCM. ` +
          "Check OpenBao connectivity and logs."
      );
      return this.cryptoService.encrypt(plaintext);
    }
  }

  /**
   * Decrypt data using OpenBao Transit or CryptoService
   *
   * Auto-detects ciphertext format:
   * - vault:v1: prefix = OpenBao Transit
   * - iv:tag:encrypted format = AES-256-GCM
   *
   * @param ciphertext - Encrypted data with format prefix
   * @param key - Transit key to use (only used for vault:v1: format)
   * @returns Decrypted plaintext
   */
  async decrypt(ciphertext: string, key: TransitKey): Promise<string> {
    if (!ciphertext) {
      throw new Error("Cannot decrypt empty string");
    }

    // Detect format
    const isVaultFormat = ciphertext.startsWith("vault:v1:");

    if (isVaultFormat) {
      // OpenBao Transit format
      if (!this.isAvailable || !this.token) {
        throw new Error(
          "Cannot decrypt vault:v1: ciphertext: OpenBao is unavailable. " +
            "Ensure OpenBao is running or re-encrypt with available encryption service."
        );
      }

      try {
        const response = await this.fetchWithTimeout(
          `${this.openbaoAddr}/v1/transit/decrypt/${key}`,
          {
            method: "POST",
            headers: {
              "X-Vault-Token": this.token,
              "Content-Type": "application/json",
            },
            body: JSON.stringify({ ciphertext }),
          }
        );

        if (!response.ok) {
          throw new Error(
            `Transit decrypt failed: ${String(response.status)} ${response.statusText}`
          );
        }

        const data = (await response.json()) as TransitDecryptResponse;

        if (!data.data?.plaintext) {
          throw new Error("Transit decrypt response missing plaintext");
        }

        // Decode base64 plaintext
        return Buffer.from(data.data.plaintext, "base64").toString("utf-8");
      } catch (error: unknown) {
        const errorMsg = error instanceof Error ? error.message : "Unknown";
        this.logger.error(`Transit decryption failed for ${key}: ${errorMsg}`);
        throw new Error("Failed to decrypt data");
      }
    } else {
      // AES-256-GCM format (fallback)
      try {
        return this.cryptoService.decrypt(ciphertext);
      } catch (error: unknown) {
        const errorMsg = error instanceof Error ? error.message : "Unknown";
        this.logger.error(`AES decryption failed: ${errorMsg}`);
        throw new Error("Failed to decrypt data");
      }
    }
  }

  /**
   * Get OpenBao service status
   */
  getStatus(): VaultStatus {
    return {
      available: this.isAvailable,
      fallbackMode: !this.isAvailable,
      endpoint: this.openbaoAddr,
    };
  }

  /**
   * Cleanup on module destroy
   */
  onModuleDestroy(): void {
    if (this.renewalTimer) {
      clearTimeout(this.renewalTimer);
    }
  }
}
