// Deterministic state mutation functions for linkedin-studio plugin.
// Pure functions operate on string content (same pattern as week-rollover.mjs).
// I/O wrapper (writeState) handles file reads/writes (same pattern as queue-manager.mjs).

import { readFileSync, writeFileSync, renameSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { applyWeekRollover } from './week-rollover.mjs';

const __dirname = dirname(fileURLToPath(import.meta.url));
const HOME = process.env.HOME || process.env.USERPROFILE || '';
const STATE_FILE = process.env.STATE_FILE || join(HOME, '.claude', 'linkedin-studio.local.md');

function replaceField(content, field, value) {
  // Replacement FUNCTION, not string: most call sites pass dates/integers/booleans,
  // but `:58` passes the untrusted `last_post_topic`. In a replacement *string*,
  // `$&`/`` $` ``/`$'`/`$$` (and `$n` group refs) are special, so a `$`-bearing topic
  // (e.g. "$& budget") would expand `$&` to the whole matched line and silently
  // corrupt the scalar. A function inserts `value` verbatim — closing the last member
  // of the `$`-injection class the S12 section-append fix targeted.
  return content.replace(
    new RegExp(`^${field}: .*`, 'm'),
    () => `${field}: ${value}`
  );
}

function isoWeekFromDate(dateStr) {
  const d = new Date(dateStr + 'T12:00:00Z');
  const dayNum = d.getUTCDay() || 7;
  d.setUTCDate(d.getUTCDate() + 4 - dayNum);
  const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
  const weekNo = Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
  return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`;
}

function daysBetween(dateA, dateB) {
  const a = new Date(dateA + 'T12:00:00Z').getTime();
  const b = new Date(dateB + 'T12:00:00Z').getTime();
  if (isNaN(a) || isNaN(b)) return null;
  return Math.abs(Math.round((b - a) / 86400000));
}

function extractField(content, field) {
  const re = new RegExp(`^${field}: *"?([^"\\n]*)"?`, 'm');
  const m = content.match(re);
  return m ? m[1].trim() : '';
}

/**
 * Update post tracking fields deterministically.
 * @param {string} stateContent - Full state file content
 * @param {{ postDate: string, postTopic: string, hookText: string, charCount: number, format: string }} opts
 * @returns {{ content: string, changes: string[] } | null}
 */
export function updatePostTracking(stateContent, { postDate, postTopic, hookText, charCount, format }) {
  let content = stateContent;
  const changes = [];

  // 1. Update last_post_date
  content = replaceField(content, 'last_post_date', `"${postDate}"`);
  changes.push(`last_post_date → ${postDate}`);

  // 2. Update last_post_topic
  content = replaceField(content, 'last_post_topic', `"${postTopic}"`);
  changes.push(`last_post_topic → ${postTopic}`);

  // 3. Set first_post_date if null
  const existingFirst = extractField(content, 'first_post_date');
  if (!existingFirst || existingFirst === 'null') {
    content = replaceField(content, 'first_post_date', `"${postDate}"`);
    changes.push(`first_post_date → ${postDate} (first post!)`);
  }

  // 4. Week rollover — check if ISO week changed
  const currentWeek = extractField(content, 'current_week');
  const postWeek = isoWeekFromDate(postDate);
  const rollover = applyWeekRollover(content, currentWeek, postWeek);
  if (rollover) {
    content = rollover.content;
    changes.push(rollover.message);
  }

  // 5. Increment posts_this_week
  const currentPosts = parseInt(extractField(content, 'posts_this_week') || '0', 10);
  content = replaceField(content, 'posts_this_week', String(currentPosts + 1));
  changes.push(`posts_this_week → ${currentPosts + 1}`);

  // 6. Update streak
  const lastPostDate = extractField(stateContent, 'last_post_date');
  let currentStreak = parseInt(extractField(content, 'current_streak') || '0', 10);

  if (lastPostDate && lastPostDate !== 'null') {
    const gap = daysBetween(lastPostDate, postDate);
    if (gap !== null && gap <= 2) {
      currentStreak += 1;
      changes.push(`current_streak → ${currentStreak} (gap: ${gap}d)`);
    } else {
      currentStreak = 1;
      changes.push(`current_streak → 1 (gap: ${gap}d, reset)`);
    }
  } else {
    currentStreak = 1;
    changes.push('current_streak → 1 (first post)');
  }
  content = replaceField(content, 'current_streak', String(currentStreak));

  // 7. Update longest_streak if exceeded
  const longestStreak = parseInt(extractField(content, 'longest_streak') || '0', 10);
  if (currentStreak > longestStreak) {
    content = replaceField(content, 'longest_streak', String(currentStreak));
    changes.push(`longest_streak → ${currentStreak}`);
  }

  // 8. Append to Recent Posts section
  const hookPreview = hookText.length > 60 ? hookText.slice(0, 57) + '...' : hookText;
  const entry = `- [${postDate}] "${hookPreview}" (${charCount}) - ${postTopic}`;
  // Replacement FUNCTION, not string: `entry` embeds untrusted user content
  // (hookPreview, postTopic). In a replacement *string*, `$1`/`$&`/`` $` ``/`$'`/`$$`
  // are special, so a `$`-bearing topic (e.g. "$100 budget cut") would re-inject the
  // captured heading and drop characters, silently corrupting state. A function
  // inserts `entry` verbatim. (m === the whole captured heading.)
  content = content.replace(
    /^(## Recent Posts\n\n?)/m,
    (m) => `${m}${entry}\n`
  );
  changes.push(`Recent Posts += ${postDate} "${hookPreview.slice(0, 30)}..."`);

  if (content === stateContent) return null;
  return { content, changes };
}

/**
 * Remove Recent Posts entries older than maxAgeDays.
 * @param {string} stateContent - Full state file content
 * @param {number} [maxAgeDays=90]
 * @param {Date} [today=new Date()] - Injectable clock (tests pass a fixed date)
 * @returns {{ content: string, pruned: number } | null}
 */
export function pruneContentHistory(stateContent, maxAgeDays = 90, today = new Date()) {
  const cutoff = new Date(today);
  cutoff.setDate(cutoff.getDate() - maxAgeDays);
  const cutoffStr = cutoff.toISOString().slice(0, 10);

  // Find all Recent Posts entries
  const entryPattern = /^- \[(\d{4}-\d{2}-\d{2})\] .+$/gm;
  // No /m flag: with /m the $ alternative in the lookahead matched the end of
  // EVERY line, so the lazy capture stopped after the FIRST entry line and
  // older entries below it were never scanned (and never pruned). Without /m,
  // $ matches only end-of-string and the capture spans the whole section.
  const recentSection = stateContent.match(/## Recent Posts\n\n?([\s\S]*?)(?=\n## [^R]|\n## $|$)/);
  if (!recentSection || !recentSection[1].trim()) return null;

  const sectionContent = recentSection[1];
  let pruned = 0;
  const lines = sectionContent.split('\n');
  const kept = [];

  for (const line of lines) {
    const dateMatch = line.match(/^- \[(\d{4}-\d{2}-\d{2})\]/);
    if (dateMatch) {
      if (dateMatch[1] < cutoffStr) {
        pruned++;
        continue;
      }
    }
    kept.push(line);
  }

  if (pruned === 0) return null;

  const newSection = kept.join('\n');
  // Replacement FUNCTION, not string: `newSection` is rebuilt from KEPT user
  // entries; with a string search, `$&`/`` $` ``/`$'`/`$$` in any kept post would be
  // interpreted and corrupt the rewrite. A function inserts it verbatim.
  const content = stateContent.replace(recentSection[1], () => newSection);
  return { content, pruned };
}

/**
 * Update follower count and recalculate growth metrics.
 * @param {string} stateContent - Full state file content
 * @param {{ count: number, month: string }} opts
 * @returns {{ content: string, changes: string[] } | null}
 */
export function updateFollowerCount(stateContent, { count, month }) {
  let content = stateContent;
  const changes = [];

  const previousCount = parseInt(extractField(content, 'follower_count') || '0', 10);
  const delta = count - previousCount;

  // Update follower_count
  content = replaceField(content, 'follower_count', String(count));
  changes.push(`follower_count → ${count} (${delta >= 0 ? '+' : ''}${delta})`);

  // Recalculate growth_rate_needed
  const target = parseInt(extractField(content, 'follower_target') || '10000', 10);
  const targetDate = extractField(content, 'target_date');
  const remaining = target - count;

  if (targetDate && targetDate !== 'null' && targetDate !== '""') {
    const [tYear, tMonth] = targetDate.split('-').map(Number);
    const [mYear, mMonth] = month.split('-').map(Number);
    const monthsLeft = (tYear - mYear) * 12 + (tMonth - mMonth);
    const effectiveMonths = Math.max(1, monthsLeft);
    const rateNeeded = Math.ceil(remaining / effectiveMonths);
    content = replaceField(content, 'growth_rate_needed', String(rateNeeded));
    changes.push(`growth_rate_needed → ${rateNeeded}/month`);
  }

  // Append to Milestone Log section
  const logEntry = `- [${month}] ${count} (${delta >= 0 ? '+' : ''}${delta})`;
  // Replacement FUNCTION, not string: same class as the other section appends.
  // `logEntry` is month + integers today (no `$`), but a function keeps the whole
  // append family uniform and `$`-safe by construction.
  content = content.replace(
    /^(## Milestone Log\n)/m,
    (m) => `${m}${logEntry}\n`
  );
  changes.push(`Milestone Log += ${month}`);

  if (content === stateContent) return null;
  return { content, changes };
}

/**
 * Record a first-hour / reply-loop engagement plan deterministically.
 *
 * Additive by contract (older state files predate these fields): a missing
 * scalar is inserted, a missing section is created — existing fields are never
 * touched. Mirrors updatePostTracking: scalar replace + newest-first section
 * append. The section name is deliberately non-`R`-initial so it falls outside
 * pruneContentHistory's `## Recent Posts … (?=\n## [^R])` capture window.
 *
 * @param {string} stateContent - Full state file content
 * @param {{ planDate: string, postTopic?: string, targets?: string[], draftComments?: string[], plan?: string[] }} opts
 * @returns {{ content: string, changes: string[] } | null}
 */
export function recordFirstHourPlan(stateContent, { planDate, postTopic = '', targets = [], draftComments = [], plan = [] }) {
  let content = stateContent;
  const changes = [];

  // 1. last_firsthour_date — replace in place, else insert after last_post_date (additive).
  //    Report the change only inside the branch that actually writes it: if neither
  //    anchor field exists, the scalar is not inserted and must not be reported as changed.
  if (/^last_firsthour_date: .*/m.test(content)) {
    content = replaceField(content, 'last_firsthour_date', `"${planDate}"`);
    changes.push(`last_firsthour_date → ${planDate}`);
  } else if (/^last_post_date: .*/m.test(content)) {
    content = content.replace(/^(last_post_date: .*)$/m, (m) => `${m}\nlast_firsthour_date: "${planDate}"`); // function, not string: `m` === the matched line (was `$1`); keeps planDate `$`-safe by construction
    changes.push(`last_firsthour_date → ${planDate}`);
  }

  // 2. firsthour_active flag — only touch if the field is declared (additive)
  if (/^firsthour_active: .*/m.test(content)) {
    content = replaceField(content, 'firsthour_active', 'true');
    changes.push('firsthour_active → true');
  }

  // 3. Build the plan entry block
  const fmtList = (items) => (Array.isArray(items) && items.length ? items.map((i) => `- ${i}`).join('\n') : '- _(none)_');
  const entry = [
    `### [${planDate}] ${postTopic}`.trimEnd(),
    '**Targets:**',
    fmtList(targets),
    '**Draft comments:**',
    fmtList(draftComments),
    '**First-hour timeline:**',
    fmtList(plan),
    ''
  ].join('\n');

  // 4. Append to ## First-Hour Plans (newest first); create the section if absent (additive)
  if (/^## First-Hour Plans\b/m.test(content)) {
    content = content.replace(/^(## First-Hour Plans\n\n?)/m, (m) => `${m}${entry}\n`); // function, not string: entry embeds untrusted topic/targets/comments — `$`-safe
  } else {
    const trimmed = content.replace(/\s*$/, '');
    content = `${trimmed}\n\n## First-Hour Plans\n\n<!-- First-hour / reply-loop plans. Format: ### [YYYY-MM-DD HH:MM] topic -->\n\n${entry}\n`;
  }
  changes.push(`First-Hour Plans += ${planDate} "${postTopic}"`);

  if (content === stateContent) return null;
  return { content, changes };
}

/**
 * Record an outreach contact / pipeline entry deterministically. Additive: an
 * absent scalar is inserted (never required up front), an absent section is
 * created, and no existing field is touched. Mirrors recordFirstHourPlan:
 * scalar replace + newest-first section append. The section name is
 * deliberately non-`R`-initial ("Outreach …") so it falls outside
 * pruneContentHistory's `## Recent Posts … (?=\n## [^R])` capture window.
 *
 * @param {string} stateContent - Full state file content
 * @param {{ contactDate: string, track?: string, partner?: string, stage?: string, nextAction?: string, dueDate?: string }} opts
 * @returns {{ content: string, changes: string[] } | null}
 */
export function recordOutreachContact(stateContent, { contactDate, track = '', partner = '', stage = '', nextAction = '', dueDate = '' }) {
  let content = stateContent;
  const changes = [];

  // 1. last_outreach_date — replace in place, else insert after last_firsthour_date
  //    if present, else after last_post_date (additive — never required up front).
  //    Report the change only inside the branch that actually writes it.
  if (/^last_outreach_date: .*/m.test(content)) {
    content = replaceField(content, 'last_outreach_date', `"${contactDate}"`);
    changes.push(`last_outreach_date → ${contactDate}`);
  } else if (/^last_firsthour_date: .*/m.test(content)) {
    content = content.replace(/^(last_firsthour_date: .*)$/m, (m) => `${m}\nlast_outreach_date: "${contactDate}"`); // function, not string: `m` === the matched line (was `$1`); keeps contactDate `$`-safe by construction
    changes.push(`last_outreach_date → ${contactDate}`);
  } else if (/^last_post_date: .*/m.test(content)) {
    content = content.replace(/^(last_post_date: .*)$/m, (m) => `${m}\nlast_outreach_date: "${contactDate}"`); // function, not string: `m` === the matched line (was `$1`); keeps contactDate `$`-safe by construction
    changes.push(`last_outreach_date → ${contactDate}`);
  }

  // 2. outreach_active flag — only touch if the field is declared (additive)
  if (/^outreach_active: .*/m.test(content)) {
    content = replaceField(content, 'outreach_active', 'true');
    changes.push('outreach_active → true');
  }

  // 3. Build the pipeline entry block
  const fmt = (v) => (v && String(v).trim() ? String(v).trim() : '_(none)_');
  const heading = `### [${contactDate}] ${partner || '(contact)'}${track ? ` — ${track}` : ''}`.trimEnd();
  const entry = [
    heading,
    `- **Stage:** ${fmt(stage)}`,
    `- **Next action:** ${fmt(nextAction)}`,
    `- **Due:** ${fmt(dueDate)}`,
    ''
  ].join('\n');

  // 4. Append to ## Outreach Pipeline (newest first); create the section if absent (additive)
  if (/^## Outreach Pipeline\b/m.test(content)) {
    content = content.replace(/^(## Outreach Pipeline\n\n?)/m, (m) => `${m}${entry}\n`); // function, not string: entry embeds untrusted partner/stage/nextAction — `$`-safe
  } else {
    const trimmed = content.replace(/\s*$/, '');
    content = `${trimmed}\n\n## Outreach Pipeline\n\n<!-- Outreach contacts / pipeline rows, newest first. Written by /linkedin:outreach. -->\n<!-- Format: ### [YYYY-MM-DD HH:MM] partner — track -->\n\n${entry}\n`;
  }
  changes.push(`Outreach Pipeline += ${contactDate} "${partner || '(contact)'}"`);

  if (content === stateContent) return null;
  return { content, changes };
}

/**
 * I/O wrapper: read state file, apply update function, write atomically.
 * @param {function(string): {content: string}|null} updateFn - Pure update function
 */
export function writeState(updateFn) {
  const content = readFileSync(STATE_FILE, 'utf-8');
  const result = updateFn(content);
  if (!result) {
    console.log('No changes needed.');
    return;
  }
  const tmpPath = STATE_FILE + '.tmp';
  writeFileSync(tmpPath, result.content, 'utf-8');
  renameSync(tmpPath, STATE_FILE);
  if (result.changes) {
    console.log('State updated:', result.changes.join(', '));
  } else if (result.pruned !== undefined) {
    console.log(`Pruned ${result.pruned} old entries.`);
  }
}

// Standalone mode
if (import.meta.url === `file://${process.argv[1]}`) {
  const args = process.argv.slice(2);
  if (args.includes('--update-post')) {
    const getArg = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : ''; };
    writeState(content => updatePostTracking(content, {
      postDate: getArg('--date') || new Date().toISOString().slice(0, 10),
      postTopic: getArg('--topic') || 'unknown',
      hookText: getArg('--hook') || '',
      charCount: parseInt(getArg('--chars') || '0', 10),
      format: getArg('--format') || 'post'
    }));
  } else if (args.includes('--prune')) {
    const days = parseInt(args[args.indexOf('--prune') + 1] || '90', 10);
    writeState(content => pruneContentHistory(content, days));
  } else if (args.includes('--update-followers')) {
    const getArg = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : ''; };
    writeState(content => updateFollowerCount(content, {
      count: parseInt(getArg('--count') || '0', 10),
      month: getArg('--month') || new Date().toISOString().slice(0, 7)
    }));
  } else if (args.includes('--record-firsthour')) {
    const getArg = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : ''; };
    const splitList = (s) => (s ? s.split(';').map(x => x.trim()).filter(Boolean) : []);
    writeState(content => recordFirstHourPlan(content, {
      planDate: getArg('--date') || new Date().toISOString().slice(0, 16).replace('T', ' '),
      postTopic: getArg('--topic') || '',
      targets: splitList(getArg('--targets')),
      draftComments: splitList(getArg('--comments')),
      plan: splitList(getArg('--plan'))
    }));
  } else if (args.includes('--record-outreach')) {
    const getArg = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : ''; };
    writeState(content => recordOutreachContact(content, {
      contactDate: getArg('--date') || new Date().toISOString().slice(0, 16).replace('T', ' '),
      track: getArg('--track') || '',
      partner: getArg('--partner') || '',
      stage: getArg('--stage') || '',
      nextAction: getArg('--next') || '',
      dueDate: getArg('--due') || ''
    }));
  } else {
    console.log('Usage:');
    console.log('  node state-updater.mjs --update-post --date YYYY-MM-DD --topic "topic" --hook "Hook text" --chars 1500 --format post');
    console.log('  node state-updater.mjs --prune [days]');
    console.log('  node state-updater.mjs --update-followers --count 920 --month 2026-04');
    console.log('  node state-updater.mjs --record-firsthour --date "YYYY-MM-DD HH:MM" --topic "topic" --targets "a;b" --comments "c;d" --plan "e;f"');
    console.log('  node state-updater.mjs --record-outreach --date "YYYY-MM-DD HH:MM" --track collab --partner "@name" --stage pitched --next "follow up" --due YYYY-MM-DD');
  }
}
