← back to Marketing Command Center

modules/channels/index.js

998 lines

// Channels — the multi-channel PUBLISHING ENGINE. The heart of the marketing
// machine: one composer → publishes to Facebook (pages), Instagram, TikTok,
// YouTube. Each platform adapter holds the REAL publish call behind a credential
// gate; with no creds the post is STAGED to the outbox (status pending-connection)
// so the pipeline works end-to-end before any account is connected.
//
// LIVE posting is gated exactly like the CC sends: requires confirm:true and
// dryRun:false; bulk/auto never posts without that. Connecting accounts (OAuth)
// is a Steve-authorized, per-platform step done on each developer portal.
const fs = require('fs');
const path = require('path');
const mediaCache = require('../../lib/media-cache');

const OUTBOX = path.join(__dirname, '..', '..', 'data', 'channels-outbox.json');
const readOutbox = () => { try { return JSON.parse(fs.readFileSync(OUTBOX, 'utf8')); } catch { return []; } };
const writeOutbox = (a) => { fs.mkdirSync(path.dirname(OUTBOX), { recursive: true }); fs.writeFileSync(OUTBOX, JSON.stringify(a, null, 2)); };
const env = (k) => (process.env[k] || '').trim();
const GRAPH = 'https://graph.facebook.com/v23.0';

// ── Meta Pages cache (the user's 80 FB Pages + per-Page tokens + linked IG) ─────
// Discovered from /me/accounts with META_ACCESS_TOKEN (a user token). Each Page
// carries its OWN access_token (that's what you post to a Page with) + an optional
// linked Instagram business account. Cached here so the picker + per-Page posting
// don't re-hit Graph on every render. CONTAINS PAGE TOKENS → gitignored, never
// sent to the browser (the /pages route sanitizes the tokens out).
const META_PAGES = path.join(__dirname, '..', '..', 'data', 'meta-pages.json');
const readMetaPages = () => { try { return JSON.parse(fs.readFileSync(META_PAGES, 'utf8')); } catch { return { fetchedAt: null, pages: [] }; } };
const writeMetaPages = (o) => { fs.mkdirSync(path.dirname(META_PAGES), { recursive: true }); fs.writeFileSync(META_PAGES, JSON.stringify(o, null, 2)); };
async function fetchMetaPages() {
  const token = env('META_ACCESS_TOKEN');
  if (!token) throw new Error('META_ACCESS_TOKEN not set — paste it into .env first');
  let pages = [], url = `${GRAPH}/me/accounts?fields=name,id,access_token,instagram_business_account{id,username},tasks&limit=100&access_token=${encodeURIComponent(token)}`;
  let guard = 0;
  while (url && guard++ < 20) {
    const r = await fetch(url); const j = await r.json();
    if (j.error) throw new Error(j.error.message);
    for (const p of (j.data || [])) pages.push({
      id: p.id, name: p.name, token: p.access_token || null,
      igId: p.instagram_business_account?.id || null,
      igUsername: p.instagram_business_account?.username || null,
      canPost: (p.tasks || []).includes('CREATE_CONTENT'),
    });
    url = j.paging?.next || null;
  }
  // Meta stopped populating instagram_business_account in the bulk /me/accounts
  // response (verified 2026-07-15 — it returns null there even with instagram_basic).
  // It DOES resolve when you query the page node WITH ITS OWN PAGE TOKEN. So for any
  // page still missing an IG link but holding a token, resolve it per-page (batched).
  const needIg = pages.filter(p => !p.igId && p.token);
  for (let i = 0; i < needIg.length; i += 10) {
    await Promise.all(needIg.slice(i, i + 10).map(async (p) => {
      try {
        const r = await fetch(`${GRAPH}/${p.id}?fields=instagram_business_account{id,username}&access_token=${encodeURIComponent(p.token)}`);
        const j = await r.json();
        const ig = j.instagram_business_account;
        if (ig) { p.igId = ig.id || null; p.igUsername = ig.username || null; }
      } catch { /* leave this page IG-less; a single failure never aborts the fleet */ }
    }));
  }
  pages.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
  const cache = { fetchedAt: new Date().toISOString(), pages };
  writeMetaPages(cache);
  return cache;
}

// ── OAuth connect machinery ───────────────────────────────────────────────────
// Tokens minted by the connect flow are stored here (gitignored), so a one-time
// browser "Authorize" is all it takes once the app client id/secret are set.
const TOKENS = path.join(__dirname, '..', '..', 'data', 'channels-tokens.json');
const readTokens = () => { try { return JSON.parse(fs.readFileSync(TOKENS, 'utf8')); } catch { return {}; } };
const writeTokens = (o) => { fs.mkdirSync(path.dirname(TOKENS), { recursive: true }); fs.writeFileSync(TOKENS, JSON.stringify(o, null, 2)); };
// Public base for OAuth redirect URIs — register `${base}/api/channels/oauth/<platform>/callback`
// in each app console. Defaults to the canonical live host (matches .env's
// OAUTH_REDIRECT_BASE); override with OAUTH_REDIRECT_BASE.
const redirectBase = () => env('OAUTH_REDIRECT_BASE') || 'https://marketing.agentabrams.com';
const redirectUri = (p) => `${redirectBase()}/api/channels/oauth/${p}/callback`;

// ── Self-serve setup (the Activate page) ──────────────────────────────────────
// .env is the source of truth the shell loads; we write directly to it AND update
// process.env live so the Connect button lights up without a restart.
const ENV_PATH = path.join(__dirname, '..', '..', '.env');
// Allowlist — only these may be written via the form (no arbitrary env injection).
const ALLOWED_KEYS = new Set([
  'META_APP_ID', 'META_APP_SECRET', 'FB_PAGE_IDS', 'FB_PAGE_ACCESS_TOKEN', 'IG_BUSINESS_ACCOUNT_ID', 'IG_ACCESS_TOKEN',
  'TIKTOK_CLIENT_KEY', 'TIKTOK_CLIENT_SECRET', 'TIKTOK_ACCESS_TOKEN',
  'YOUTUBE_CLIENT_ID', 'YOUTUBE_CLIENT_SECRET', 'GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET', 'YOUTUBE_ACCESS_TOKEN', 'YOUTUBE_REFRESH_TOKEN',
  'BLUESKY_HANDLE', 'BLUESKY_APP_PASSWORD',
  'THREADS_ACCESS_TOKEN', 'THREADS_USER_ID',
  // LinkedIn (Phase 2) — allowed so the Accounts panel's one-stop "fix" can save
  // them the same way; the linkedin module reads these same keys from process.env.
  'LINKEDIN_ACCESS_TOKEN', 'LINKEDIN_AUTHOR_URN', 'LINKEDIN_ORG_URN',
  'OAUTH_REDIRECT_BASE',
]);
// Provider cards the front-end renders from. id is the form group only.
const SETUP_PROVIDERS = [
  {
    id: 'meta', title: 'Meta — Facebook + Instagram', icon: '📘',
    blurb: 'One Meta app activates BOTH Facebook Pages and Instagram. In the portal: create a Business app, add "Facebook Login", request the permissions below, and register the redirect URIs.',
    portal: 'https://developers.facebook.com/apps', portalLabel: 'Meta for Developers',
    platforms: ['facebook', 'instagram'],
    permissions: 'pages_manage_posts · pages_read_engagement · instagram_content_publish · instagram_basic · business_management',
    fields: [
      { key: 'META_APP_ID', label: 'App ID', secret: false, ph: 'e.g. 1234567890123456' },
      { key: 'META_APP_SECRET', label: 'App Secret', secret: true, ph: '32-char secret' },
    ],
  },
  {
    id: 'tiktok', title: 'TikTok', icon: '🎵',
    blurb: 'TikTok for Developers app with the Content Posting API. Direct-post requires app audit; until approved, sandbox posts are private-only.',
    portal: 'https://developers.tiktok.com', portalLabel: 'TikTok for Developers',
    platforms: ['tiktok'],
    permissions: 'video.publish · video.upload · user.info.basic',
    fields: [
      { key: 'TIKTOK_CLIENT_KEY', label: 'Client Key', secret: false, ph: 'aw...' },
      { key: 'TIKTOK_CLIENT_SECRET', label: 'Client Secret', secret: true, ph: 'client secret' },
    ],
  },
  {
    id: 'youtube', title: 'YouTube', icon: '▶️',
    blurb: 'Google Cloud project with YouTube Data API v3 enabled + an OAuth client (type: Web application). OAuth connects now; the binary upload pipeline lands later, so posts stage until then.',
    portal: 'https://console.cloud.google.com/apis/credentials', portalLabel: 'Google Cloud Console',
    platforms: ['youtube'],
    permissions: 'youtube.upload · youtube.readonly',
    fields: [
      { key: 'YOUTUBE_CLIENT_ID', label: 'OAuth Client ID', secret: false, ph: '...apps.googleusercontent.com' },
      { key: 'YOUTUBE_CLIENT_SECRET', label: 'OAuth Client Secret', secret: true, ph: 'GOCSPX-...' },
    ],
  },
  {
    id: 'bluesky', title: 'Bluesky', icon: '🦋', noRedirect: true,
    blurb: 'No app review, no OAuth — just an App Password. In Bluesky: Settings → Privacy and security → App Passwords → Add. Paste your handle + the app password below and it posts live immediately.',
    portal: 'https://bsky.app/settings/app-passwords', portalLabel: 'Bluesky App Passwords',
    platforms: ['bluesky'],
    permissions: 'AT Protocol app-password (full account; no granular scopes)',
    fields: [
      { key: 'BLUESKY_HANDLE', label: 'Handle', secret: false, ph: 'designerwallcoverings.bsky.social' },
      { key: 'BLUESKY_APP_PASSWORD', label: 'App Password', secret: true, ph: 'xxxx-xxxx-xxxx-xxxx' },
    ],
  },
  {
    id: 'threads', title: 'Threads', icon: '🧵', noRedirect: true,
    blurb: 'Threads (Meta) Graph API. Needs a Threads user-access token with threads_basic + threads_content_publish (Meta App Review gates content_publish, like FB/IG). Paste the token + your Threads user id to enable live posting.',
    portal: 'https://developers.facebook.com/docs/threads', portalLabel: 'Threads API docs',
    platforms: ['threads'],
    permissions: 'threads_basic · threads_content_publish',
    fields: [
      { key: 'THREADS_USER_ID', label: 'Threads User ID', secret: false, ph: 'e.g. 17841400000000000' },
      { key: 'THREADS_ACCESS_TOKEN', label: 'Access Token', secret: true, ph: 'THQVJ...' },
    ],
  },
];

function setEnvKeys(updates) {
  let lines = [];
  try { lines = fs.readFileSync(ENV_PATH, 'utf8').split('\n'); } catch { /* new file */ }
  for (const [k, v] of Object.entries(updates)) {
    lines = lines.filter(l => !l.startsWith(k + '='));
    // Single-quote the .env value so a token containing a space / # / <> can't
    // abort a `set -a` shell `source` at that line (Steve's env-unquoted-value
    // sourcing bug). loadEnv() strips surrounding quotes on read, so process.env
    // still gets the raw value below — round-trip stays clean.
    if (v !== '' && v != null) { lines.push(`${k}='${String(v).replace(/'/g, "'\\''")}'`); process.env[k] = String(v); }
    else { delete process.env[k]; }
  }
  const out = lines.join('\n').replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '');
  fs.writeFileSync(ENV_PATH, out.endsWith('\n') ? out : out + '\n', { mode: 0o600 });
}

// Per-platform OAuth config. clientId/secret come from env (Steve pastes once).
const OAUTH = {
  youtube: {
    clientId: () => env('YOUTUBE_CLIENT_ID') || env('GOOGLE_CLIENT_ID'),
    clientSecret: () => env('YOUTUBE_CLIENT_SECRET') || env('GOOGLE_CLIENT_SECRET'),
    authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
    tokenUrl: 'https://oauth2.googleapis.com/token',
    scope: 'https://www.googleapis.com/auth/youtube.upload https://www.googleapis.com/auth/youtube.readonly https://www.googleapis.com/auth/youtube.force-ssl',
    extra: { access_type: 'offline', prompt: 'consent' },
  },
  tiktok: {
    clientId: () => env('TIKTOK_CLIENT_KEY') || env('TIKTOK_CLIENT_ID'),
    clientSecret: () => env('TIKTOK_CLIENT_SECRET'),
    authUrl: 'https://www.tiktok.com/v2/auth/authorize/',
    tokenUrl: 'https://open.tiktokapis.com/v2/oauth/token/',
    scope: 'video.publish,video.upload,user.info.basic',
    clientParam: 'client_key',
  },
  facebook: {
    clientId: () => env('META_APP_ID'),
    clientSecret: () => env('META_APP_SECRET'),
    authUrl: 'https://www.facebook.com/v21.0/dialog/oauth',
    tokenUrl: 'https://graph.facebook.com/v21.0/oauth/access_token',
    scope: 'pages_manage_posts,pages_read_engagement,instagram_content_publish,instagram_basic,business_management',
  },
};
OAUTH.instagram = OAUTH.facebook; // same Meta app/flow

function authorizeUrl(p) {
  const c = OAUTH[p]; if (!c || !c.clientId()) return null;
  const params = new URLSearchParams({
    [c.clientParam || 'client_id']: c.clientId(),
    redirect_uri: redirectUri(p),
    response_type: 'code',
    scope: c.scope,
    state: p,
    ...(c.extra || {}),
  });
  return `${c.authUrl}?${params.toString()}`;
}
async function exchangeCode(p, code) {
  const c = OAUTH[p];
  const body = new URLSearchParams({
    [c.clientParam || 'client_id']: c.clientId(),
    client_secret: c.clientSecret(),
    code, grant_type: 'authorization_code', redirect_uri: redirectUri(p),
  });
  const r = await fetch(c.tokenUrl, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body });
  const j = await r.json().catch(() => ({}));
  if (!r.ok || j.error) throw new Error(j.error_description || j.error?.message || JSON.stringify(j).slice(0, 200));
  return j; // {access_token, refresh_token?, expires_in?}
}

// Upgrade a short-lived Meta user token to a long-lived (~60-day) one. Page tokens
// derived from a long-lived user token are PERMANENT (never expire) — this is the
// single step that stops the whole fleet dying in an hour. Returns the long-lived
// access_token, or throws (caller decides whether to fall back to the short one).
async function exchangeLongLivedMeta(shortToken) {
  const appId = env('META_APP_ID'), secret = env('META_APP_SECRET');
  if (!appId || !secret) throw new Error('META_APP_ID / META_APP_SECRET not set');
  const qs = new URLSearchParams({
    grant_type: 'fb_exchange_token', client_id: appId, client_secret: secret, fb_exchange_token: shortToken,
  });
  const r = await fetch(`${GRAPH}/oauth/access_token?${qs}`);
  const j = await r.json().catch(() => ({}));
  if (!r.ok || j.error || !j.access_token) throw new Error(j.error?.message || 'long-lived exchange returned no token');
  return j.access_token;
}

// Real-world posting capability. `connected` (creds present) is NOT the same as
// "will actually post" — a channel can be connected yet blocked by App Review, a
// dead token, or a media-type mismatch. This encodes the KNOWN structural blocks
// (verified in production) so the status endpoint stops advertising a non-postable
// channel as ready. `postable`: can it publish anything right now? `mediaTypes`:
// what it will accept (an image caller must check this). `postableNote`: the why.
const POSTABILITY = {
  bluesky:   { postable: true,  mediaTypes: ['image', 'text'] },
  threads:   { postable: true,  mediaTypes: ['image', 'text'] },   // DW is a Meta tester → publishes without App Review (proven)
  instagram: { postable: true,  mediaTypes: ['image', 'video'] },
  youtube:   { postable: true,  mediaTypes: ['video'],
               postableNote: 'Video uploads only — image/text posts are not supported.' },
  tiktok:    { postable: true,  mediaTypes: ['video'],
               postableNote: 'Private-only (SELF_ONLY) and video-only until TikTok App Review — cannot post publicly or post an image.' },
  facebook:  { postable: false, mediaTypes: ['image', 'video'],
               postableNote: 'Blocked: page posting needs pages_manage_posts, which requires Meta App Review — API posts fail until approved.' },
  linkedin:  { postable: false, mediaTypes: ['image', 'text'],
               postableNote: 'Blocked: company-page posting needs a dedicated Community-Management app; the current token is unscoped/invalid.' },
};

// ── Per-platform connection status (what's wired vs what Steve must authorize) ──
function platformStatus() {
  const tk = readTokens();
  const cfg = (p) => !!(OAUTH[p] && OAUTH[p].clientId() && OAUTH[p].clientSecret());
  const tokenConnected = (p) => !!(tk[p] && tk[p].access_token);
  const base = {
    facebook: {
      label: 'Facebook Pages', icon: '📘',
      // A user META_ACCESS_TOKEN unlocks per-Page tokens (via /me/accounts) — that's
      // "connected" for the page-picker flow, independent of the legacy single-page env.
      connected: !!(env('META_ACCESS_TOKEN') || (env('META_APP_ID') && env('FB_PAGE_ACCESS_TOKEN'))),
      accounts: env('META_ACCESS_TOKEN') ? readMetaPages().pages.map(p => p.name || p.id)
        : (env('FB_PAGE_IDS') ? env('FB_PAGE_IDS').split(',').map(s => s.trim()).filter(Boolean) : []),
      needs: 'Meta user token (META_ACCESS_TOKEN) → per-Page tokens auto-discovered. Live posting needs pages_manage_posts on the token.',
      scopes: ['pages_manage_posts', 'pages_read_engagement', 'business_management'],
    },
    instagram: {
      label: 'Instagram', icon: '📸',
      connected: !!(env('META_ACCESS_TOKEN') || (env('META_APP_ID') && env('IG_ACCESS_TOKEN') && env('IG_BUSINESS_ACCOUNT_ID'))),
      accounts: env('META_ACCESS_TOKEN') ? readMetaPages().pages.filter(p => p.igId).map(p => p.igUsername || p.igId)
        : (env('IG_BUSINESS_ACCOUNT_ID') ? [env('IG_BUSINESS_ACCOUNT_ID')] : []),
      needs: 'IG Business account linked to a selected Page. Live posting needs instagram_content_publish on the token.',
      scopes: ['instagram_content_publish', 'instagram_basic'],
    },
    tiktok: {
      label: 'TikTok', icon: '🎵',
      connected: !!(env('TIKTOK_ACCESS_TOKEN')),
      accounts: env('TIKTOK_USERNAME') ? [env('TIKTOK_USERNAME')] : (env('TIKTOK_OPEN_ID') ? [env('TIKTOK_OPEN_ID')] : []),
      needs: 'TikTok for Developers app + Content Posting API access (audited) → user OAuth → access token. Direct-post needs app review; sandbox posts to private only.',
      scopes: ['video.publish', 'video.upload'],
      // Surfaced at publish time so a "live" post isn't mistaken for public.
      caveat: 'Posts are private-only (SELF_ONLY) until TikTok approves the app audit.',
    },
    youtube: {
      label: 'YouTube', icon: '▶️',
      connected: !!(env('YOUTUBE_ACCESS_TOKEN') || (env('GOOGLE_CLIENT_ID') && env('YOUTUBE_REFRESH_TOKEN'))),
      accounts: env('YOUTUBE_CHANNEL_TITLE') ? [env('YOUTUBE_CHANNEL_TITLE')] : (env('YOUTUBE_CHANNEL_ID') ? [env('YOUTUBE_CHANNEL_ID')] : []),
      needs: 'Google Cloud project + YouTube Data API v3 enabled + OAuth consent (youtube.upload scope) → channel refresh token.',
      scopes: ['https://www.googleapis.com/auth/youtube.upload'],
      // Resumable videos.insert upload pipeline is wired (postYouTube). Uploads land on
      // the connected token's channel; defaults to privacyStatus 'private'.
      caveat: 'Uploads go to the connected channel; posts default to Private (set content.privacy to publish public).',
    },
    bluesky: {
      label: 'Bluesky', icon: '🦋',
      // App-password auth: present creds = live. No OAuth, no review.
      connected: !!(env('BLUESKY_HANDLE') && env('BLUESKY_APP_PASSWORD')),
      accounts: env('BLUESKY_HANDLE') ? [env('BLUESKY_HANDLE')] : [],
      needs: 'Bluesky handle + an App Password (Settings → App Passwords). Posts go live immediately — no review.',
      scopes: ['app-password'],
    },
    threads: {
      label: 'Threads', icon: '🧵',
      connected: !!(env('THREADS_ACCESS_TOKEN') && env('THREADS_USER_ID')),
      accounts: env('THREADS_USER_ID') ? [env('THREADS_USER_ID')] : [],
      needs: 'Threads user id + access token with threads_content_publish.',
      scopes: ['threads_basic', 'threads_content_publish'],
      caveat: 'DW is a Meta tester → threads_content_publish posts live WITHOUT App Review (verified). Public rollout still needs review, but tester posting works now.',
    },
    linkedin: {
      label: 'LinkedIn', icon: '💼',
      connected: !!(env('LINKEDIN_ACCESS_TOKEN') && (env('LINKEDIN_ORG_URN') || env('LINKEDIN_AUTHOR_URN'))),
      accounts: env('LINKEDIN_ORG_URN') ? [env('LINKEDIN_ORG_URN')] : (env('LINKEDIN_AUTHOR_URN') ? [env('LINKEDIN_AUTHOR_URN')] : []),
      needs: 'LinkedIn access token (w_organization_social) + org/author URN — same keys the linkedin module reads.',
      scopes: ['w_organization_social'],
    },
  };
  // Merge in OAuth-flow state: a platform is `connected` if env creds OR a minted
  // token exists; `configured` = app client id/secret present so a Connect button works.
  for (const p of Object.keys(base)) {
    // OAuth platforms: configured = app id/secret present (lights the Connect button).
    // App-password / token-paste platforms (bluesky, threads): configured once their
    // creds are present — there's no OAuth Connect, creds are saved directly via /config.
    base[p].configured = cfg(p) || base[p].connected;
    base[p].connected = base[p].connected || tokenConnected(p);
    base[p].connectUrl = (cfg(p) && !base[p].connected) ? `/api/channels/connect/${p}` : null;
    // postable = connected AND not structurally blocked. This is the honest
    // "will it actually post" signal; `connected` alone over-promises.
    const cap = POSTABILITY[p] || { postable: base[p].connected, mediaTypes: ['image', 'text'] };
    base[p].postable = base[p].connected && cap.postable;
    base[p].mediaTypes = cap.mediaTypes;
    if (cap.postableNote) base[p].postableNote = cap.postableNote;
    else if (!base[p].connected) base[p].postableNote = 'Not connected — add credentials first.';
  }
  return base;
}

// ── Meta token health (expiry-aware status) ───────────────────────────────────
// platformStatus() is presence-only: a PRESENT-but-EXPIRED Meta token reads as
// "connected" while every real post silently fails AND the reconnect button stays
// hidden (connectUrl only shows when !connected). This adds a cached debug_token
// check so an expired/invalid Meta token flips to needsReconnect → surfaces the
// Connect button. FAIL-SAFE: a network blip or missing app creds returns ok:null
// (unknown) and NEVER flips a possibly-good token to broken.
let _metaHealth = null;                 // { token, at, v }
const META_HEALTH_TTL = 10 * 60 * 1000; // 10 min — debug_token is free but rate-limited
async function metaTokenHealth() {
  const token = env('META_ACCESS_TOKEN');
  if (!token) return { present: false, ok: false, reason: 'no token' };
  const now = Date.now();
  if (_metaHealth && _metaHealth.token === token && (now - _metaHealth.at) < META_HEALTH_TTL) return _metaHealth.v;
  const appId = env('META_APP_ID'), secret = env('META_APP_SECRET');
  if (!appId || !secret) {            // can't verify without app creds → unknown, don't flip
    const v = { present: true, ok: null, reason: 'no app id/secret to verify token' };
    _metaHealth = { token, at: now, v }; return v;
  }
  try {
    const r = await fetch(`${GRAPH}/debug_token?input_token=${encodeURIComponent(token)}&access_token=${encodeURIComponent(appId + '|' + secret)}`);
    const j = await r.json().catch(() => ({}));
    // Graph signals rate-limits / app-token problems as a TOP-LEVEL {error} (no
    // throw, no data.is_valid) — that means we COULDN'T verify, not that the user
    // token is bad. Treat is_valid as three-valued: only a definitive boolean
    // false flips a token; a missing/indeterminate verdict → ok:null (unknown),
    // leaving the presence-based status untouched (fail-safe).
    if (j.error || !j.data || typeof j.data.is_valid !== 'boolean') {
      const v = { present: true, ok: null, reason: j.error?.message || 'validity indeterminate — Graph returned no verdict' };
      _metaHealth = { token, at: now, v }; return v;
    }
    const d = j.data;
    const v = {
      present: true,
      ok: d.is_valid,
      expiresAt: d.expires_at ? d.expires_at * 1000 : null,
      reason: d.is_valid ? null : (d.error?.message || 'token invalid/expired'),
    };
    _metaHealth = { token, at: now, v }; return v;
  } catch (e) {                       // network failure → unknown, keep prior belief
    const v = { present: true, ok: null, reason: 'validity check failed: ' + e.message };
    _metaHealth = { token, at: now, v }; return v;
  }
}

// Mutate the presence-based status with Meta token validity. Only acts when the
// token is DEFINITIVELY invalid (ok === false); ok:null (unknown) is left alone.
function augmentMetaHealth(st, health) {
  for (const p of ['facebook', 'instagram']) {
    if (!st[p] || !st[p].connected || !health || !health.present) continue;
    if (health.ok === false) {
      st[p].connected = false;
      st[p].needsReconnect = true;
      st[p].tokenError = health.reason || 'token expired — reconnect required';
      st[p].connectUrl = st[p].configured ? `/api/channels/connect/${p}` : st[p].connectUrl;
    } else if (health.ok === true && health.expiresAt) {
      st[p].tokenExpiresAt = health.expiresAt; // lets the UI show "expires in N days"
    }
  }
  return st;
}

// ── Real adapters (fire only when connected) ──────────────────────────────────
async function postFacebook(content, opts = {}) {
  // Two modes: (a) selected-pages — post to a chosen subset using each Page's OWN
  // token from the cache; (b) legacy — FB_PAGE_IDS + one FB_PAGE_ACCESS_TOKEN.
  let targets = [];
  if (opts.pages && opts.pages.length) {
    const byId = Object.fromEntries(readMetaPages().pages.map(p => [String(p.id), p]));
    for (const id of opts.pages) { const p = byId[String(id)]; if (p && p.token) targets.push({ id: p.id, token: p.token, name: p.name }); }
    if (!targets.length) return [{ ok: false, error: 'selected Pages not in cache — click ↻ refresh on the Pages picker' }];
  } else {
    const token = env('FB_PAGE_ACCESS_TOKEN');
    const ids = env('FB_PAGE_IDS');
    if (!ids) return [{ ok: false, error: 'No Facebook Page selected and FB_PAGE_IDS not configured' }];
    targets = ids.split(',').map(s => s.trim()).filter(Boolean).map(id => ({ id, token, name: id }));
  }
  const results = [];
  for (const t of targets) {
    // video (a selected reel) → /videos with a public file_url; photo → /photos;
    // text → /feed. Video + photo posting both need pages_manage_posts on the token.
    let url, body;
    if (content.videoUrl) {
      url = `${GRAPH}/${t.id}/videos`;
      body = { file_url: content.videoUrl, description: content.caption, access_token: t.token };
    } else if (content.mediaUrl) {
      url = `${GRAPH}/${t.id}/photos`;
      body = { url: content.mediaUrl, caption: content.caption, access_token: t.token };
    } else {
      url = `${GRAPH}/${t.id}/feed`;
      body = { message: content.caption, access_token: t.token };
    }
    const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
    const j = await r.json().catch(() => ({}));
    results.push({ pageId: t.id, name: t.name, ok: r.ok, id: j.id || j.post_id, error: r.ok ? null : (j.error?.message || `HTTP ${r.status}`) });
  }
  return results;
}
async function postInstagram(content, opts = {}) {
  if (!content.mediaUrl && !content.videoUrl) return [{ ok: false, error: 'Instagram requires an image or video URL' }];
  // selected-pages → post to each selected Page's LINKED IG account (Page token);
  // legacy → single IG_BUSINESS_ACCOUNT_ID + IG_ACCESS_TOKEN.
  let igTargets = [];
  if (opts.pages && opts.pages.length) {
    const byId = Object.fromEntries(readMetaPages().pages.map(p => [String(p.id), p]));
    for (const id of opts.pages) { const p = byId[String(id)]; if (p && p.igId && p.token) igTargets.push({ igId: p.igId, token: p.token, name: p.igUsername || p.name }); }
    if (!igTargets.length) return [{ ok: false, error: 'none of the selected Pages have a linked Instagram account' }];
  } else {
    igTargets = [{ igId: env('IG_BUSINESS_ACCOUNT_ID'), token: env('IG_ACCESS_TOKEN'), name: 'IG' }];
  }
  const results = [];
  for (const t of igTargets) {
    // A selected reel → REELS container (video_url); else an IMAGE container.
    const createBody = content.videoUrl
      ? { media_type: 'REELS', video_url: content.videoUrl, caption: content.caption, access_token: t.token }
      : { image_url: content.mediaUrl, caption: content.caption, access_token: t.token };
    const create = await fetch(`${GRAPH}/${t.igId}/media`, {
      method: 'POST', headers: { 'content-type': 'application/json' },
      body: JSON.stringify(createBody),
    });
    const cj = await create.json().catch(() => ({}));
    if (!create.ok || !cj.id) { results.push({ igId: t.igId, name: t.name, ok: false, error: cj.error?.message || 'media create failed' }); continue; }
    // Meta processes the container ASYNC — publishing before it's FINISHED throws
    // "Media ID is not available". Poll status_code until FINISHED (or ERROR/timeout).
    // Video (REELS) transcodes slower than an image, so give it more polls.
    let ready = false;
    const maxPolls = content.videoUrl ? 30 : 12;
    for (let i = 0; i < maxPolls; i++) {
      await new Promise(r => setTimeout(r, i === 0 ? 1500 : 2500));
      const s = await fetch(`${GRAPH}/${cj.id}?fields=status_code&access_token=${encodeURIComponent(t.token)}`);
      const sj = await s.json().catch(() => ({}));
      if (sj.status_code === 'FINISHED') { ready = true; break; }
      if (sj.status_code === 'ERROR' || sj.status_code === 'EXPIRED') { break; }
    }
    if (!ready) { results.push({ igId: t.igId, name: t.name, ok: false, error: 'media container not ready (still processing or errored)' }); continue; }
    const pub = await fetch(`${GRAPH}/${t.igId}/media_publish`, {
      method: 'POST', headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ creation_id: cj.id, access_token: t.token }),
    });
    const pj = await pub.json().catch(() => ({}));
    results.push({ igId: t.igId, name: t.name, ok: pub.ok, id: pj.id, error: pub.ok ? null : (pj.error?.message || 'publish failed') });
  }
  return results;
}
async function postTikTok(content) {
  // TikTok Content Posting API (direct post). Requires audited app.
  const token = env('TIKTOK_ACCESS_TOKEN');
  const r = await fetch('https://open.tiktokapis.com/v2/post/publish/video/init/', {
    method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
    body: JSON.stringify({ post_info: { title: content.caption, privacy_level: 'SELF_ONLY' }, source_info: { source: 'PULL_FROM_URL', video_url: content.mediaUrl } }),
  });
  const j = await r.json().catch(() => ({}));
  return [{ ok: r.ok && !j.error?.code, id: j.data?.publish_id, error: r.ok ? null : JSON.stringify(j.error || {}) }];
}
// YouTube access tokens live ~1h, so mint a fresh one from the stored refresh token
// on every post (grant_type=refresh_token). Falls back to any stored access token.
async function youtubeAccessToken() {
  const yt = readTokens().youtube || {};
  const rt = yt.refresh_token || env('YOUTUBE_REFRESH_TOKEN');
  const cid = env('YOUTUBE_CLIENT_ID') || env('GOOGLE_CLIENT_ID');
  const cs = env('YOUTUBE_CLIENT_SECRET') || env('GOOGLE_CLIENT_SECRET');
  if (rt && cid && cs) {
    const body = new URLSearchParams({ client_id: cid, client_secret: cs, refresh_token: rt, grant_type: 'refresh_token' });
    const r = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body });
    const j = await r.json().catch(() => ({}));
    if (j.access_token) return j.access_token;
  }
  return yt.access_token || env('YOUTUBE_ACCESS_TOKEN') || null;
}

// YouTube Data API videos.insert — a RESUMABLE binary upload. Unlike the URL-based
// channels, YouTube needs the actual bytes: we fetch the source video, open a
// resumable session (metadata POST → upload URL), then PUT the bytes. Posts land on
// whichever channel the connected token authorized (pick the brand channel at OAuth
// time). Defaults to privacyStatus 'private' unless content.privacy overrides.
async function postYouTube(content) {
  const src = content.videoUrl || content.mediaUrl;
  if (!src) return [{ ok: false, error: 'YouTube needs a video (videoUrl/mediaUrl) — it never posts an image alone' }];
  const token = await youtubeAccessToken();
  if (!token) return [{ ok: false, error: 'YouTube not connected (no refresh/access token)' }];
  const vr = await fetch(src);
  if (!vr.ok) return [{ ok: false, error: `couldn't fetch source video (${vr.status}) from ${src}` }];
  const ctype = vr.headers.get('content-type') || 'video/*';
  const buf = Buffer.from(await vr.arrayBuffer());
  const cap = (content.caption || 'Designer Wallcoverings').trim();
  const title = (content.title || cap.split('\n')[0] || 'Designer Wallcoverings').slice(0, 100);
  const description = content.description || cap;
  const privacyStatus = content.privacy || 'private';
  // 1) open a resumable upload session
  const init = await fetch('https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status', {
    method: 'POST',
    headers: {
      authorization: `Bearer ${token}`,
      'content-type': 'application/json; charset=UTF-8',
      'x-upload-content-type': ctype,
      'x-upload-content-length': String(buf.length),
    },
    body: JSON.stringify({
      snippet: { title, description, tags: content.tags || ['wallpaper', 'designerwallcoverings', 'interiordesign'], categoryId: env('YOUTUBE_CATEGORY_ID') || '22' },
      status: { privacyStatus, selfDeclaredMadeForKids: false },
    }),
  });
  if (!init.ok) { const e = await init.text().catch(() => ''); return [{ ok: false, error: `youtube init ${init.status}: ${e.slice(0, 200)}` }]; }
  const uploadUrl = init.headers.get('location');
  if (!uploadUrl) return [{ ok: false, error: 'youtube resumable init returned no upload URL' }];
  // 2) upload the bytes in a single PUT
  const put = await fetch(uploadUrl, { method: 'PUT', headers: { 'content-type': ctype, 'content-length': String(buf.length) }, body: buf });
  const pj = await put.json().catch(() => ({}));
  if (!put.ok || pj.error) return [{ ok: false, error: `youtube upload ${put.status}: ${JSON.stringify(pj.error || {}).slice(0, 200)}` }];
  return [{ ok: true, id: pj.id, url: pj.id ? `https://youtu.be/${pj.id}` : null }];
}
// Rich-text FACETS for a Bluesky post — makes #hashtags and links clickable.
// Per the AT Protocol spec (docs.bsky.app), facet index offsets are UTF-8 BYTE
// positions, NOT JS string indices — so emoji/multibyte in the caption don't shift
// them. We measure with Buffer.byteLength on the substring up to each match.
function detectFacets(text) {
  const facets = [];
  const byteLen = s => Buffer.byteLength(s, 'utf8');
  // #hashtags — # then a non-digit, run of non-space; strip trailing punctuation
  const tagRe = /(?:^|\s)(#[^\s#]+)/g;
  let m;
  while ((m = tagRe.exec(text)) !== null) {
    let tag = m[1].replace(/[\p{P}]+$/u, '');          // drop trailing punctuation
    if (tag.length < 2 || /^#\d/.test(tag)) continue;   // skip bare # / #123
    const at = m.index + m[0].indexOf('#');
    const byteStart = byteLen(text.slice(0, at));
    facets.push({ index: { byteStart, byteEnd: byteStart + byteLen(tag) }, features: [{ $type: 'app.bsky.richtext.facet#tag', tag: tag.slice(1) }] });
  }
  // explicit http(s) links + bare domains (e.g. designerwallcoverings.com) → link facet
  const urlRe = /(?:^|\s)(https?:\/\/[^\s]+|(?:[a-z0-9-]+\.)+[a-z]{2,}(?:\/[^\s]*)?)/gi;
  while ((m = urlRe.exec(text)) !== null) {
    let token = m[1].replace(/[.,;:!?)]+$/, '');
    const uri = /^https?:\/\//i.test(token) ? token : 'https://' + token;
    const at = m.index + m[0].indexOf(m[1]);
    const byteStart = byteLen(text.slice(0, at));
    facets.push({ index: { byteStart, byteEnd: byteStart + byteLen(token) }, features: [{ $type: 'app.bsky.richtext.facet#link', uri }] });
  }
  return facets.sort((a, b) => a.index.byteStart - b.index.byteStart);
}
async function postBluesky(content) {
  // AT Protocol: createSession (handle + app password) → optional uploadBlob for the
  // image → createRecord (app.bsky.feed.post). No OAuth, no review. 300-char limit.
  const handle = env('BLUESKY_HANDLE'), pw = env('BLUESKY_APP_PASSWORD');
  if (!handle || !pw) return [{ ok: false, error: 'Bluesky not configured (handle + app password)' }];
  const PDS = 'https://bsky.social/xrpc';
  const s = await fetch(`${PDS}/com.atproto.server.createSession`, {
    method: 'POST', headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ identifier: handle, password: pw }),
  });
  const sj = await s.json().catch(() => ({}));
  if (!s.ok || !sj.accessJwt) return [{ ok: false, error: sj.message || sj.error || 'Bluesky auth failed' }];
  const jwt = sj.accessJwt, did = sj.did;
  let embed;
  if (content.mediaUrl) {
    // HARD RULE (Steve 2026-07-17): DW never posts text-only. If a mediaUrl was
    // supplied, the image MUST embed — if the fetch or uploadBlob fails, ABORT the
    // post instead of silently falling back to text-only. The route-level text-only
    // guard can't catch that fallback because content.mediaUrl was truthy upstream,
    // so this is the only place it can be enforced. Fail LOUD, never post naked.
    try {
      const img = await fetch(content.mediaUrl);
      if (!img.ok) return [{ ok: false, error: `Bluesky image fetch failed (${img.status}) — post aborted; DW never posts text-only` }];
      const buf = Buffer.from(await img.arrayBuffer());
      const ct = img.headers.get('content-type') || 'image/jpeg';
      const up = await fetch(`${PDS}/com.atproto.repo.uploadBlob`, {
        method: 'POST', headers: { 'content-type': ct, authorization: `Bearer ${jwt}` }, body: buf,
      });
      const uj = await up.json().catch(() => ({}));
      if (!up.ok || !uj.blob) return [{ ok: false, error: `Bluesky image upload failed (${up.status}) — post aborted; DW never posts text-only` }];
      embed = { $type: 'app.bsky.embed.images', images: [{ alt: (content.caption || '').slice(0, 280), image: uj.blob }] };
    } catch (e) {
      return [{ ok: false, error: `Bluesky image upload error: ${e.message} — post aborted; DW never posts text-only` }];
    }
  }
  const text = (content.caption || '').slice(0, 300);
  const record = { $type: 'app.bsky.feed.post', text, createdAt: new Date().toISOString() };
  const facets = detectFacets(text);            // clickable #hashtags + links (docs.bsky.app)
  if (facets.length) record.facets = facets;
  if (embed) record.embed = embed;
  const r = await fetch(`${PDS}/com.atproto.repo.createRecord`, {
    method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${jwt}` },
    body: JSON.stringify({ repo: did, collection: 'app.bsky.feed.post', record }),
  });
  const rj = await r.json().catch(() => ({}));
  return [{ ok: r.ok, id: rj.uri, error: r.ok ? null : (rj.message || rj.error || 'post failed') }];
}
async function postThreads(content) {
  // Threads Graph API: create a media container (TEXT or IMAGE) → publish it.
  // threads_content_publish is App-Review-gated, so this fails cleanly until approved.
  const token = env('THREADS_ACCESS_TOKEN'), uid = env('THREADS_USER_ID');
  if (!token || !uid) return [{ ok: false, error: 'Threads not configured (user id + access token)' }];
  const base = `https://graph.threads.net/v1.0/${uid}`;
  const cp = new URLSearchParams({ access_token: token });
  if (content.mediaUrl) { cp.set('media_type', 'IMAGE'); cp.set('image_url', content.mediaUrl); } else { cp.set('media_type', 'TEXT'); }
  if (content.caption) cp.set('text', content.caption.slice(0, 500));
  const c = await fetch(`${base}/threads`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: cp });
  const cj = await c.json().catch(() => ({}));
  if (!c.ok || !cj.id) return [{ ok: false, error: cj.error?.message || 'Threads container create failed' }];
  const pub = await fetch(`${base}/threads_publish`, {
    method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({ creation_id: cj.id, access_token: token }),
  });
  const pj = await pub.json().catch(() => ({}));
  return [{ ok: pub.ok, id: pj.id, error: pub.ok ? null : (pj.error?.message || 'Threads publish failed') }];
}

async function postLinkedIn(content) {
  // LinkedIn Community Management API. DW posts always carry media, so this is the
  // full image flow: initializeUpload → PUT the bytes → create the post with the
  // image URN. Text+link ride in `commentary`. Version pinned to match liPost.
  const token = env('LINKEDIN_ACCESS_TOKEN');
  const author = env('LINKEDIN_ORG_URN') || env('LINKEDIN_AUTHOR_URN');
  if (!token || !author) return [{ ok: false, error: 'LinkedIn not configured (token + org/author URN)' }];
  const H = { Authorization: `Bearer ${token}`, 'LinkedIn-Version': '202401', 'X-Restli-Protocol-Version': '2.0.0' };
  let mediaId = null;
  const src = content.mediaUrl || content.videoUrl;
  if (src) {
    const init = await fetch('https://api.linkedin.com/rest/images?action=initializeUpload', {
      method: 'POST', headers: { ...H, 'Content-Type': 'application/json' },
      body: JSON.stringify({ initializeUploadRequest: { owner: author } }),
    });
    const ij = await init.json().catch(() => ({}));
    const up = ij.value && ij.value.uploadUrl; mediaId = ij.value && ij.value.image;
    if (!init.ok || !up) return [{ ok: false, error: `image init ${init.status}: ${JSON.stringify(ij).slice(0, 150)}` }];
    const img = await fetch(src);
    if (!img.ok) return [{ ok: false, error: `couldn't fetch media (${img.status})` }];
    const buf = Buffer.from(await img.arrayBuffer());
    const put = await fetch(up, { method: 'PUT', headers: { Authorization: `Bearer ${token}` }, body: buf });
    if (!put.ok) return [{ ok: false, error: `image upload ${put.status}` }];
  }
  const body = {
    author, commentary: content.caption || '', visibility: 'PUBLIC',
    distribution: { feedDistribution: 'MAIN_FEED', targetEntities: [], thirdPartyDistributionChannels: [] },
    lifecycleState: 'PUBLISHED', isReshareDisabledByAuthor: false,
  };
  if (mediaId) body.content = { media: { id: mediaId } };
  const r = await fetch('https://api.linkedin.com/rest/posts', {
    method: 'POST', headers: { ...H, 'Content-Type': 'application/json' }, body: JSON.stringify(body),
  });
  const id = r.headers.get('x-restli-id') || r.headers.get('x-linkedin-id');
  let j = null; try { j = await r.json(); } catch { /* create returns empty body */ }
  return [{ ok: r.ok, id, error: r.ok ? null : ((j && j.message) || `HTTP ${r.status}`) }];
}

const ADAPTERS = { facebook: postFacebook, instagram: postInstagram, tiktok: postTikTok, youtube: postYouTube, bluesky: postBluesky, threads: postThreads, linkedin: postLinkedIn };

module.exports = {
  id: 'channels',
  title: 'Channels & Publish',
  icon: '📡',
  mount(router) {
    router.get('/status', async (_req, res) => {
      const st = augmentMetaHealth(platformStatus(), await metaTokenHealth());
      const connected = Object.values(st).filter(p => p.connected).length;
      res.json({ platforms: st, connectedCount: connected, total: Object.keys(st).length, outbox: readOutbox().length });
    });
    // Localize expiring IG/fbcdn media to durable local copies before serving
    // (see lib/media-cache): cached→local path synchronously, background-fetch
    // any missing so the next load self-heals. Never blocks the response.
    router.get('/outbox', (_req, res) => res.json({ items: mediaCache.localizeItems(readOutbox().slice(-100).reverse()) }));
    // IG media re-pull health: the board shows last-run time + result at a glance
    // (see scripts/ig-media-repull.js, run daily by com.steve.mcc-ig-repull).
    const REPULL_STATUS = path.join(__dirname, '..', '..', 'data', 'ig-repull-status.json');
    router.get('/ig-repull-status', (_req, res) => {
      try { res.json(JSON.parse(fs.readFileSync(REPULL_STATUS, 'utf8'))); }
      catch { res.json({ ranAt: null }); } // never run yet
    });
    // "Run now" — the fix-it affordance. Triggers a --apply re-pull as a detached
    // child (idempotent, backs up the outbox, only touches at-risk images). Guarded
    // against concurrent runs; returns immediately, the status file reflects the result.
    let repullRunning = false;
    router.post('/ig-repull-run', (_req, res) => {
      if (repullRunning) return res.status(409).json({ ok: false, error: 'a re-pull is already running' });
      repullRunning = true;
      const { spawn } = require('child_process');
      const child = spawn(process.execPath, [path.join(__dirname, '..', '..', 'scripts', 'ig-media-repull.js'), '--apply'], { cwd: path.join(__dirname, '..', '..'), detached: false });
      let out = '';
      child.stdout.on('data', d => { out += d; });
      child.stderr.on('data', d => { out += d; });
      child.on('close', code => { repullRunning = false; });
      child.on('error', () => { repullRunning = false; });
      res.json({ ok: true, started: true });
    });
    // Clear outbox cards. Body: { scope } — 'staged' (default) drops everything that
    // never went live (staged / pending-connection / failed), keeping real 'posted'
    // history; 'all' wipes everything; { channel } limits it to one channel. Lets the
    // board dismiss stale seed/staged cards (e.g. old pending-connection demo posts).
    router.post('/outbox/clear', (req, res) => {
      const { scope = 'staged', channel } = req.body || {};
      const keptPosted = it => it.status === 'posted';
      let items = readOutbox();
      const before = items.length;
      items = items.filter(it => {
        if (channel && (it.channel || '').toLowerCase() !== String(channel).toLowerCase()) return true; // untouched
        if (scope === 'all') return false;
        return keptPosted(it); // 'staged' scope → keep only posted
      });
      writeOutbox(items);
      res.json({ ok: true, removed: before - items.length, remaining: items.length });
    });

    // The Page picker's data source: the user's FB Pages (+ linked IG), discovered
    // from /me/accounts. Tokens are STRIPPED before sending to the browser. Pass
    // ?refresh=1 to re-fetch from Graph; otherwise serves the cache.
    router.get('/pages', async (req, res) => {
      try {
        let cache = readMetaPages();
        if (req.query.refresh === '1' || !cache.pages.length) cache = await fetchMetaPages();
        const pages = cache.pages.map(p => ({ id: p.id, name: p.name, igUsername: p.igUsername, hasIG: !!p.igId, canPost: !!p.canPost }));
        res.json({ fetchedAt: cache.fetchedAt, count: pages.length, igCount: pages.filter(p => p.hasIG).length, hasToken: !!env('META_ACCESS_TOKEN'), pages });
      } catch (e) { res.status(500).json({ error: e.message }); }
    });

    // Activate page data: provider cards + which keys are already set (booleans only,
    // never the values) + the redirect URIs to register in each portal.
    router.get('/setup', async (_req, res) => {
      const st = augmentMetaHealth(platformStatus(), await metaTokenHealth());
      const providers = SETUP_PROVIDERS.map(p => ({
        id: p.id, title: p.title, icon: p.icon, blurb: p.blurb,
        portal: p.portal, portalLabel: p.portalLabel, permissions: p.permissions,
        redirectUris: p.noRedirect ? [] : p.platforms.map(pl => ({ platform: pl, uri: redirectUri(pl) })),
        fields: p.fields.map(f => ({ key: f.key, label: f.label, secret: f.secret, ph: f.ph, set: !!env(f.key) })),
        platforms: p.platforms.map(pl => ({
          id: pl, label: st[pl].label, connected: st[pl].connected,
          configured: st[pl].configured, connectUrl: st[pl].connectUrl,
          caveat: st[pl].caveat || null,
          needsReconnect: st[pl].needsReconnect || false,
          tokenError: st[pl].tokenError || null,
          tokenExpiresAt: st[pl].tokenExpiresAt || null,
        })),
      }));
      res.json({ redirectBase: redirectBase(), providers });
    });

    // Save app credentials → .env (allowlisted keys only). Blank value = keep
    // existing (we simply skip it) so re-saving one field doesn't wipe the rest.
    router.post('/config', (req, res) => {
      const keys = (req.body && req.body.keys) || {};
      const updates = {};
      for (const [k, v] of Object.entries(keys)) {
        if (!ALLOWED_KEYS.has(k)) return res.status(400).json({ error: `key not allowed: ${k}` });
        if (typeof v !== 'string') return res.status(400).json({ error: `bad value for ${k}` });
        const t = v.trim();
        if (t === '') continue; // blank = leave as-is
        updates[k] = t;
      }
      if (!Object.keys(updates).length) return res.status(400).json({ error: 'nothing to save' });
      try { setEnvKeys(updates); } catch (e) { return res.status(500).json({ error: e.message }); }
      res.json({ ok: true, saved: Object.keys(updates), status: platformStatus() });
    });

    // Deep IG audit — find EVERY Instagram account this Meta user can reach, not
    // just the page-linked ones. Three sources, deduped by IG id:
    //   1. page-linked IG (from /me/accounts → instagram_business_account, per-page)
    //   2. Business-Manager OWNED igs (/{business}/owned_instagram_accounts)
    //   3. Business-Manager CLIENT igs (/{business}/client_instagram_accounts)
    // Personal (non-Business/Creator) IG accounts are invisible to the Graph API and
    // cannot appear here — that gap is reported so the count is honest.
    router.get('/ig-audit', async (_req, res) => {
      const token = env('META_ACCESS_TOKEN');
      if (!token) return res.status(400).json({ error: 'META_ACCESS_TOKEN not set' });
      const seen = new Map(); // igId → {username, id, via}
      const add = (ig, via) => { if (ig && ig.id && !seen.has(ig.id)) seen.set(ig.id, { id: ig.id, username: ig.username || null, via }); };
      const getJSON = async (u) => { try { const r = await fetch(u); return await r.json(); } catch { return {}; } };
      try {
        // 1. page-linked (reuse the fresh per-page resolver)
        const cache = await fetchMetaPages();
        for (const p of cache.pages) if (p.igId) add({ id: p.igId, username: p.igUsername }, 'page');
        // 2 + 3. businesses → owned + client IG accounts
        const biz = await getJSON(`${GRAPH}/me/businesses?fields=id,name&limit=100&access_token=${encodeURIComponent(token)}`);
        const businesses = (biz.data || []);
        for (const b of businesses) {
          for (const edge of ['owned_instagram_accounts', 'client_instagram_accounts', 'instagram_business_accounts']) {
            let url = `${GRAPH}/${b.id}/${edge}?fields=username,id&limit=200&access_token=${encodeURIComponent(token)}`, guard = 0;
            while (url && guard++ < 20) {
              const j = await getJSON(url);
              for (const ig of (j.data || [])) add(ig, edge.replace('_instagram_accounts', ''));
              url = j.paging?.next || null;
            }
          }
        }
        const all = [...seen.values()].sort((a, b) => (a.username || '').localeCompare(b.username || ''));
        res.json({ total: all.length, businesses: businesses.map(b => b.name), byVia: all.reduce((m, a) => (m[a.via] = (m[a.via] || 0) + 1, m), {}), accounts: all });
      } catch (e) { res.status(500).json({ error: e.message }); }
    });

    // OAuth: start the connect flow → redirect the browser to the platform consent.
    router.get('/connect/:platform', (req, res) => {
      const url = authorizeUrl(req.params.platform);
      if (!url) return res.status(400).send(`Channel '${req.params.platform}' has no app client id/secret configured yet. Paste them into .env first.`);
      res.redirect(url);
    });
    // OAuth callback: exchange the code, store the token, mark connected.
    router.get('/oauth/:platform/callback', async (req, res) => {
      const p = req.params.platform;
      if (req.query.error) return res.status(400).send(`Authorization declined: ${req.query.error_description || req.query.error}`);
      if (!req.query.code) return res.status(400).send('No authorization code returned.');
      try {
        const tok = await exchangeCode(p, req.query.code);
        const all = readTokens();
        all[p] = { ...tok, connectedAt: new Date().toISOString() };
        if (p === 'facebook' || p === 'instagram') { all.facebook = all[p]; all.instagram = all[p]; }
        writeTokens(all);
        // TikTok: platformStatus() connected-check AND postTikTok() both read
        // env('TIKTOK_ACCESS_TOKEN'), but the callback above only persisted the token to
        // channels-tokens.json — so a live post would send `Bearer undefined`. Mirror the
        // freshly-minted token into the live env so posting sends a real Bearer.
        if (p === 'tiktok' && tok && tok.access_token) { try { setEnvKeys({ TIKTOK_ACCESS_TOKEN: tok.access_token }); } catch { /* non-fatal */ } }
        // Meta short-lived → long-lived, then re-cache ALL Pages. A short-lived
        // user token (and any Page tokens derived from it) dies in ~1-2h; Page
        // tokens derived from a LONG-LIVED user token never expire. So we upgrade
        // once here, persist it as META_ACCESS_TOKEN, and fetch /me/accounts so
        // the whole 80-Page / IG fleet comes back permanent — not just for an hour.
        let fleetNote = '';
        if (p === 'facebook' || p === 'instagram') {
          try {
            const longLived = await exchangeLongLivedMeta(tok.access_token);
            setEnvKeys({ META_ACCESS_TOKEN: longLived }); // process.env updated live
            const cache = await fetchMetaPages();          // re-cache all Pages + IG links
            const igCount = cache.pages.filter(x => x.igId).length;
            fleetNote = `<p>Restored <b>${cache.pages.length} Pages</b> (${igCount} with Instagram) — long-lived, won’t expire.</p>`;
          } catch (e) {
            fleetNote = `<p style="color:#b00">Connected, but fleet refresh failed: ${e.message}. Click “↻ refresh Pages” on the Pages picker.</p>`;
          }
        }
        res.send(`<body style="font-family:system-ui;padding:50px;text-align:center"><h2>✓ ${p} connected</h2>${fleetNote}<p>You can close this tab and return to the Marketing Command Center.</p><a href="/#channels">← back to Channels</a></body>`);
      } catch (e) { res.status(500).send(`Token exchange failed for ${p}: ${e.message}`); }
    });

    // Paste-a-token ingest — the fallback when the OAuth redirect is blocked (e.g.
    // App Domains / redirect-URI not registered in the Meta app). Steve generates a
    // user token in Graph API Explorer and pastes it; we upgrade it to long-lived,
    // persist it as META_ACCESS_TOKEN, and re-cache the whole Page/IG fleet. Same end
    // state as a successful OAuth callback, zero redirect config required.
    router.post('/meta-token', async (req, res) => {
      const raw = (req.body && (req.body.token || req.body.access_token) || '').trim();
      if (!raw) return res.status(400).json({ error: 'no token provided' });
      try {
        let longLived = raw;
        try { longLived = await exchangeLongLivedMeta(raw); } catch { /* already long-lived / exchange n/a → use as-is */ }
        setEnvKeys({ META_ACCESS_TOKEN: longLived });         // process.env updated live
        const cache = await fetchMetaPages();                 // re-cache all Pages + IG (per-page resolve)
        const igCount = cache.pages.filter(p => p.igId).length;
        res.json({ ok: true, pages: cache.pages.length, ig: igCount, igAccounts: cache.pages.filter(p => p.igId).map(p => p.igUsername || p.igId) });
      } catch (e) { res.status(500).json({ error: e.message }); }
    });

    // The publish pipeline. Stages by default; only fires live with confirm + !dryRun
    // AND a connected platform. Unconnected platforms always stage.
    router.post('/publish', async (req, res) => {
      try {
        const d = req.body || {};
        const channels = Array.isArray(d.channels) ? d.channels.filter(c => ADAPTERS[c]) : [];
        const selectedPages = Array.isArray(d.pages) ? d.pages.map(String).filter(Boolean) : [];
        const content = { caption: (d.caption || '').slice(0, 2200), mediaUrl: d.mediaUrl || '', videoUrl: d.videoUrl || '' };
        if (!channels.length) return res.status(400).json({ error: 'no channels selected' });
        if (!content.caption && !content.mediaUrl) return res.status(400).json({ error: 'empty post' });
        // HARD RULE (Steve 2026-07-17): every DW social post MUST carry a photo/video/reel —
        // NEVER text-only. Block a text-only publish server-side (dry-run staging too).
        if (!content.mediaUrl && !content.videoUrl) {
          return res.status(400).json({ error: 'A photo, video, or reel is required — DW never posts text-only. Attach an image and try again.' });
        }
        const st = platformStatus();
        const live = d.confirm === true && d.dryRun === false;
        const isVideo = !!content.videoUrl;
        const isImage = !!content.mediaUrl && !content.videoUrl;
        // Why a channel can't actually post right now (null = clear to fire).
        // Respects the honest `postable` signal + media-type fit so a full fan-out
        // silently SKIPS doomed attempts — Facebook (App Review), LinkedIn (dead
        // token), and video-only TikTok/YouTube given an image — instead of firing
        // them and collecting guaranteed errors in the outbox.
        const blockedReason = (ch) => {
          const s = st[ch] || {};
          if (!s.connected) return 'not connected';
          if (!s.postable) return s.postableNote || 'not postable';
          const mt = s.mediaTypes || ['image', 'text'];
          if (isVideo && !mt.includes('video')) return 'accepts image/text only — no video';
          if (isImage && !mt.includes('image')) return `requires video — image not accepted (accepts: ${mt.join('/')})`;
          return null;
        };
        const outbox = readOutbox();
        const results = [];
        for (const ch of channels) {
          const block = blockedReason(ch);
          if (live && !block) {
            const r = await ADAPTERS[ch](content, { pages: selectedPages }).catch(e => [{ ok: false, error: e.message }]);
            const ok = r.length > 0 && r.every(x => x.ok);
            outbox.push({ id: 'pub-' + ch + '-' + outbox.length, channel: ch, caption: content.caption, mediaUrl: content.mediaUrl, pages: selectedPages.length || undefined, status: ok ? 'posted' : 'failed', detail: r, at: new Date().toISOString() });
            results.push({ channel: ch, live: true, ok, detail: r });
          } else {
            const reason = block ? `skipped — ${block}`
              : (d.dryRun !== false ? 'staged (dry-run)' : 'staged (needs confirm)');
            outbox.push({ id: 'stg-' + ch + '-' + outbox.length, channel: ch, caption: content.caption, mediaUrl: content.mediaUrl, status: reason, at: new Date().toISOString() });
            results.push({ channel: ch, live: false, skipped: !!block, staged: true, reason });
          }
        }
        writeOutbox(outbox);
        res.json({ ok: true, live, results, note: live ? 'Live posts fired to connected channels; unconnected staged.' : 'Staged only — connect channels + confirm with dryRun:false to post live.' });
      } catch (e) { res.status(500).json({ error: e.message }); }
    });

    // Flip a YouTube video's visibility (private ↔ unlisted ↔ public) via videos.update.
    // Lets us post Private, review, then publish Public — customer-facing, so gated.
    router.post('/youtube/visibility', async (req, res) => {
      try {
        const { videoId, privacy = 'public' } = req.body || {};
        if (!videoId) return res.status(400).json({ error: 'videoId required' });
        if (!['private', 'unlisted', 'public'].includes(privacy)) return res.status(400).json({ error: 'privacy must be private|unlisted|public' });
        const token = await youtubeAccessToken();
        if (!token) return res.status(400).json({ error: 'YouTube not connected' });
        const r = await fetch('https://www.googleapis.com/youtube/v3/videos?part=status', {
          method: 'PUT',
          headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
          body: JSON.stringify({ id: videoId, status: { privacyStatus: privacy } }),
        });
        const j = await r.json().catch(() => ({}));
        if (!r.ok || j.error) return res.status(500).json({ error: JSON.stringify(j.error || {}).slice(0, 200) });
        res.json({ ok: true, id: videoId, privacy: j.status?.privacyStatus, url: `https://youtu.be/${videoId}` });
      } catch (e) { res.status(500).json({ error: e.message }); }
    });
  },
};

// ── Shared read-only exports for the Accounts panel ───────────────────────────
// The Accounts rail is a VIEW over the same credential truth this module owns.
// Rather than duplicate the env checks (and risk drift), expose the authoritative
// helpers so `modules/accounts` can aggregate without re-deriving. Additive — the
// server only reads id/title/icon/mount, so these extra props are inert to it.
module.exports.platformStatus = platformStatus;
module.exports.setupProviders = () => SETUP_PROVIDERS;
module.exports.redirectUriFor = redirectUri;
// Live token-health so the accounts rail can show the SAME truth as /channels/status
// (a dead Meta token flips connected:false instead of showing green on env-presence).
module.exports.metaTokenHealth = metaTokenHealth;
module.exports.augmentMetaHealth = augmentMetaHealth;

// Programmatic live publish for the engine scheduler. Approval in the Engine
// panel IS the confirm; this enforces the same rails as /publish.
module.exports.postLive = async function postLive(channel, content, opts = {}) {
  if (!ADAPTERS[channel]) return { ok: false, error: 'unknown channel' };
  if (!content.mediaUrl && !content.videoUrl)
    return { ok: false, error: 'media required — DW never posts text-only' };
  const st = platformStatus();
  if (!st[channel] || !st[channel].connected) return { ok: false, error: 'not connected' };
  const r = await ADAPTERS[channel](content, opts).catch(e => [{ ok: false, error: e.message }]);
  const ok = Array.isArray(r) && r.length > 0 && r.every(x => x.ok);
  const outbox = readOutbox();
  const entry = { id: 'eng-' + channel + '-' + Date.now().toString(36), channel,
    caption: content.caption, mediaUrl: content.mediaUrl, status: ok ? 'posted' : 'failed',
    detail: r, via: 'engine', at: new Date().toISOString() };
  outbox.push(entry); writeOutbox(outbox);
  return { ok, detail: r, outboxId: entry.id };
};