/**
 * engine/cooldown.js — Dispatch cooldowns, deduplication, and context coalescing.
 * Extracted from engine.js.
 */

const path = require('path');
const shared = require('./shared');
const queries = require('./queries');

const { safeJson, safeJsonNoRestore, mutateCooldowns, log, ENGINE_DEFAULTS } = shared;
const { ENGINE_DIR } = queries;

/**
 * Truncate any string fields on a pendingContexts entry so a single huge PR
 * comment / build log cannot bloat cooldowns.json to hundreds of MB (#1167).
 * Returns a new entry object (does not mutate the caller's copy).
 */
function _truncateContextEntry(entry, maxBytes) {
  if (entry == null) return entry;
  const limit = Number(maxBytes) > 0 ? Number(maxBytes) : ENGINE_DEFAULTS.maxPendingContextEntryBytes;
  if (typeof entry === 'string') {
    return Buffer.byteLength(entry, 'utf8') > limit
      ? entry.slice(0, limit) + `\n\n... [truncated: context exceeded ${Math.round(limit / 1024)} KB]`
      : entry;
  }
  if (typeof entry !== 'object') return entry;
  const out = Array.isArray(entry) ? [] : {};
  for (const [k, v] of Object.entries(entry)) {
    if (typeof v === 'string' && Buffer.byteLength(v, 'utf8') > limit) {
      out[k] = v.slice(0, limit) + `\n\n... [truncated: ${k} exceeded ${Math.round(limit / 1024)} KB]`;
    } else {
      out[k] = v;
    }
  }
  return out;
}

const COOLDOWN_PATH = path.join(ENGINE_DIR, 'cooldowns.json');
const dispatchCooldowns = new Map(); // key → { timestamp, failures }
let _lastDiskCooldownKeys = new Set();

function loadCooldowns() {
  // safeJsonNoRestore — cooldowns are time-bounded ephemeral state (24h TTL).
  // Restoring a stale `cooldowns.json.backup` could resurrect expired entries
  // that should already have been pruned, suppressing legitimate dispatches
  // (P-bfa1d-safejson-no-restore). Missing/corrupt primary == "no cooldowns".
  const saved = safeJsonNoRestore(COOLDOWN_PATH);
  if (!saved) return;
  const now = Date.now();
  for (const [k, v] of Object.entries(saved)) {
    // Skip malformed entries (null, primitives, missing timestamp)
    if (!v || typeof v !== 'object' || typeof v.timestamp !== 'number') continue;
    // Prune entries older than 24 hours
    if (now - v.timestamp < 24 * 60 * 60 * 1000) {
      dispatchCooldowns.set(k, v);
    }
  }
  _lastDiskCooldownKeys = new Set(dispatchCooldowns.keys());
  log('info', `Loaded ${dispatchCooldowns.size} cooldowns from disk`);
}

let _cooldownWriteTimer = null;
/**
 * Persist the in-memory `dispatchCooldowns` Map to disk.
 *
 * Merges the in-memory map ON TOP of the lock-acquired on-disk snapshot
 * rather than replacing it (P-bfa3a → docs/cooldown-merge-semantics.md):
 *
 * 1. Concurrent writers' added keys SURVIVE — `saveCooldowns` starts from a
 *    copy of `diskCooldowns` (the freshly-read snapshot inside the
 *    `mutateCooldowns` lock) and unions in-memory entries on top, so a key
 *    another writer persisted between our last load and this flush is no
 *    longer silently overwritten.
 * 2. In-memory entries WIN collisions — they were mutated within the current
 *    debounce window and are by construction at least as recent as the disk
 *    snapshot we just read under the same lock.
 * 3. Deletions still PERSIST — explicit `clearCooldown` removes a key from
 *    `dispatchCooldowns`; the merge step then drops any key that was in
 *    `_lastDiskCooldownKeys` (i.e. we wrote it on our previous flush) but is
 *    no longer in `dispatchCooldowns`, so the union doesn't re-add a stale
 *    disk copy. External deletions are mirrored into memory via the existing
 *    `_lastDiskCooldownKeys` ∧ `!diskCooldowns[k]` check before the union.
 * 4. Expiry-prune (>24 h) and `pendingContexts` cap/truncation run on the
 *    merged result, not just the in-memory subset, so a stale concurrent
 *    entry inherited from disk cannot slip past the limits.
 *
 * Writes are debounced (1 s) so back-to-back mutations coalesce into one
 * locked write. `_lastDiskCooldownKeys` is anchored to `dispatchCooldowns`
 * keys after the merge — concurrent-writer keys absorbed via the union are
 * intentionally NOT tracked as "written by us" so the next save's deletion
 * heuristic doesn't classify them as our in-memory clears.
 */
function saveCooldowns() {
  // Debounce: reset timer on each call so latest state is always written
  if (_cooldownWriteTimer) clearTimeout(_cooldownWriteTimer);
  _cooldownWriteTimer = setTimeout(() => {
    _cooldownWriteTimer = null;
    try {
      mutateCooldowns((diskCooldowns) => {
        // 1) Mirror external deletions: keys we previously wrote that a
        //    concurrent writer has removed from disk drop out of memory too.
        for (const key of Array.from(dispatchCooldowns.keys())) {
          if (_lastDiskCooldownKeys.has(key) && !Object.prototype.hasOwnProperty.call(diskCooldowns, key)) {
            dispatchCooldowns.delete(key);
          }
        }
        // 2) Start the merged result from the lock-acquired disk snapshot so
        //    concurrent writers' added keys survive the flush.
        const merged = { ...diskCooldowns };
        // 3) Honor explicit in-memory deletes: any key we previously wrote
        //    that is no longer in `dispatchCooldowns` (e.g. via clearCooldown)
        //    must be removed from the merged result — otherwise the union
        //    would resurrect it from the disk snapshot.
        for (const key of _lastDiskCooldownKeys) {
          if (!dispatchCooldowns.has(key)) delete merged[key];
        }
        // 4) Union in-memory entries on top — in-memory wins on collisions.
        for (const [k, v] of dispatchCooldowns) {
          merged[k] = v;
        }
        // 5) Apply expiry-prune (>24 h) and pendingContexts cap/truncation
        //    to EVERY entry in the merged result, including disk-inherited
        //    entries, so a stale concurrent-writer add can't carry an
        //    expired timestamp or an oversized payload through the write.
        const now = Date.now();
        const cap = ENGINE_DEFAULTS.maxPendingContexts;
        const entryLimit = ENGINE_DEFAULTS.maxPendingContextEntryBytes;
        for (const [k, v] of Object.entries(merged)) {
          if (!v || typeof v !== 'object' || typeof v.timestamp !== 'number') continue;
          if (now - v.timestamp > 24 * 60 * 60 * 1000) {
            delete merged[k];
            dispatchCooldowns.delete(k);
            continue;
          }
          if (Array.isArray(v.pendingContexts)) {
            if (v.pendingContexts.length > cap) {
              v.pendingContexts = v.pendingContexts.slice(-cap);
            }
            v.pendingContexts = v.pendingContexts.map(e => _truncateContextEntry(e, entryLimit));
          }
        }
        // 6) Anchor _lastDiskCooldownKeys to keys we actively own in memory
        //    (NOT the full union). Absorbed concurrent-writer keys belong to
        //    the other writer's lifecycle; tracking them here would make the
        //    next save's deletion-detection (step 3) misclassify them as our
        //    in-memory clears.
        _lastDiskCooldownKeys = new Set(dispatchCooldowns.keys());
        return merged;
      });
    } catch (err) {
      log('warn', `saveCooldowns failed writing ${COOLDOWN_PATH}: ${err.message}`);
    }
  }, 1000); // debounce — write at most once per second
}

function isOnCooldown(key, cooldownMs) {
  const entry = dispatchCooldowns.get(key);
  if (!entry) return false;
  const backoff = Math.min(Math.pow(2, entry.failures || 0), 8);
  return (Date.now() - entry.timestamp) < (cooldownMs * backoff);
}

function normalizePrHeadForCooldown(value) {
  const raw = String(value || '').trim();
  if (!raw) return '';
  return raw.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80);
}

function getPrReviewHead(pr) {
  if (!pr || typeof pr !== 'object') return '';
  const candidates = [
    pr.headSha,
    pr.headSHA,
    pr.head?.sha,
    pr._adoSourceCommit,
    pr._adoHeadCommit,
    pr.lastMergeSourceCommit?.commitId,
    pr.lastMergeCommit?.commitId,
  ];
  for (const candidate of candidates) {
    const head = normalizePrHeadForCooldown(candidate);
    if (head) return head;
  }
  return '';
}

function getPrReviewCooldownBase(prefix, project, pr, prDisplayId = null) {
  return `${prefix}-${project?.name || 'default'}-${prDisplayId || shared.getPrDisplayId(pr)}`;
}

function getPrReviewCooldownKey(prefix, project, pr, prDisplayId = null) {
  const base = getPrReviewCooldownBase(prefix, project, pr, prDisplayId);
  const head = getPrReviewHead(pr);
  return head ? `${base}-${head}` : base;
}

function clearLegacyPrReviewCooldown(prefix, project, pr, prDisplayId = null, scopedKey = '') {
  const legacyKey = getPrReviewCooldownBase(prefix, project, pr, prDisplayId);
  if (!scopedKey || scopedKey === legacyKey || !dispatchCooldowns.has(legacyKey)) return false;
  dispatchCooldowns.delete(legacyKey);
  saveCooldowns();
  return true;
}

function setCooldown(key) {
  const existing = dispatchCooldowns.get(key);
  dispatchCooldowns.set(key, { timestamp: Date.now(), failures: existing?.failures || 0 });
  saveCooldowns();
}

function setCooldownWithContext(key, context) {
  const existing = dispatchCooldowns.get(key);
  let pendingContexts = existing?.pendingContexts || [];
  if (context) {
    // Truncate oversized string fields per-entry BEFORE storing so a single
    // huge payload (PR diff, build log, full repo dump) cannot bloat
    // cooldowns.json regardless of array cap (#1167).
    pendingContexts.push(_truncateContextEntry(context, ENGINE_DEFAULTS.maxPendingContextEntryBytes));
  }
  // Cap to last N entries to prevent unbounded growth (cooldowns.json bloat)
  const cap = ENGINE_DEFAULTS.maxPendingContexts;
  if (pendingContexts.length > cap) pendingContexts = pendingContexts.slice(-cap);
  dispatchCooldowns.set(key, {
    timestamp: Date.now(),
    failures: existing?.failures || 0,
    pendingContexts
  });
  saveCooldowns();
}

// Drain pending coalesced contexts for a key, clearing the entry's
// pendingContexts and persisting the change. Returns [] for unknown / empty
// keys without side effects (no save, no phantom entry creation).
function drainCoalescedContexts(key) {
  const entry = dispatchCooldowns.get(key);
  if (!entry || !Array.isArray(entry.pendingContexts) || entry.pendingContexts.length === 0) {
    return [];
  }
  const contexts = entry.pendingContexts;
  entry.pendingContexts = [];
  saveCooldowns();
  return contexts;
}

function setCooldownFailure(key) {
  const existing = dispatchCooldowns.get(key);
  const failures = (existing?.failures || 0) + 1;
  dispatchCooldowns.set(key, { timestamp: Date.now(), failures });
  if (failures >= 3) {
    log('warn', `${key} has failed ${failures} times — cooldown is now ${Math.min(Math.pow(2, failures), 8)}x`);
  }
  saveCooldowns();
}

function clearCooldown(key) {
  if (!dispatchCooldowns.has(key)) return false;
  dispatchCooldowns.delete(key);
  saveCooldowns();
  return true;
}

function isAlreadyDispatched(key) {
  const dispatch = queries.getDispatch();
  // Check pending and active
  const inFlight = [...dispatch.pending, ...(dispatch.active || [])];
  if (inFlight.some(d => d.meta?.dispatchKey === key)) return true;
  // Also check recently completed — shorter window for errors (15min) vs success (1hr)
  const now = Date.now();
  const recentCompleted = (dispatch.completed || []).filter(d => {
    if (!d.completed_at) return false;
    const windowMs = d.result === 'error' ? 900000 : 3600000; // 15 min for errors, 1 hr for success
    return now - new Date(d.completed_at).getTime() < windowMs;
  });
  return recentCompleted.some(d => d.meta?.dispatchKey === key);
}

/**
 * Check if a branch is currently locked by an active dispatch.
 * Returns the conflicting dispatch item, or null if the branch is free.
 */
function isBranchActive(branch) {
  if (!branch) return null;
  const { sanitizeBranch } = require('./shared');
  const normalized = sanitizeBranch(branch);
  const dispatch = queries.getDispatch();
  return (dispatch.active || []).find(d => {
    const dBranch = d.meta?.branch;
    return dBranch && sanitizeBranch(dBranch) === normalized;
  }) || null;
}

module.exports = {
  COOLDOWN_PATH,
  dispatchCooldowns,
  loadCooldowns,
  saveCooldowns,
  isOnCooldown,
  normalizePrHeadForCooldown, // exported for testing
  getPrReviewHead,
  getPrReviewCooldownBase, // exported for testing
  getPrReviewCooldownKey,
  clearLegacyPrReviewCooldown,
  setCooldown,
  setCooldownWithContext,
  drainCoalescedContexts,
  setCooldownFailure,
  clearCooldown,
  isAlreadyDispatched,
  isBranchActive,
  _truncateContextEntry, // exported for testing
};
