/**
 * CredentialBoundary: Allowlist-based secret injection for WASM tool sandbox.
 *
 * Creates Extism host functions that let WASM guests request secrets by key name.
 * Secrets are read from process.env on the host side only: the WASM guest never
 * sees process.env. Only keys explicitly listed in the allowlist are accessible.
 *
 * Access attempts (allowed and denied) are logged with key name only, never values.
 */

/**
 * @typedef {object} CredentialAccessLog
 * @property {string} toolName - Tool that requested the credential
 * @property {string} keyName - Secret key name requested
 * @property {'allowed'|'denied'} verdict - Whether access was granted
 * @property {string} reason - Why access was allowed or denied
 * @property {number} timestamp - Unix ms timestamp
 */

export class CredentialBoundary {
  /**
   * Allowlist mapping tool names to arrays of permitted secret key names.
   * @type {Map<string, Set<string>>}
   */
  #allowlist = new Map();

  /**
   * Access log for auditing. Key names only, never values.
   * @type {CredentialAccessLog[]}
   */
  #accessLog = [];

  /**
   * Optional external logger function.
   * @type {((entry: CredentialAccessLog) => void)|null}
   */
  #logger = null;

  /**
   * @param {Record<string, string[]>} allowlist - Map of tool names to permitted secret key arrays
   * @param {object} [options]
   * @param {(entry: CredentialAccessLog) => void} [options.logger] - External log handler
   */
  constructor(allowlist = {}, options = {}) {
    for (const [toolName, keys] of Object.entries(allowlist)) {
      if (!Array.isArray(keys)) {
        throw new Error(
          `CredentialBoundary: allowlist for tool "${toolName}" must be an array of key names`
        );
      }
      this.#allowlist.set(toolName, new Set(keys));
    }
    if (options.logger && typeof options.logger === 'function') {
      this.#logger = options.logger;
    }
  }

  /**
   * Check if a tool is allowed to access a specific secret key.
   *
   * @param {string} toolName
   * @param {string} keyName
   * @returns {boolean}
   */
  isAllowed(toolName, keyName) {
    const keys = this.#allowlist.get(toolName);
    return keys ? keys.has(keyName) : false;
  }

  /**
   * Get a secret value for a tool, enforcing the allowlist.
   * Returns the secret value if allowed, empty string if denied or not found.
   *
   * @param {string} toolName
   * @param {string} keyName
   * @returns {string} Secret value or empty string
   */
  getSecret(toolName, keyName) {
    if (typeof keyName !== 'string') {
      this.#log(toolName, String(keyName), 'denied', 'invalid key name type');
      return '';
    }

    if (!this.isAllowed(toolName, keyName)) {
      this.#log(toolName, keyName, 'denied', 'key not in allowlist');
      return '';
    }

    const value = process.env[keyName];
    if (value === undefined) {
      this.#log(toolName, keyName, 'allowed', 'key permitted but not set in environment');
      return '';
    }

    this.#log(toolName, keyName, 'allowed', 'secret injected');
    return value;
  }

  /**
   * Create Extism host functions for a specific tool.
   * Returns a functions object suitable for passing to createPlugin options.
   *
   * The WASM guest calls `get_secret` with a key name offset and receives
   * the secret value (or empty string) back via the host function return.
   *
   * @param {string} toolName - Tool identity for allowlist lookup
   * @returns {Record<string, Record<string, Function>>} Extism host functions config
   */
  createHostFunctions(toolName) {
    return {
      'extism:host/user': {
        /**
         * Host function: get_secret(key_name) -> secret_value
         * @param {import('@extism/extism').CurrentPlugin} cp
         * @param {bigint} keyOffset - Memory offset of the key name string
         * @returns {bigint} Memory offset of the secret value string
         */
        get_secret: (cp, keyOffset) => {
          let keyName;
          try {
            keyName = cp.read(keyOffset).text();
          } catch {
            this.#log(toolName, '<unreadable>', 'denied', 'failed to read key from guest memory');
            return cp.store('');
          }

          const value = this.getSecret(toolName, keyName);
          return cp.store(value);
        },
      },
    };
  }

  /**
   * Get the full access log (key names only, never values).
   * @returns {CredentialAccessLog[]}
   */
  getAccessLog() {
    return [...this.#accessLog];
  }

  /**
   * Clear the access log.
   */
  clearAccessLog() {
    this.#accessLog.length = 0;
  }

  /**
   * Get allowed keys for a tool.
   * @param {string} toolName
   * @returns {string[]} Array of allowed key names, empty if tool not registered
   */
  getAllowedKeys(toolName) {
    const keys = this.#allowlist.get(toolName);
    return keys ? [...keys] : [];
  }

  /**
   * @param {string} toolName
   * @param {string} keyName
   * @param {'allowed'|'denied'} verdict
   * @param {string} reason
   */
  #log(toolName, keyName, verdict, reason) {
    /** @type {CredentialAccessLog} */
    const entry = {
      toolName,
      keyName,
      verdict,
      reason,
      timestamp: Date.now(),
    };
    this.#accessLog.push(entry);
    if (this.#logger) {
      try {
        this.#logger(entry);
      } catch {
        // Don't let logger errors break credential operations
      }
    }
  }
}
