import fs from 'node:fs';
import path from 'node:path';
import type {
  EncryptedVaultFile,
  EncryptedVaultEntry,
  ResolvedCredentials,
} from '@zyphon/shared-types';
import {
  encryptAES256GCM,
  decryptAES256GCM,
  resolveMasterKey,
} from '@zyphon/crypto';

export interface VaultMetadataSummary {
  provider_id: string;
  version: number;
  meta: Record<string, string | number | boolean>;
  rotated_at: number;
  uses: number;
  last_used?: number;
}

export class CredentialVault {
  private vaultFilePath: string;
  private masterKey: Buffer;
  private entries = new Map<string, EncryptedVaultEntry>();
  private decryptedCache = new Map<string, ResolvedCredentials>();
  private maxBackups = 5;

  constructor(vaultFilePath = './data/state/vault.enc.json', masterKey?: Buffer) {
    this.vaultFilePath = path.resolve(vaultFilePath);
    this.masterKey = masterKey ?? resolveMasterKey();
    this.ensureDirectory();
    this.load();
  }

  private ensureDirectory(): void {
    const dir = path.dirname(this.vaultFilePath);
    if (!fs.existsSync(dir)) {
      fs.mkdirSync(dir, { recursive: true });
    }
  }

  private load(): void {
    if (!fs.existsSync(this.vaultFilePath)) {
      this.save();
      return;
    }

    try {
      const raw = fs.readFileSync(this.vaultFilePath, 'utf8');
      const data: EncryptedVaultFile = JSON.parse(raw);
      this.entries.clear();
      this.decryptedCache.clear();

      for (const [providerId, entry] of Object.entries(data.entries || {})) {
        this.entries.set(providerId, entry);
      }
    } catch (err: any) {
      throw new Error(`Failed to load encrypted vault file from ${this.vaultFilePath}: ${err.message}`);
    }
  }

  private save(): void {
    const fileContent: EncryptedVaultFile = {
      format_version: 1,
      updated_at: Date.now(),
      entries: Object.fromEntries(this.entries.entries()),
    };

    // Rotate backups before saving
    if (fs.existsSync(this.vaultFilePath)) {
      this.rotateBackups();
    }

    const tmpPath = `${this.vaultFilePath}.tmp.${Date.now()}`;
    try {
      fs.writeFileSync(tmpPath, JSON.stringify(fileContent, null, 2), {
        encoding: 'utf8',
        mode: 0o600,
      });
      fs.renameSync(tmpPath, this.vaultFilePath);
      fs.chmodSync(this.vaultFilePath, 0o600);
    } catch (err: any) {
      try {
        if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
      } catch {}
      throw new Error(`Failed to atomically save vault file: ${err.message}`);
    }
  }

  private rotateBackups(): void {
    try {
      for (let i = this.maxBackups - 1; i >= 1; i--) {
        const older = `${this.vaultFilePath}.bak.${i}`;
        const newer = `${this.vaultFilePath}.bak.${i + 1}`;
        if (fs.existsSync(older)) {
          fs.copyFileSync(older, newer);
        }
      }
      fs.copyFileSync(this.vaultFilePath, `${this.vaultFilePath}.bak.1`);
    } catch {}
  }

  public getSecret(providerId: string): ResolvedCredentials | null {
    if (this.decryptedCache.has(providerId)) {
      const entry = this.entries.get(providerId);
      if (entry) {
        entry.uses++;
        entry.last_used = Date.now();
      }
      return this.decryptedCache.get(providerId)!;
    }

    const entry = this.entries.get(providerId);
    if (!entry) return null;

    try {
      const decryptedJson = decryptAES256GCM(
        {
          iv: entry.iv,
          tag: entry.tag,
          ciphertext: entry.ciphertext,
        },
        this.masterKey
      );

      const secretPayload: Record<string, string | number | boolean> = JSON.parse(decryptedJson);
      const combined: ResolvedCredentials = {
        ...entry.meta,
        ...secretPayload,
      };

      entry.uses++;
      entry.last_used = Date.now();

      this.decryptedCache.set(providerId, combined);
      return combined;
    } catch (err: any) {
      throw new Error(`Decryption failed for provider ${providerId}: ${err.message}`);
    }
  }

  public setSecret(
    providerId: string,
    secrets: Record<string, string | number | boolean>,
    meta: Record<string, string | number | boolean> = {}
  ): void {
    const existing = this.entries.get(providerId);
    const version = existing ? existing.version + 1 : 1;
    const uses = existing ? existing.uses : 0;

    const plaintext = JSON.stringify(secrets);
    const encrypted = encryptAES256GCM(plaintext, this.masterKey);

    const entry: EncryptedVaultEntry = {
      provider_id: providerId,
      version,
      iv: encrypted.iv,
      tag: encrypted.tag,
      ciphertext: encrypted.ciphertext,
      meta,
      rotated_at: Date.now(),
      uses,
    };

    this.entries.set(providerId, entry);
    // Invalidate cached credentials
    this.decryptedCache.delete(providerId);
    this.save();
  }

  public deleteSecret(providerId: string): boolean {
    const existed = this.entries.delete(providerId);
    this.decryptedCache.delete(providerId);
    if (existed) {
      this.save();
    }
    return existed;
  }

  /** True when an encrypted vault entry exists (does not decrypt). */
  public hasSecret(providerId: string): boolean {
    return this.entries.has(providerId);
  }

  /** Update vault metadata (e.g. display name) without rotating encrypted secrets. */
  public updateMeta(
    providerId: string,
    metaPatch: Record<string, string | number | boolean>,
  ): boolean {
    const entry = this.entries.get(providerId);
    if (!entry) return false;
    entry.meta = { ...entry.meta, ...metaPatch };
    this.decryptedCache.delete(providerId);
    this.save();
    return true;
  }

  public getMeta(providerId: string): Record<string, string | number | boolean> | null {
    const entry = this.entries.get(providerId);
    return entry ? { ...entry.meta } : null;
  }

  public listMetadata(): VaultMetadataSummary[] {
    return Array.from(this.entries.values()).map((entry) => ({
      provider_id: entry.provider_id,
      version: entry.version,
      meta: { ...entry.meta },
      rotated_at: entry.rotated_at,
      uses: entry.uses,
      last_used: entry.last_used,
    }));
  }

  public rotateMasterKey(newMasterKey: Buffer): void {
    if (newMasterKey.length !== 32) {
      throw new Error('New master key must be exactly 32 bytes');
    }

    // Decrypt all entries with current key, re-encrypt with new key
    const reEncryptedEntries = new Map<string, EncryptedVaultEntry>();
    for (const [providerId, entry] of this.entries.entries()) {
      const decryptedJson = decryptAES256GCM(
        {
          iv: entry.iv,
          tag: entry.tag,
          ciphertext: entry.ciphertext,
        },
        this.masterKey
      );

      const encrypted = encryptAES256GCM(decryptedJson, newMasterKey);
      reEncryptedEntries.set(providerId, {
        ...entry,
        iv: encrypted.iv,
        tag: encrypted.tag,
        ciphertext: encrypted.ciphertext,
        rotated_at: Date.now(),
      });
    }

    this.entries = reEncryptedEntries;
    this.masterKey = newMasterKey;
    this.decryptedCache.clear();
    this.save();
  }
}

