/**
 * OS-native credential storage with file-based fallback.
 *
 * Uses CLI tools (no native bindings / node-gyp) so the public npm package
 * installs cleanly on all platforms:
 *   macOS:   /usr/bin/security (Keychain Services)
 *   Linux:   secret-tool (libsecret)
 *   Windows: powershell cmdkey + dpapi
 *
 * Falls back to ~/.cielara/connector.json (0o600) when CLI tools are
 * unavailable (headless servers, CI, Docker).
 */

const { execFile } = require('node:child_process');
const { promisify } = require('node:util');
const { platform } = require('node:os');
const { readConfig, writeConfig } = require('./config');

const execFileAsync = promisify(execFile);

const SERVICE = 'cielara-code';

// Hard ceiling on any single keychain CLI invocation. The OS credential tools
// can block indefinitely rather than fail — notably the Windows WinRT
// PasswordVault, which hangs in non-interactive sessions (RDP, service hosts,
// SSH) because it needs an interactive desktop. Without a timeout a single
// hung child wedges `cielara login` forever. On timeout execFile kills the
// child and rejects, and every caller below already treats a rejection as
// "backend unavailable" and falls back to the 0o600 config file.
const KEYCHAIN_TIMEOUT_MS = 15_000;

// Whether to bypass the OS keychain and store credentials in the 0o600 config
// file. The explicit CIELARA_CRED_STORE override always wins
// ('file' = force file, 'keychain' = force vault); otherwise auto-detect
// environments where the OS keychain can't function so we never pay the
// per-call timeout (the WinRT PasswordVault hang) and degrade straight to the
// file. Useful for headless servers / Docker / CI / RDP.
function fileOnly() {
  const env = process.env.CIELARA_CRED_STORE;
  if (env === 'file') return true;
  if (env === 'keychain') return false; // explicit opt-IN escape hatch
  return keychainUnavailable();
}

// Heuristic: detect sessions where the OS keychain blocks or is absent. The
// KEYCHAIN_TIMEOUT_MS ceiling is the real safety net; this is purely a
// latency/UX optimization, so err toward the file store when unsure.
function keychainUnavailable() {
  if (process.platform === 'win32') {
    // Windows WinRT PasswordVault needs an interactive desktop session. In RDP,
    // service hosts, SSH, and most CI it blocks forever, so prefer the file
    // store. SESSIONNAME like "RDP-Tcp#1" => remote; absent => service/SSH.
    const session = process.env.SESSIONNAME || '';
    const isRemote = /^RDP-/i.test(session);
    const isCI = !!process.env.CI;
    const noInteractive = !process.env.SESSIONNAME;
    if (isRemote || isCI || noInteractive) return true;
  }
  // Linux: no D-Bus session bus => no Secret Service agent (headless).
  if (process.platform === 'linux' && !process.env.DBUS_SESSION_BUS_ADDRESS) {
    return true;
  }
  return false;
}

// ─── Public API ────────────────────────────────────────────────────────

async function storeCredentials(account, credentials) {
  if (fileOnly()) {
    await storeToFile(account, credentials);
    return;
  }
  const json = JSON.stringify(credentials);
  let backend;
  try {
    backend = getBackend();
    await backend.store(account, json);
    // Verify the OS keychain can actually read back exactly what we wrote.
    // Some backends (notably the Windows PowerShell/PasswordVault path) can
    // "succeed" on store yet return an empty or different blob on read. Without
    // this check the credential is silently lost and every later license check
    // fails with NO_CONNECTION even though login reported success.
    const readBack = await backend.retrieve(account);
    if (readBack === json) return; // keychain holds a correct, readable copy
  } catch {
    // Backend unavailable or threw — fall through to the file fallback.
  }
  // Keychain unavailable or failed to round-trip. Remove any partial/garbage
  // entry so retrieveCredentials doesn't prefer it over the file, then persist
  // to the 0o600 config file.
  if (backend) {
    try { await backend.remove(account); } catch { /* best-effort */ }
  }
  await storeToFile(account, credentials);
}

async function retrieveCredentials(account) {
  if (fileOnly()) {
    return retrieveFromFile(account);
  }
  try {
    const backend = getBackend();
    const json = await backend.retrieve(account);
    if (json) {
      try {
        const parsed = JSON.parse(json);
        // A { revoked: true } entry means logout ran but couldn't delete the
        // keychain entry — treat it as if no credentials exist.
        if (parsed?.revoked) return null;
        return parsed;
      } catch {
        // Garbage in the keychain — fall through to the file fallback rather
        // than failing outright.
      }
    }
  } catch {
    // Backend unavailable — fall through to the file fallback.
  }
  // Keychain had nothing usable (missing or unparseable). The file fallback is
  // where storeCredentials lands when the keychain can't round-trip, so it must
  // be consulted here too — returning null without checking it was the bug that
  // left logged-in users stuck on "No active connection".
  return retrieveFromFile(account);
}

async function removeCredentials(account) {
  if (!fileOnly()) {
    let backend;
    try {
      backend = getBackend();
      await backend.remove(account);
    } catch {
      // Best-effort — removal may fail if entry doesn't exist.
    }
    // Verify the removal actually took effect. If the credential is still
    // readable, overwrite it with a revocation marker so retrieveCredentials
    // won't return it as a valid session even though the delete failed.
    if (backend) {
      try {
        const remaining = await backend.retrieve(account);
        if (remaining) {
          await backend.store(account, JSON.stringify({ revoked: true }));
        }
      } catch {
        // Best-effort
      }
    }
  }
  // Also clear from file if present
  await removeFromFile(account);
}

// ─── Platform backends ─────────────────────────────────────────────────

function getBackend() {
  switch (platform()) {
    case 'darwin':  return macOSBackend;
    case 'linux':   return linuxBackend;
    case 'win32':   return windowsBackend;
    default:        throw new Error('unsupported platform');
  }
}

const macOSBackend = {
  async store(account, json) {
    try {
      // Delete existing entry first (add-generic-password fails on duplicate)
      await execFileAsync('/usr/bin/security', [
        'delete-generic-password', '-a', account, '-s', SERVICE,
      ], { timeout: KEYCHAIN_TIMEOUT_MS });
    } catch {
      // Not found — fine
    }
    await execFileAsync('/usr/bin/security', [
      'add-generic-password', '-a', account, '-s', SERVICE, '-w', json, '-U',
    ], { timeout: KEYCHAIN_TIMEOUT_MS });
  },

  async retrieve(account) {
    try {
      const { stdout } = await execFileAsync('/usr/bin/security', [
        'find-generic-password', '-a', account, '-s', SERVICE, '-w',
      ], { timeout: KEYCHAIN_TIMEOUT_MS });
      return stdout.trim();
    } catch {
      return null;
    }
  },

  async remove(account) {
    await execFileAsync('/usr/bin/security', [
      'delete-generic-password', '-a', account, '-s', SERVICE,
    ], { timeout: KEYCHAIN_TIMEOUT_MS });
  },
};

const linuxBackend = {
  async store(account, json) {
    await execFileAsync('secret-tool', [
      'store', '--label=Cielara Code', 'service', SERVICE, 'account', account,
    ], { input: json, timeout: KEYCHAIN_TIMEOUT_MS });
  },

  async retrieve(account) {
    try {
      const { stdout } = await execFileAsync('secret-tool', [
        'lookup', 'service', SERVICE, 'account', account,
      ], { timeout: KEYCHAIN_TIMEOUT_MS });
      return stdout.trim() || null;
    } catch {
      return null;
    }
  },

  async remove(account) {
    await execFileAsync('secret-tool', [
      'clear', 'service', SERVICE, 'account', account,
    ], { timeout: KEYCHAIN_TIMEOUT_MS });
  },
};

const windowsBackend = {
  async store(account, json) {
    // Read the credential blob from stdin as a single string. `$input` is a
    // pipeline *enumerator*, not a string — passing it straight to the
    // PasswordCredential constructor (or `cmdkey /pass`) stored a coerced
    // enumerator, not the JSON, so the value could never be read back. Use
    // [Console]::In.ReadToEnd() to get the exact payload.
    //
    // We store via the WinRT PasswordVault only. cmdkey GENERIC credentials
    // cannot be read back as plaintext, so a cmdkey fallback would write a
    // value retrieve() could never recover — worse than failing, because it
    // looks stored. If the vault is unavailable the script throws and
    // storeCredentials falls back to the 0o600 config file.
    const script = `
$ErrorActionPreference = 'Stop'
[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime]
$json = [Console]::In.ReadToEnd()
$vault = New-Object Windows.Security.Credentials.PasswordVault
try { $old = $vault.Retrieve('${SERVICE}', '${account}'); $vault.Remove($old) } catch {}
$cred = New-Object Windows.Security.Credentials.PasswordCredential('${SERVICE}', '${account}', $json)
$vault.Add($cred)
`;
    await execFileAsync('powershell', ['-NoProfile', '-Command', script], {
      input: json,
      timeout: KEYCHAIN_TIMEOUT_MS,
    });
  },

  async retrieve(account) {
    const script = `
$ErrorActionPreference = 'Stop'
[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime]
try {
  $vault = New-Object Windows.Security.Credentials.PasswordVault
  $cred = $vault.Retrieve('${SERVICE}', '${account}')
  $cred.RetrievePassword()
  [Console]::Out.Write($cred.Password)
} catch {
  exit 1
}
`;
    try {
      const { stdout } = await execFileAsync('powershell', [
        '-NoProfile', '-Command', script,
      ], { timeout: KEYCHAIN_TIMEOUT_MS });
      return stdout.trim() || null;
    } catch {
      return null;
    }
  },

  async remove(account) {
    const script = `
[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime] 2>$null
try {
  $vault = New-Object Windows.Security.Credentials.PasswordVault
  $cred = $vault.Retrieve('${SERVICE}', '${account}')
  $vault.Remove($cred)
} catch {}
`;
    try {
      await execFileAsync('powershell', ['-NoProfile', '-Command', script], {
        timeout: KEYCHAIN_TIMEOUT_MS,
      });
    } catch {
      // Not found
    }
  },
};

// ─── File fallback ─────────────────────────────────────────────────────

async function storeToFile(account, credentials) {
  const config = readConfig();
  if (!config.connections) config.connections = {};
  if (!config.connections[account]) config.connections[account] = {};
  Object.assign(config.connections[account], credentials);
  config.active = account;
  writeConfig(config);
}

function retrieveFromFile(account) {
  const config = readConfig();
  const conn = config.connections?.[account];
  if (!conn?.accessToken) return null;
  return conn;
}

async function removeFromFile(account) {
  const config = readConfig();
  if (config.connections?.[account]) {
    delete config.connections[account];
    if (config.active === account) {
      const remaining = Object.keys(config.connections);
      config.active = remaining[0] || '';
    }
    writeConfig(config);
  }
}

module.exports = {
  storeCredentials,
  retrieveCredentials,
  removeCredentials,
  // Exported for tests: the keychain-availability heuristic.
  fileOnly,
  keychainUnavailable,
};
