/**
 * engine/ado-git-auth.js — W-mpcuc8i80003a7b3
 *
 * Inject a per-invocation Authorization: Bearer <token> http.extraHeader into
 * git ops that touch an ADO origin. Solves the headless-dispatch failure mode
 * where Git Credential Manager falls back to a TTY prompt (because the engine
 * has no terminal) and emits:
 *
 *   fatal: could not read Username for 'https://office.visualstudio.com'
 *
 * The token is sourced via the existing `engine/ado-token.js#acquireAdoTokenSync`
 * helper (az CLI first, azureauth fallback). We cache it for 30 minutes and
 * back off acquisition retries for 10 minutes on failure so we don't hammer
 * az / azureauth on every git op during an outage.
 *
 * Public API:
 *   - getAdoGitExtraArgs(project, opts?) → string[]
 *       Sync. Returns `['-c', 'http.<scope>.extraHeader=Authorization: Bearer <token>']`
 *       for ADO projects, `[]` for everything else (GitHub/local/null). Plumbed
 *       into shared.shellSafeGit via `opts.gitExtraArgs`.
 *   - invalidateAdoTokenCache()
 *       Drops the cached token so the next getAdoGitExtraArgs() re-acquires.
 *       Used by runAdoGit on auth failure to handle mid-dispatch token expiry.
 *   - isAdoAuthFailure(err) → boolean
 *       Pure. Matches credential/auth-specific phrases against err.message,
 *       err.stdout, err.stderr. Deliberately NARROW — does not match bare
 *       "fatal: unable to access" since that also fires for DNS/TLS/proxy.
 *   - runAdoGit(project, gitArgs, opts) → Promise<string>
 *       Async wrapper around shellSafeGit. On ADO auth failure, invalidates
 *       cache, re-acquires, retries ONCE in the same dispatch. Redacts the
 *       bearer token from rethrown error messages so the token never lands in
 *       logs / inbox alerts / completion reports.
 *
 * Argv-vs-env tradeoff: putting `-c http.extraHeader=Authorization: Bearer X`
 * in argv is visible via process listing. The simpler alternative
 * `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=... GIT_CONFIG_VALUE_0=...` hides the
 * token from `ps`. We chose argv to match the documented manual-verification
 * recipe (matches the task spec) and explicitly redact on error rethrow.
 * Migrate to env-form if argv exposure becomes a real concern.
 */

const shared = require('./shared');
const adoToken = require('./ado-token');
const { log } = shared;

const TOKEN_TTL_MS = 30 * 60 * 1000;     // 30 min — matches gh-token cadence
const ACQUIRE_BACKOFF_MS = 10 * 60 * 1000; // 10 min — same backoff as gh-token
const ACQUIRE_TIMEOUT_MS = 30000;        // 30s — generous for slow IWA/broker auth

// #2762 — Suppress Windows credential helpers (GCM/WAM broker) and any
// interactive credential prompt for the lifetime of each ADO git invocation.
// Without these, when the bearer extraHeader doesn't cover a needed host
// (e.g. Scalar/GVFS cache-server like *.gvfscache.dev.azure.com), git falls
// through to `credential.helper=git-credential-manager`. On Windows with
// `credential.msauthusebroker=true` in the user's global config, GCM invokes
// the WAM broker which pops a focus-stealing "Account Manager" dialog mid-task.
// Setting credential.helper to an empty string disables ALL helpers for this
// command only (no global config mutation); credential.interactive=false
// keeps git from falling back to TTY prompts. Together they convert an
// otherwise-silent hang behind a UI dialog into an immediate, debuggable 401.
const CREDENTIAL_DISABLE_ARGS = [
  '-c', 'credential.helper=',
  '-c', 'credential.interactive=false',
];

let _cached = null;       // { token, expiresAt }
let _backoffUntil = 0;    // epoch ms — no acquire attempts before this

// Credential/auth patterns. Narrow on purpose: do NOT include bare
// "fatal: unable to access" — that also fires for DNS, TLS, and proxy errors
// which are correctly classified elsewhere (NETWORK_ERROR, retryable).
const ADO_AUTH_PATTERNS = [
  /could not read Username/i,
  /could not read Password/i,
  /Authentication failed/i,
  /terminal prompts disabled/i,
  /TF40081\d/i,                          // Azure DevOps auth error family
  /HTTP\s*4(?:01|03)\b/i,                // explicit 401/403 in error text
];

function isAdoProject(project) {
  return !!(project && project.repoHost === 'ado');
}

// Scope the header to the ADO host(s) so a misconfigured remote pointing at
// another host (e.g. github.com on a misclassified project) doesn't leak the
// bearer token. Falls back to the unscoped `http.extraHeader` key when we
// can't compute any host (still safe — git only sends extraHeader on HTTP/S
// transfers, and the engine only invokes ado-git-auth for ADO projects).
//
// W-mped1zap00069ea5 — ADO has TWO canonical URL forms for the same org:
//   - https://dev.azure.com/<org>/...          (modern, default for new clones)
//   - https://<org>.visualstudio.com/...       (legacy alias, still active)
// Git scopes `http.<url>.extraHeader` by URL PREFIX match: a header scoped to
// `office.visualstudio.com` is silently dropped when the remote is
// `dev.azure.com/office` (and vice-versa). The bearer token is identical for
// either form, so we emit the header under BOTH scopes to cover whichever
// alias the project's git remote happens to use.
function _resolveScopeUrls(project) {
  try {
    if (!isAdoProject(project) || !project.adoOrg) return [];
    const scopes = new Set();

    // Always include the modern dev.azure.com host. Remotes cloned via the
    // current ADO UI use this form regardless of what `prUrlBase` says.
    scopes.add('https://dev.azure.com/');

    // Derive the visualstudio.com form from adoOrg. Works whether adoOrg is
    // the bare org slug ("office") or already a full host ("office.visualstudio.com").
    const orgName = project.adoOrg.includes('.')
      ? project.adoOrg.split('.')[0]
      : project.adoOrg;
    if (orgName) {
      scopes.add(`https://${orgName}.visualstudio.com/`);
    }

    // Also honor whatever host `getAdoOrgBase` resolves — covers custom
    // prUrlBase values (e.g. self-hosted Azure DevOps Server) that don't
    // match either canonical alias.
    const base = shared.getAdoOrgBase(project);
    if (typeof base === 'string' && base.startsWith('http')) {
      const m = base.match(/^(https?:\/\/[^/]+)/);
      if (m) scopes.add(`${m[1]}/`);
    }

    // Stable alphabetical order so tests can assert deterministically.
    return Array.from(scopes).sort();
  } catch (_e) {
    return [];
  }
}

function _buildHeaderArgs(token, scopeUrls) {
  // Empty/null scopes → degenerate unscoped fallback (matches pre-W-mped1zap
  // behavior). Still safe because we only inject this for ADO projects.
  if (!Array.isArray(scopeUrls) || scopeUrls.length === 0) {
    return ['-c', `http.extraHeader=Authorization: Bearer ${token}`];
  }
  const args = [];
  for (const scopeUrl of scopeUrls) {
    args.push('-c', `http.${scopeUrl}.extraHeader=Authorization: Bearer ${token}`);
  }
  return args;
}

// #2762 — Always-on credential-helper suppression for ADO git ops, paired with
// (optional) bearer-header injection. The credential disable flags come FIRST
// so they're effective even when token acquisition failed and we have nothing
// else to add — preventing the WAM popup is more important than authenticating.
function _composeAdoArgs(headerArgs) {
  return [...CREDENTIAL_DISABLE_ARGS, ...headerArgs];
}

function _acquireToken(opts = {}) {
  const exec = opts.acquireAdoTokenSync || adoToken.acquireAdoTokenSync;
  const result = exec({ timeout: opts.timeout || ACQUIRE_TIMEOUT_MS });
  return result && result.token ? String(result.token) : null;
}

function getAdoGitExtraArgs(project, opts = {}) {
  if (!isAdoProject(project)) return [];
  const now = Date.now();
  if (_cached && _cached.expiresAt > now) {
    return _composeAdoArgs(_buildHeaderArgs(_cached.token, _resolveScopeUrls(project)));
  }
  if (now < _backoffUntil) {
    // #2762 — Still suppress the credential helper even when we have no token.
    // Without these flags, git falls through to GCM/WAM and pops the account
    // picker; with them, the fetch fails fast with a 401 in agent logs.
    return [...CREDENTIAL_DISABLE_ARGS];
  }
  try {
    const token = _acquireToken(opts);
    if (!token) {
      _backoffUntil = now + ACQUIRE_BACKOFF_MS;
      log('warn', `ado-git-auth: acquireAdoTokenSync returned empty token; backing off ${ACQUIRE_BACKOFF_MS / 1000}s`);
      return [...CREDENTIAL_DISABLE_ARGS];
    }
    _cached = { token, expiresAt: now + TOKEN_TTL_MS };
    return _composeAdoArgs(_buildHeaderArgs(token, _resolveScopeUrls(project)));
  } catch (e) {
    _backoffUntil = now + ACQUIRE_BACKOFF_MS;
    // #3045 — log the FULL exec error (redacted) instead of just the first
    // line. child_process.exec rejections start with "Command failed: <cmd>"
    // and put the real stderr ("TF400813 unknown user", "HTTP 401", etc.)
    // on the SECOND line; .split('\n')[0] threw that away and made every
    // ADO auth failure look like an indistinguishable wall of "Command
    // failed: ...". _redactBearer strips any bearer header echoed back by
    // the failing git command so the token never reaches engine logs.
    const fullMsg = _redactBearer(String(e && e.message || e));
    log('warn', `ado-git-auth: token acquire failed (${fullMsg}); backing off ${ACQUIRE_BACKOFF_MS / 1000}s`);
    return [...CREDENTIAL_DISABLE_ARGS];
  }
}

function invalidateAdoTokenCache() {
  _cached = null;
}

function isAdoAuthFailure(err) {
  if (!err || typeof err !== 'object') return false;
  const parts = [
    err.message,
    typeof err.stdout === 'string' ? err.stdout : '',
    typeof err.stderr === 'string' ? err.stderr : '',
  ].filter(Boolean).join('\n');
  if (!parts) return false;
  return ADO_AUTH_PATTERNS.some((p) => p.test(parts));
}

function _redactBearer(s) {
  if (typeof s !== 'string') return s;
  return s.replace(/Authorization:\s*Bearer\s+[A-Za-z0-9._\-]+/gi, 'Authorization: Bearer [REDACTED]');
}

function _redactErrorInPlace(err) {
  if (!err || typeof err !== 'object') return err;
  if (err.message) err.message = _redactBearer(err.message);
  if (typeof err.stdout === 'string') err.stdout = _redactBearer(err.stdout);
  if (typeof err.stderr === 'string') err.stderr = _redactBearer(err.stderr);
  return err;
}

async function runAdoGit(project, gitArgs, opts = {}) {
  const { acquireAdoTokenSync, _runGit, ...callerOpts } = opts;
  const runGit = _runGit || shared.shellSafeGit;
  const baseExtra = Array.isArray(callerOpts.gitExtraArgs) ? callerOpts.gitExtraArgs : [];
  const adoOpts = { acquireAdoTokenSync };

  const firstExtra = baseExtra.concat(getAdoGitExtraArgs(project, adoOpts));
  const firstOpts = firstExtra.length ? { ...callerOpts, gitExtraArgs: firstExtra } : callerOpts;
  try {
    return await runGit(gitArgs, firstOpts);
  } catch (err) {
    if (!isAdoProject(project) || !isAdoAuthFailure(err)) {
      throw _redactErrorInPlace(err);
    }
    // Token may have expired mid-dispatch — refresh and retry exactly once.
    invalidateAdoTokenCache();
    _backoffUntil = 0; // give the retry a chance to actually hit the token source
    const refreshedExtra = getAdoGitExtraArgs(project, adoOpts);
    // #2762 — `refreshedExtra` now always includes the credential-disable
    // flags even when token acquisition fails, so length alone no longer
    // signals "got a usable token." Detect a real bearer header instead;
    // without one, retrying just re-runs the same unauthenticated request.
    const hasBearer = refreshedExtra.some(
      (a) => typeof a === 'string' && a.includes('Authorization: Bearer '),
    );
    if (!hasBearer) {
      throw _redactErrorInPlace(err);
    }
    const retryExtra = baseExtra.concat(refreshedExtra);
    try {
      return await runGit(gitArgs, { ...callerOpts, gitExtraArgs: retryExtra });
    } catch (err2) {
      throw _redactErrorInPlace(err2);
    }
  }
}

// ── Test seams ──────────────────────────────────────────────────────────────

function _setTokenForTest(token) {
  if (token == null) { _cached = null; _backoffUntil = 0; return; }
  _cached = { token: String(token), expiresAt: Date.now() + TOKEN_TTL_MS };
  _backoffUntil = 0;
}

function _clearTokenCache() {
  _cached = null;
  _backoffUntil = 0;
}

function _isBackedOff() { return Date.now() < _backoffUntil; }

module.exports = {
  getAdoGitExtraArgs,
  invalidateAdoTokenCache,
  isAdoAuthFailure,
  runAdoGit,
  isAdoProject,
  redactBearer: _redactBearer,
  _setTokenForTest,
  _clearTokenCache,
  _isBackedOff,
  TOKEN_TTL_MS,
  ACQUIRE_BACKOFF_MS,
  ADO_AUTH_PATTERNS,
};
