// Human-like throttler. The whole point: never look automated.
//
// Layers of protection (each independently enforced):
//   1. Gaussian inter-post delay (not uniform — humans cluster around a mean)
//   2. Active hours window (no posting at 3am)
//   3. Hourly + daily + weekly caps
//   4. Mandatory long break after a "burst" of N posts
//   5. Per-group cooldown (don't repost in same group too soon)
//   6. Typing simulation delays (per character) for the composer
//   7. Tiny pre-click pauses to look like reading

// Platform-specific override profiles. Applied on top of DEFAULTS when a job's
// group has a particular `platform` field.
// LinkedIn is materially stricter than Facebook on automation.
export const PLATFORM_PROFILES = {
  facebook: {
    interPostMeanSec: 360,    // 6 min
    interPostMinSec: 180,     // 3 min
    interPostMaxSec: 900,     // 15 min
    hourlyCap: 6,
    dailyCap: 25,
    weeklyCap: 120,
    burstSize: 4,
    burstBreakMinSec: 1200,
    burstBreakMaxSec: 2700,
    perGroupCooldownHours: 72,
    typingMinMsPerChar: 40,
    typingMaxMsPerChar: 140,
    preBrowseMinSec: 0,
    preBrowseMaxSec: 0,
  },
  // Instagram — strict on automation, visual content required. The aggressive
  // ones get shadow-banned fast. Slow + value-first content wins.
  instagram: {
    interPostMeanSec: 90 * 60,       // 90 min average
    interPostMinSec: 30 * 60,        // 30 min min
    interPostMaxSec: 4 * 3600,
    hourlyCap: 1,                    // 1/hour
    dailyCap: 5,                     // very strict
    weeklyCap: 25,
    burstSize: 2,
    burstBreakMinSec: 3 * 3600,
    burstBreakMaxSec: 6 * 3600,
    perGroupCooldownHours: 24,       // 24h between posts (same hashtag set etc.)
    typingMinMsPerChar: 50,
    typingMaxMsPerChar: 130,
    preBrowseMinSec: 25,
    preBrowseMaxSec: 70,
  },

  // Reddit — community-driven. Anti-spam is STRICT (automod), per-sub cooldowns,
  // karma requirements. Slow posting + value-driven content is key.
  reddit: {
    interPostMeanSec: 2 * 3600,        // 2h average
    interPostMinSec: 30 * 60,          // 30 min min
    interPostMaxSec: 8 * 3600,
    hourlyCap: 2,
    dailyCap: 8,                       // Reddit's "active user" range
    weeklyCap: 40,
    burstSize: 2,
    burstBreakMinSec: 2 * 3600,
    burstBreakMaxSec: 6 * 3600,
    perGroupCooldownHours: 48,         // 2 days per subreddit (anti-spam)
    typingMinMsPerChar: 45,
    typingMaxMsPerChar: 150,
    preBrowseMinSec: 20,
    preBrowseMaxSec: 60,
  },

  // Twitter/X — middle ground. Tolerates more posts than LI, less than FB.
  twitter: {
    interPostMeanSec: 30 * 60,        // 30 min average
    interPostMinSec: 10 * 60,         // 10 min minimum
    interPostMaxSec: 2 * 3600,        // up to 2h
    hourlyCap: 3,
    dailyCap: 15,
    weeklyCap: 80,
    burstSize: 3,
    burstBreakMinSec: 60 * 60,        // 1h
    burstBreakMaxSec: 3 * 3600,       // 3h
    perGroupCooldownHours: 24,        // 24h per community
    typingMinMsPerChar: 30,           // Twitter is fast-paced
    typingMaxMsPerChar: 100,
    preBrowseMinSec: 15,
    preBrowseMaxSec: 45,
  },

  // LinkedIn — much tighter. Real LI users post maybe once a day with engagement.
  linkedin: {
    interPostMeanSec: 4 * 3600,      // 4 HOURS average between posts
    interPostMinSec: 90 * 60,        // 90 min minimum
    interPostMaxSec: 12 * 3600,      // up to 12h
    hourlyCap: 1,                    // max 1/hour
    dailyCap: 3,                     // max 3/day (LI's sweet spot)
    weeklyCap: 15,                   // max 15/week
    burstSize: 2,                    // after 2 posts, forced break
    burstBreakMinSec: 4 * 3600,      // 4h
    burstBreakMaxSec: 8 * 3600,      // 8h
    perGroupCooldownHours: 168,      // 1 WEEK between posts in same group
    typingMinMsPerChar: 60,          // slower typing — looks more thoughtful
    typingMaxMsPerChar: 200,
    // Pre-post "browsing simulation" — only on LinkedIn
    preBrowseMinSec: 30,             // browse 30-90 sec before posting
    preBrowseMaxSec: 90,
  },
};

export function profileForPlatform(platform) {
  return PLATFORM_PROFILES[platform] || PLATFORM_PROFILES.facebook;
}

export const DEFAULTS = {
  // inter-post delay (seconds): mean 6 min, stddev ~2 min, clamped to [180, 900]
  interPostMeanSec: 360,
  interPostStddevSec: 120,
  interPostMinSec: 180,
  interPostMaxSec: 900,

  // active hours (local time, 24h). End is exclusive and may be fractional.
  // 24.5 = 00:30 next day (window crosses midnight).
  activeHourStart: 9,
  activeHourEnd: 24.5,

  // rate caps
  hourlyCap: 6,
  dailyCap: 25,
  weeklyCap: 120,

  // burst control
  burstSize: 4,                // after this many in a row...
  burstBreakMinSec: 1200,      // ...take a 20-45 min break
  burstBreakMaxSec: 2700,

  // per-group cooldown (don't repost same group within N hours)
  perGroupCooldownHours: 72,

  // group discovery (ADR-55) — SEARCHING for groups is what tripped FB's flag,
  // not publishing. It had NO limits: it opened search, hard-scrolled 8×, moved
  // to the next query, no cap, no memory of when it last searched. These gate it
  // like a cautious human who occasionally browses for groups.
  discoveryDailyCap: 3,          // at most N group-searches per day
  discoveryMinGapMinutes: 90,    // and never two searches closer than this
  discoveryMaxScrolls: 3,        // gentle scroll depth (was 8)
  discoveryScrollPauseMinMs: 2500,
  discoveryScrollPauseMaxMs: 5000,

  // session shape (ADR-53) — a human shows up in a few short visits a day, not
  // evenly across the whole active window. Uniform pacing is one of the loudest
  // "this is a script" signals, so the day gets 2-4 randomised windows and the
  // agent is QUIET outside them.
  sessionShape: true,
  sessionsPerDayMin: 2,
  sessionsPerDayMax: 4,
  sessionMinMinutes: 15,       // a visit lasts 15-45 minutes
  sessionMaxMinutes: 45,

  // typing simulation
  typingMinMsPerChar: 40,
  typingMaxMsPerChar: 140,
  typingPauseChance: 0.08,     // 8% chance to "pause and think" mid-typing
  typingPauseMinMs: 400,
  typingPauseMaxMs: 1800,

  // pre-action reading pause
  preClickMinMs: 800,
  preClickMaxMs: 2500,
};

// ---------- Random utilities ----------

// Box-Muller: standard normal -> shifted/scaled gaussian, clamped to [min, max].
export function gaussian(mean, stddev, min, max) {
  let u = 0, v = 0;
  while (u === 0) u = Math.random();
  while (v === 0) v = Math.random();
  const z = Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
  const raw = mean + z * stddev;
  return Math.max(min, Math.min(max, raw));
}

export function uniform(min, max) {
  return min + Math.random() * (max - min);
}

export function sleep(ms) {
  return new Promise((r) => setTimeout(r, ms));
}

// ---------- Inter-post delay ----------

export function nextInterPostDelayMs(cfg = DEFAULTS) {
  const sec = gaussian(
    cfg.interPostMeanSec,
    cfg.interPostStddevSec,
    cfg.interPostMinSec,
    cfg.interPostMaxSec,
  );
  return Math.floor(sec * 1000);
}

// ---------- Active hours ----------
// Both start and end may be fractional (e.g. 24.5 = 00:30 next day).
// If end > 24, the window crosses midnight: 9–24.5 means 09:00 → 00:30.
// If end <= start (e.g. 22 → 2), same: crosses midnight.

function timeNum(d) { return d.getHours() + d.getMinutes() / 60 + d.getSeconds() / 3600; }

export function isWithinActiveHours(now = new Date(), cfg = DEFAULTS) {
  const t = timeNum(now);
  const start = cfg.activeHourStart;
  const end = cfg.activeHourEnd;
  if (end > 24) return t >= start || t < (end - 24);
  if (end <= start) return t >= start || t < end;
  return t >= start && t < end;
}

// Returns ms until the next active window opens. 0 if already inside.
export function msUntilActiveWindow(now = new Date(), cfg = DEFAULTS) {
  if (isWithinActiveHours(now, cfg)) return 0;
  // Find next opening of the window.
  const t = timeNum(now);
  const start = cfg.activeHourStart;
  const startHour = Math.floor(start);
  const startMin = Math.round((start - startHour) * 60);
  const next = new Date(now);
  next.setHours(startHour, startMin, 0, 0);
  if (next <= now) next.setDate(next.getDate() + 1);
  const jitterMs = Math.floor(uniform(0, 25 * 60 * 1000));
  return (next.getTime() - now.getTime()) + jitterMs;
}

// ---------- Caps ----------

export function countWithin(jobs, sinceMs) {
  const cutoff = Date.now() - sinceMs;
  return jobs.filter((j) => j.status === 'done' && (j.completedAt || 0) >= cutoff).length;
}

// Returns null if OK to send another, otherwise a reason string.
export function capCheck(jobs, cfg = DEFAULTS) {
  if (countWithin(jobs, 3600 * 1000) >= cfg.hourlyCap) return 'hourly-cap';
  if (countWithin(jobs, 24 * 3600 * 1000) >= cfg.dailyCap) return 'daily-cap';
  if (countWithin(jobs, 7 * 24 * 3600 * 1000) >= cfg.weeklyCap) return 'weekly-cap';
  return null;
}

// ---------- Burst control ----------

// Look at the last N done jobs. If they were all within a tight cluster
// (e.g. last hour) and count >= burstSize, force a long break.
export function burstBreakMsNeeded(jobs, cfg = DEFAULTS) {
  const done = jobs
    .filter((j) => j.status === 'done' && j.completedAt)
    .sort((a, b) => b.completedAt - a.completedAt);
  if (done.length < cfg.burstSize) return 0;
  const lastN = done.slice(0, cfg.burstSize);
  const span = lastN[0].completedAt - lastN[lastN.length - 1].completedAt;
  if (span > 60 * 60 * 1000) return 0; // not really a burst
  // last burst ended at lastN[0].completedAt; require break since then
  const elapsed = Date.now() - lastN[0].completedAt;
  const breakMs = Math.floor(uniform(cfg.burstBreakMinSec, cfg.burstBreakMaxSec) * 1000);
  return Math.max(0, breakMs - elapsed);
}

// ---------- Per-group cooldown ----------

export function isGroupOnCooldown(group, cfg = DEFAULTS) {
  if (!group?.lastPostedAt) return false;
  const elapsedHrs = (Date.now() - group.lastPostedAt) / (3600 * 1000);
  return elapsedHrs < cfg.perGroupCooldownHours;
}

// ---------- Group discovery gate (ADR-55) ----------
// Decide whether a group SEARCH is allowed right now, given the timestamps of
// recent searches. Same philosophy as canPostNow: caps + spacing + active hours,
// but tuned much tighter because search is a high-signal "you're crawling" action.
// `history` = array of past search epoch-ms (most recent first is fine, we filter).
export function canDiscoverNow({ history = [], cfg = DEFAULTS, now = new Date() }) {
  if (!isWithinActiveHours(now, cfg)) {
    return { ok: false, reason: 'outside-active-hours', retryAfterMs: msUntilActiveWindow(now, cfg) };
  }
  const t = now.getTime();
  const dayAgo = t - 24 * 3600 * 1000;
  const today = history.filter((ts) => ts >= dayAgo);
  if (today.length >= (cfg.discoveryDailyCap ?? 3)) {
    // wait until the oldest of today's searches rolls out of the 24h window
    const oldest = Math.min(...today);
    return { ok: false, reason: 'discovery-daily-cap', retryAfterMs: Math.max(60000, oldest + 24 * 3600 * 1000 - t) };
  }
  const last = history.length ? Math.max(...history) : 0;
  const gapMs = (cfg.discoveryMinGapMinutes ?? 90) * 60 * 1000;
  if (last && t - last < gapMs) {
    return { ok: false, reason: 'discovery-min-gap', retryAfterMs: gapMs - (t - last) };
  }
  return { ok: true };
}

// A gentle, randomised scroll pause for discovery (vs. the old fixed 1500ms).
export function discoveryScrollPauseMs(cfg = DEFAULTS) {
  return Math.floor(uniform(cfg.discoveryScrollPauseMinMs ?? 2500, cfg.discoveryScrollPauseMaxMs ?? 5000));
}

// ---------- Session shape (ADR-53) ----------
// The caps above answer "how much"; this answers "WHEN". A real person visits a
// network in a few short bursts and is absent in between — spreading the same
// volume evenly over 15 hours is the pattern that reads as scripted.
//
// The plan must be DETERMINISTIC for a given day: an MV3 service worker restarts
// constantly, and a plan that re-randomised on every wake would let the agent act
// at any time (defeating the point). Seeding from the date + a caller-supplied
// seed also de-syncs different agents/platforms from each other.

function hashSeed(str) {
  let h = 2166136261;
  for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
  return h >>> 0;
}

// Small deterministic LCG — enough for scheduling jitter, not for crypto.
function seededRand(seed) {
  let s = seed >>> 0;
  return () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 4294967296; };
}

/** Today's visit windows as { startH, endH } in fractional local hours. */
export function sessionPlan(now = new Date(), cfg = DEFAULTS, seed = '') {
  const dayKey = `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}|${seed}`;
  const rnd = seededRand(hashSeed(dayKey));
  const start = cfg.activeHourStart;
  const end = Math.min(cfg.activeHourEnd, 24);
  const span = Math.max(0, end - start);
  if (span <= 0) return [];

  const nMin = cfg.sessionsPerDayMin ?? 2;
  const nMax = cfg.sessionsPerDayMax ?? 4;
  const n = Math.max(1, nMin + Math.floor(rnd() * (nMax - nMin + 1)));
  const minM = cfg.sessionMinMinutes ?? 15;
  const maxM = cfg.sessionMaxMinutes ?? 45;

  const slice = span / n;
  const windows = [];
  for (let i = 0; i < n; i++) {
    const durH = (minM + rnd() * Math.max(0, maxM - minM)) / 60;
    // Place each visit inside its own slice so they never overlap, jittered.
    const room = Math.max(0, slice - durH);
    const startH = start + i * slice + rnd() * room;
    windows.push({ startH, endH: Math.min(end, startH + durH) });
  }
  return windows;
}

/** Are we inside a visit right now? If not, how long until the next one starts. */
export function sessionGate(now = new Date(), cfg = DEFAULTS, seed = '') {
  if (cfg.sessionShape === false) return { ok: true };
  const windows = sessionPlan(now, cfg, seed);
  if (!windows.length) return { ok: true };
  const t = timeNum(now);
  for (const w of windows) if (t >= w.startH && t < w.endH) return { ok: true, window: w };

  const nextStart = windows.map((w) => w.startH).filter((h) => h > t).sort((a, b) => a - b)[0];
  const msUntil = nextStart != null
    ? (nextStart - t) * 3600000
    : msUntilActiveWindow(now, cfg);           // no visits left today
  return { ok: false, retryAfterMs: Math.max(60000, Math.ceil(msUntil)) };
}

// ---------- Composite gatekeeper ----------
// Returns { ok: true } or { ok: false, reason, retryAfterMs }.
export function canPostNow({ jobs, group, cfg = DEFAULTS, now = new Date() }) {
  if (!isWithinActiveHours(now, cfg)) {
    return { ok: false, reason: 'outside-active-hours', retryAfterMs: msUntilActiveWindow(now, cfg) };
  }
  // Inside the active window, still only act during today's visits (ADR-53).
  // Seeded per platform so agents on different networks don't move in lockstep.
  const session = sessionGate(now, cfg, group?.platform || cfg.platform || '');
  if (!session.ok) {
    return { ok: false, reason: 'outside-session', retryAfterMs: session.retryAfterMs };
  }
  const cap = capCheck(jobs, cfg);
  if (cap) {
    // back off ~30 min and re-check
    return { ok: false, reason: cap, retryAfterMs: 30 * 60 * 1000 };
  }
  const burst = burstBreakMsNeeded(jobs, cfg);
  if (burst > 0) {
    return { ok: false, reason: 'burst-break', retryAfterMs: burst };
  }
  if (group && isGroupOnCooldown(group, cfg)) {
    const remainingHrs = cfg.perGroupCooldownHours - (Date.now() - group.lastPostedAt) / 3600000;
    return { ok: false, reason: 'group-cooldown', retryAfterMs: Math.ceil(remainingHrs * 3600000) };
  }
  return { ok: true };
}

// ---------- Typing simulation ----------
// Yields a generator of (char, delayMs) pairs for the composer.
export function* typingPlan(text, cfg = DEFAULTS) {
  for (const ch of text) {
    let d = uniform(cfg.typingMinMsPerChar, cfg.typingMaxMsPerChar);
    // longer pause after punctuation
    if (/[.,!?\n]/.test(ch)) d *= 2.5;
    // occasional "thinking" pause
    if (Math.random() < cfg.typingPauseChance) {
      d += uniform(cfg.typingPauseMinMs, cfg.typingPauseMaxMs);
    }
    yield { ch, delayMs: Math.floor(d) };
  }
}

export function preClickDelayMs(cfg = DEFAULTS) {
  return Math.floor(uniform(cfg.preClickMinMs, cfg.preClickMaxMs));
}
