// ── Announcement pipeline: pure logic ──────────────────────────────────────
//
// No DB, no network — just the decisions the worker and model make, kept here so
// they are unit-testable in isolation (server/test/announceJobs.test.js):
//   • buildTownCrierText — turn a post into sidecar-safe town-crier lines
//   • classifyTownCrier / classifyDiscord — map a dispatch result to done / retry
//     / terminal, so a data problem fails fast and a transient outage retries
//   • scheduleAfter — exponential backoff schedule + the attempt cap
//   • rollupStatus — derive the parent job status from the two legs

const { deriveExcerpt } = require('../../utils/sanitizeHtml')

// Sidecar town-crier caps, mirrored from the admin route validation
// (admin.routes.js: lines isArray({ max: 8 }), lines.* isLength({ max: 200 })).
// We pre-truncate to these so a published post never bounces with towncrier.error.
const MAX_LINES = 8
const MAX_LINE_LEN = 200

// Backoff between retries, indexed by attempts-so-far. Six attempts spread over
// ~a couple of hours; after the last one a leg is marked failed and surfaced in
// the post's admin panel. Shared by both legs.
const BACKOFF_MS = [30_000, 120_000, 600_000, 1_800_000, 3_600_000, 7_200_000]
const MAX_ATTEMPTS = BACKOFF_MS.length

// Trim to a hard length, appending an ellipsis only when something was cut.
function clamp(value, max) {
  const s = String(value == null ? '' : value)
    .replace(/\s+/g, ' ')
    .trim()
  if (s.length <= max) return s
  return `${s.slice(0, max - 1).trimEnd()}…`
}

// The public link that goes in the announcement. News has no per-post route
// (App.jsx only has the /site/news list), so we link the list — matches the
// pre-pipeline Discord announce behavior.
function articleUrl(baseUrl) {
  return `${String(baseUrl || '').replace(/\/+$/, '')}/site/news`
}

// Build the town-crier lines: title, a one-line excerpt, then the URL. Each line
// is clamped to the sidecar's per-line cap and the whole thing to the line-count
// cap. Falls back to a stripped body excerpt when the post has no excerpt.
function buildTownCrierText(post, { baseUrl } = {}) {
  const title = clamp(post.title, MAX_LINE_LEN)
  const excerptSource = post.excerpt || deriveExcerpt(post.body, MAX_LINE_LEN) || ''
  const lines = [title]
  const excerpt = clamp(excerptSource, MAX_LINE_LEN)
  if (excerpt) lines.push(excerpt)
  const url = clamp(articleUrl(baseUrl), MAX_LINE_LEN)
  if (url) lines.push(url)
  return lines.filter(Boolean).slice(0, MAX_LINES)
}

// ── Result classification ──────────────────────────────────────────────────
// Both clients return { ok, status, error }. Map that to one of:
//   done     — delivered, mark the leg done
//   retry    — transient (shard restarting, bot down, network); back off + retry
//   terminal — will never succeed as-is (over caps, bad auth/config); fail now

function classifyTownCrier(result) {
  if (result && result.ok) return { outcome: 'done' }
  const status = result ? result.status : 0
  // 400 = over the line/duration caps (a data problem — do NOT retry).
  // 401 = token mismatch, 409 = protocol mismatch (both config problems).
  if (status === 400 || status === 401 || status === 409) {
    return { outcome: 'terminal', error: legError(result) }
  }
  // 503 (shard not connected), 504 (shard timeout), 0 (network/timeout / not
  // configured yet), and any other 5xx are transient — retry.
  return { outcome: 'retry', error: legError(result) }
}

function classifyDiscord(result) {
  if (result && result.ok) return { outcome: 'done' }
  // The bot's /internal/announce collapses failures (503 = not connected,
  // 400 = no news channel configured) without surfacing Discord's own
  // retry_after, so there is no reliable terminal signal to key on here. Retry
  // every failure on the shared backoff; a genuine config problem simply
  // exhausts its attempts and lands as `failed` in the admin panel, where the
  // per-leg retry button re-runs it after the channel is set.
  return { outcome: 'retry', error: legError(result) }
}

function legError(result) {
  if (!result) return 'no response'
  if (result.status) {
    return result.data && result.data.message
      ? `${result.status}: ${result.data.message}`
      : result.error || `status ${result.status}`
  }
  return result.error || 'request failed'
}

// Given the number of attempts already made (>= 1), how long to wait before the
// next one — or null when the cap is reached and the leg should be failed.
function scheduleAfter(attempts) {
  if (attempts >= MAX_ATTEMPTS) return null
  return BACKOFF_MS[Math.min(attempts - 1, BACKOFF_MS.length - 1)]
}

// Parent job status derived from the two leg statuses:
//   done    — both legs delivered
//   failed  — both legs gave up
//   partial — at least one leg reached a terminal state while the other has not
//             matched it (still pending/retrying, or the opposite terminal state)
//   pending — neither leg is terminal yet
function rollupStatus(towncrierStatus, discordStatus) {
  if (towncrierStatus === 'done' && discordStatus === 'done') return 'done'
  if (towncrierStatus === 'failed' && discordStatus === 'failed') return 'failed'
  const terminal = (s) => s === 'done' || s === 'failed'
  if (terminal(towncrierStatus) || terminal(discordStatus)) return 'partial'
  return 'pending'
}

module.exports = {
  MAX_LINES,
  MAX_LINE_LEN,
  MAX_ATTEMPTS,
  BACKOFF_MS,
  buildTownCrierText,
  articleUrl,
  classifyTownCrier,
  classifyDiscord,
  scheduleAfter,
  rollupStatus,
}
