import { execFile } from 'child_process'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Redis } from 'ioredis'
import OpenAI from 'openai'

// ── Types ─────────────────────────────────────────────────────────────────────

export type Platform = 'moltx' | 'moltbook' | 'clawstr'

export interface AgentPostAngle {
  id: string
  angle: string
  keyPoints: string[]
}

export interface AccountConfig {
  apiKeys: {
    moltxApiKey?: string
    moltbookApiKey?: string
    clawstrSecretKey?: string
  }
  angles: AgentPostAngle[]
  moltbookAngles?: AgentPostAngle[]
  sharedAngles: AgentPostAngle[]
  footers?: Record<Platform, string>
  context: string
  moltbookSubmoltMap: Record<string, string[]>
}

export interface AgentPostResult {
  posted: boolean
  platform?: Platform
  account?: string
  angleId?: string
  externalId?: string
  title?: string
  error?: string
  allRateLimited?: boolean
  retryAfterSeconds?: number
}

export interface AgentPoster {
  postToNextPlatform(account: string): Promise<AgentPostResult>
  postToPlatformDirect(platform: Platform, account: string, retryAfterEngage?: boolean): Promise<AgentPostResult>
  getNextPostTime(account: string): Promise<Date | null>
  setNextPostTime(time: Date, account: string): Promise<void>
}

export interface PackageLogger {
  debug(obj: Record<string, unknown> | string, msg?: string): void
  info(obj: Record<string, unknown> | string, msg?: string): void
  warn(obj: Record<string, unknown> | string, msg?: string): void
  error(obj: Record<string, unknown> | string, msg?: string): void
  trace(obj: Record<string, unknown> | string, msg?: string): void
}

export interface AgentPosterConfig {
  supabase: SupabaseClient
  redis: Redis
  ai: {
    apiKey: string
    baseUrl?: string
    model: string
  }
  accounts: Record<string, AccountConfig>
  onBeforePost: (platform: Platform, account: string) => Promise<boolean>
  onPickAngle?: (pool: AgentPostAngle[], platform: Platform, account: string) => Promise<AgentPostAngle>
  onHydrateAngle?: (angle: AgentPostAngle, platform: Platform, account: string) => Promise<AgentPostAngle>
  onGetWinnerPatterns?: (platform: Platform, account: string) => Promise<string[]>
  onGetRecentAnglePosts?: (account: string, angleId: string) => Promise<string[]>
  onGetRecentPlatformPosts?: (platform: Platform, account: string) => Promise<string[]>
  onPostSuccess?: (platform: Platform, account: string) => Promise<void>
  logger: PackageLogger
  redisKeyPrefix?: string
}

// ── Platform constants ────────────────────────────────────────────────────────

const PLATFORMS: Platform[] = ['moltx', 'moltbook', 'clawstr']

const RATE_LIMIT_SECONDS: Record<Platform, number> = {
  moltx: 6 * 60 * 60,
  moltbook: 6 * 60 * 60,
  clawstr: 6 * 60 * 60,
}

const MOLTX_TAGS = ['#agenteconomy', '#crypto', '#base', '#agents', '#building', '#aiagents']
const CLAWSTR_TAGS = ['#crypto', '#aiagents', '#agenteconomy', '#defi', '#evm', '#building']
const CLAWSTR_SUBCLAWS = ['/c/crypto', '/c/agent-economy', '/c/ai-economy', '/c/ai-dev', '/c/clawnch']
const MAX_GENERATION_ATTEMPTS = 4

// Opening patterns that signal lazy/repetitive posts — regenerate when detected
const BANNED_OPENING_PATTERNS = [
  /^just saw/i,
  /^just watched/i,
  /^been thinking/i,
  /^been watching/i,
  /^hot take:/i,
  /^real talk:/i,
  /^honest take:/i,
  /^unpopular opinion:/i,
]

function hasRepetitiveOpening(content: string): boolean {
  const trimmed = content.trimStart()
  return BANNED_OPENING_PATTERNS.some(p => p.test(trimmed))
}

const IDEA_STOP_WORDS = new Set([
  'a', 'an', 'and', 'are', 'as', 'at', 'be', 'been', 'but', 'by', 'for', 'from', 'how',
  'if', 'in', 'into', 'is', 'it', 'its', 'more', 'of', 'on', 'or', 'that', 'than', 'the',
  'their', 'there', 'these', 'they', 'this', 'to', 'up', 'was', 'we', 'what', 'when', 'where',
  'which', 'who', 'why', 'with', 'you', 'your', 'does', 'still',
])

const MARKDOWN_LINK_PATTERN = /\[[^\]]+\]\((?:https?:\/\/|www\.)[^)\s]+\)/gi
const URL_OR_DOMAIN_PATTERN = /\b(?:https?:\/\/|www\.)\S+\b|\b[a-z0-9-]+(?:\.[a-z0-9-]+)+(?:\/\S*)?\b/gi
const PROMPT_CONTROL_PATTERN = /[\x00-\x1f\x7f\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g

function stripDuplicateBoilerplate(content: string): string {
  return content
    .replace(MARKDOWN_LINK_PATTERN, ' ')
    .replace(URL_OR_DOMAIN_PATTERN, ' ')
}

function normalizeIdeaToken(token: string): string {
  let normalized = token.toLowerCase().replace(/[^a-z0-9]/g, '')
  if (normalized.endsWith('ies') && normalized.length > 4) normalized = `${normalized.slice(0, -3)}y`
  else if (normalized.endsWith('ing') && normalized.length > 5) normalized = normalized.slice(0, -3)
  else if (normalized.endsWith('ed') && normalized.length > 4) normalized = normalized.slice(0, -2)
  else if (normalized.endsWith('s') && normalized.length > 4) normalized = normalized.slice(0, -1)
  return normalized
}

function extractIdeaTokens(content: string): string[] {
  return content
    .split(/\s+/)
    .map(normalizeIdeaToken)
    .filter(token => token.length >= 4 && !IDEA_STOP_WORDS.has(token))
}

function extractIdeaPhrases(tokens: string[]): string[] {
  const phrases: string[] = []
  for (let i = 0; i < tokens.length - 1; i++) {
    phrases.push(`${tokens[i]} ${tokens[i + 1]}`)
  }
  return phrases
}

function isIdeaLevelDuplicate(content: string, candidate: string): boolean {
  const baseTokens = extractIdeaTokens(stripDuplicateBoilerplate(content))
  const candidateTokens = extractIdeaTokens(stripDuplicateBoilerplate(candidate))

  if (baseTokens.length < 4 || candidateTokens.length < 4) return false

  const baseSet = new Set(baseTokens)
  const candidateSet = new Set(candidateTokens)
  const sharedTokens = [...baseSet].filter(token => candidateSet.has(token))
  const tokenOverlap = sharedTokens.length / Math.min(baseSet.size, candidateSet.size)

  if (tokenOverlap >= 0.7) return true

  const basePhrases = new Set(extractIdeaPhrases(baseTokens))
  const candidatePhrases = new Set(extractIdeaPhrases(candidateTokens))
  const sharedPhraseCount = [...basePhrases].filter(phrase => candidatePhrases.has(phrase)).length

  return sharedPhraseCount >= 2 || (tokenOverlap >= 0.45 && sharedPhraseCount >= 1)
}

function sanitizePromptText(text: string, maxLength: number): string {
  return text
    .replace(PROMPT_CONTROL_PATTERN, ' ')
    .replace(/```+/g, ' ')
    .replace(/={2,}/g, ' ')
    .replace(/[`{}[\]<>]/g, ' ')
    .replace(/\s+/g, ' ')
    .trim()
    .slice(0, maxLength)
}

const PLATFORM_INSTRUCTIONS: Record<Platform, string> = {
  moltx: `Platform: Moltx (microblogging, like Twitter)
- HARD LIMIT: 380 characters maximum (hashtags + footer are appended automatically — do NOT exceed 380)
- No title field, just content
- Short, punchy, conversational tone
- Write like a community member sharing something useful, NOT like an ad
- Ask a question, share an observation, or start a discussion
- Avoid sounding promotional — be authentic and engage the reader`,

  moltbook: `Platform: Moltbook (long-form discussion, like Reddit)
- Title: a genuine question, observation, or discussion topic — NOT an ad headline
- Body: 150-300 words, markdown formatting OK
- Write as a knowledgeable community member starting a discussion, NOT promoting a product
- 80% valuable standalone content (insights, analysis, experience)
- At MOST a brief mention of the project as one example among others — never the focus
- End with an open question to invite replies
- NEVER: feature lists, product descriptions, "check out X", multiple CTAs, ad headlines`,

  clawstr: `Platform: Clawstr (short posts, like Nostr)
- Max 300 characters (including footer — leave ~120 chars for footer)
- No title field, just content
- Very concise, punchy, authentic
- Write like a quick community update or hot take
- Conversational and direct`,
}

// ── Utility functions ─────────────────────────────────────────────────────────

function pickRandom<T>(arr: readonly T[]): T {
  return arr[Math.floor(Math.random() * arr.length)]
}

function pickRandomTags(pool: string[], count: number): string[] {
  return [...pool].sort(() => Math.random() - 0.5).slice(0, count)
}

function pickSubmoltForAngle(angleId: string, submoltMap: Record<string, string[]>): string {
  const submolts = submoltMap[angleId]
  if (!submolts || submolts.length === 0) return 'crypto'
  return pickRandom(submolts)
}

// ── Exported utility functions ────────────────────────────────────────────────

export function createAIClient(apiKey: string, baseUrl?: string): OpenAI {
  return new OpenAI({ apiKey, baseURL: baseUrl })
}

export function createClawstrTempHome(secretKey: string): string {
  const tmpHome = mkdtempSync(join(tmpdir(), 'clawstr-'))
  const clawstrDir = join(tmpHome, '.clawstr')
  mkdirSync(clawstrDir)
  writeFileSync(join(clawstrDir, 'secret.key'), secretKey, { mode: 0o600 })
  writeFileSync(
    join(clawstrDir, 'config.json'),
    JSON.stringify({ relays: ['wss://relay.ditto.pub', 'wss://relay.primal.net', 'wss://relay.damus.io', 'wss://nos.lol'] }),
  )
  return tmpHome
}

export function parseJsonResponse<T>(content: string): T {
  let jsonContent = content.trim()
  if (jsonContent.startsWith('```')) {
    jsonContent = jsonContent.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '')
  }
  // Strip non-printable control chars except \t, \n, \r
  jsonContent = jsonContent.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '')
  return JSON.parse(jsonContent) as T
}

// ── Insight brief ─────────────────────────────────────────────────────────────

export interface InsightBrief {
  trigger: string
  claim: string
  tension: string
  reason_now: string
}

export async function generateInsightBrief(
  angle: AgentPostAngle,
  platform: Platform,
  client: OpenAI,
  model: string,
  logger: PackageLogger,
): Promise<InsightBrief | null> {
  try {
    const safeAngle = sanitizePromptText(angle.angle, 160)
    const safePoints = angle.keyPoints.map(p => sanitizePromptText(p, 200)).filter(Boolean)

    const userContent = `Angle: ${safeAngle}
Evidence:
${safePoints.map(p => `- ${p}`).join('\n')}

Platform: ${platform}`

    const response = await client.chat.completions.create({
      model,
      messages: [
        {
          role: 'system',
          content: 'You extract a concrete insight brief for a social post. Reply ONLY with valid JSON: {"trigger": "...", "claim": "...", "tension": "...", "reason_now": "..."}. Each field: 1-2 sentences, specific and concrete, no generic platitudes. If the evidence mentions when this angle was last used or that it has been rested, use that timing signal inside reason_now instead of ignoring it.',
        },
        { role: 'user', content: userContent },
      ],
      temperature: 0.8,
      max_tokens: 250,
    })

    const raw = response.choices[0]?.message?.content?.trim()
    if (!raw) return null

    const parsed = parseJsonResponse<Record<string, unknown>>(raw)

    if (
      typeof parsed.trigger !== 'string' ||
      typeof parsed.claim !== 'string' ||
      typeof parsed.tension !== 'string' ||
      typeof parsed.reason_now !== 'string'
    ) return null

    return {
      trigger: sanitizePromptText(parsed.trigger, 200),
      claim: sanitizePromptText(parsed.claim, 200),
      tension: sanitizePromptText(parsed.tension, 200),
      reason_now: sanitizePromptText(parsed.reason_now, 200),
    }
  } catch {
    logger.debug({ angle: angle.id, platform }, 'Brief generation failed, proceeding without brief')
    return null
  }
}

// ── AI content generation ─────────────────────────────────────────────────────

function buildSystemPrompt(
  platform: Platform,
  context: string,
  winnerPatterns?: string[],
): string {
  const winnerPatternsSection = winnerPatterns && winnerPatterns.length > 0
    ? `\n\n=== WINNER PATTERNS (LEARN THE TRAITS, NOT THE WORDING) ===\n${winnerPatterns.map((pattern, i) => `${i + 1}. ${sanitizePromptText(pattern, 180)}`).join('\n')}\n`
    : ''

  return `You are a knowledgeable crypto community member. Write social media posts that feel authentic — like a real person sharing something valuable, NOT like a corporate marketing bot.

${context}

${PLATFORM_INSTRUCTIONS[platform]}
${winnerPatternsSection}
STYLE RULES:
- Every post MUST be completely unique
- Vary sentence structure, opening hooks, and tone
- Be authentic — sound like a real community member, not a press release
- NO {curly braces} in output
- Do NOT include footer links (they are added automatically)
- Do NOT include hashtags (they are added automatically)

RESPOND ONLY with valid JSON:
${platform === 'moltbook' ? '{"title": "post title here", "content": "post body here"}' : '{"content": "your post text here"}'}`
}

function buildUserPrompt(
  angle: AgentPostAngle,
  brief?: InsightBrief | null,
  recentSameAnglePosts?: string[],
  recentPlatformPosts?: string[],
): string {
  const recentAngleSection = recentSameAnglePosts && recentSameAnglePosts.length > 0
    ? `\n\n=== RECENT POSTS WITH THIS SAME ANGLE (DO NOT REPEAT) ===\n${recentSameAnglePosts.map((p, i) => `${i + 1}. "${sanitizePromptText(p, 250)}"`).join('\n')}\n`
    : ''

  const recentPlatformSection = recentPlatformPosts && recentPlatformPosts.length > 0
    ? `\n\n=== YOUR RECENT POSTS ON THIS PLATFORM (AVOID REPEATING IDEAS OR OPENINGS) ===\n${recentPlatformPosts.map((p, i) => `${i + 1}. "${sanitizePromptText(p, 150)}"`).join('\n')}\n`
    : ''

  const safeAngle = sanitizePromptText(angle.angle, 160)
  const safeKeyPoints = angle.keyPoints.map(point => sanitizePromptText(point, 220)).filter(Boolean)

  if (brief) {
    return `=== INSIGHT BRIEF (write FROM this — do not copy it verbatim) ===
Trigger: ${brief.trigger}
Claim: ${brief.claim}
Tension: ${brief.tension}
Reason to post now: ${brief.reason_now}
===

Write a post grounded in this specific insight. Angle context: ${safeAngle}

Supporting evidence (use 1-2 points, not all):
${safeKeyPoints.map(p => `- ${p}`).join('\n')}

Make it fresh, unique, and authentic. Do NOT start with "Just saw", "Just watched", "Been thinking", "Been watching", "Hot take:", or similar lazy openers.${recentAngleSection}${recentPlatformSection}`
  }

  return `Write a post focusing on this angle: ${safeAngle}

Key points to weave in (use 2-3, not all):
${safeKeyPoints.map(p => `- ${p}`).join('\n')}

Make it fresh, unique, and authentic. Do NOT start with "Just saw", "Just watched", "Been thinking", "Been watching", "Hot take:", or similar lazy openers.${recentAngleSection}${recentPlatformSection}`
}

export async function generateAgentPostContent(
  platform: Platform,
  angle: AgentPostAngle,
  accountConfig: AccountConfig,
  client: OpenAI,
  model: string,
  logger: PackageLogger,
  winnerPatterns?: string[],
  recentSameAnglePosts?: string[],
  recentPlatformPosts?: string[],
  brief?: InsightBrief | null,
): Promise<{ content: string; title?: string }> {
  try {
    const response = await client.chat.completions.create({
      model,
      messages: [
        { role: 'system', content: buildSystemPrompt(platform, accountConfig.context, winnerPatterns) },
        { role: 'user', content: buildUserPrompt(angle, brief, recentSameAnglePosts, recentPlatformPosts) },
      ],
      temperature: 1.0,
      max_tokens: platform === 'moltbook' ? 1000 : 600,
    })

    const raw = response.choices[0]?.message?.content?.trim()
    if (!raw) throw new Error('Empty response from AI')

    const parsed = parseJsonResponse<{ content: string; title?: string }>(raw)
    if (!parsed.content) throw new Error('No content in AI response')

    return { content: parsed.content, title: parsed.title }
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err)
    logger.warn({ error: msg, platform, angle: angle.id }, 'AI generation failed, using fallback')
    const points = [...angle.keyPoints].sort(() => Math.random() - 0.5).slice(0, platform === 'moltbook' ? 3 : 2)
    if (platform === 'moltbook') {
      return { title: angle.angle, content: points.map(p => `- ${p}`).join('\n') }
    }
    return { content: points.join('. ') }
  }
}

// ── Platform API helpers ──────────────────────────────────────────────────────

export interface PostResult {
  externalId: string
  url?: string
}

export async function postToMoltx(content: string, apiKey: string): Promise<PostResult> {
  const tags = pickRandomTags(MOLTX_TAGS, 4)
  const tagStr = '\n\n' + tags.join(' ')
  const maxBase = 500 - tagStr.length
  const fullContent = content.slice(0, maxBase) + tagStr

  const res = await fetch('https://moltx.io/v1/posts', {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ content: fullContent }),
  })

  if (!res.ok) {
    const body = await res.text()
    throw new Error(`Moltx API ${res.status}: ${body}`)
  }

  const raw = await res.json() as { data?: { post?: { id?: string }; id?: string }; id?: string }
  const externalId = raw?.data?.post?.id || raw?.data?.id || raw?.id || ''
  return { externalId, url: externalId ? `https://moltx.io/post/${externalId}` : undefined }
}

async function solveMoltbookChallenge(
  challengeText: string,
  instructions: string,
  client: OpenAI,
  model: string,
  logger: PackageLogger,
): Promise<string | null> {
  try {
    const response = await client.chat.completions.create({
      model,
      messages: [
        { role: 'system', content: 'You solve verification challenges. Follow the instructions exactly. Reply with ONLY the answer, nothing else.' },
        { role: 'user', content: `Instructions: ${instructions}\n\nChallenge: ${challengeText}` },
      ],
      temperature: 0,
      max_tokens: 50,
    })
    return response.choices[0]?.message?.content?.trim() || null
  } catch (err) {
    logger.warn({ error: err instanceof Error ? err.message : String(err) }, 'Failed to solve Moltbook challenge')
    return null
  }
}

export async function postToMoltbook(
  submolt: string,
  title: string,
  content: string,
  apiKey: string,
  client: OpenAI,
  model: string,
  logger: PackageLogger,
): Promise<PostResult> {
  const res = await fetch('https://www.moltbook.com/api/v1/posts', {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ submolt, title, content }),
  })

  if (!res.ok) {
    const body = await res.text()
    throw new Error(`Moltbook API ${res.status}: ${body}`)
  }

  const raw = await res.json() as {
    post?: { id?: string }
    id?: string
    verification?: { verification_code: string; challenge_text: string; expires_at: string; instructions?: string }
  }
  const externalId = raw?.post?.id || raw?.id || ''

  if (raw?.verification) {
    const { verification_code, challenge_text, instructions } = raw.verification
    if (verification_code && challenge_text) {
      const answer = await solveMoltbookChallenge(
        challenge_text,
        instructions || 'Solve the math problem and respond with ONLY the number.',
        client,
        model,
        logger,
      )
      if (answer) {
        try {
          const vRes = await fetch('https://www.moltbook.com/api/v1/verify', {
            method: 'POST',
            headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
            body: JSON.stringify({ verification_code, answer }),
          })
          if (vRes.ok) {
            logger.info({ externalId, verificationCode: verification_code }, 'Moltbook post verified')
          } else {
            logger.warn({ externalId, verificationCode: verification_code, answer }, 'Moltbook verification rejected')
          }
        } catch (err) {
          logger.warn({ error: err instanceof Error ? err.message : String(err) }, 'Moltbook verification request failed')
        }
      }
    }
  }

  return { externalId, url: externalId ? `https://www.moltbook.com/post/${externalId}` : undefined }
}

export async function postToClawstr(subclaw: string, content: string, secretKey: string): Promise<PostResult> {
  const tmpHome = createClawstrTempHome(secretKey)
  const tags = pickRandomTags(CLAWSTR_TAGS, 3)
  const fullContent = content + '\n\n' + tags.join(' ')

  return new Promise((resolve, reject) => {
    execFile('npx', ['-y', '@clawstr/cli@latest', 'post', subclaw, fullContent], { timeout: 60000, env: { ...process.env, HOME: tmpHome } }, (error, stdout, stderr) => {
      try { rmSync(tmpHome, { recursive: true, force: true }) } catch { /* ignore */ }

      if (error) {
        reject(new Error(`Clawstr CLI failed: ${error.message}${stderr ? ` stderr: ${stderr}` : ''}`))
        return
      }

      const combined = stdout + ' ' + (stderr || '')
      const noteMatch = combined.match(/note1[a-z0-9]+/)
      const urlMatch = combined.match(/(https:\/\/clawstr\.com\/[^\s]+)/)

      if (!noteMatch && !urlMatch) {
        reject(new Error(`Clawstr CLI returned no note ID. stdout: ${stdout.trim().slice(0, 200)}`))
        return
      }

      if (noteMatch) {
        resolve({ externalId: noteMatch[0], url: `https://clawstr.com/${noteMatch[0]}` })
      } else {
        const url = urlMatch![1]
        const hexMatch = url.match(/\/([a-f0-9]{64})$/)
        resolve({ externalId: hexMatch ? hexMatch[1] : url, url })
      }
    })
  })
}

/**
 * Minimal shape of the `@neynar/nodejs-sdk` client this helper needs.
 * `publishCast` takes ONE object argument in Neynar SDK v3 — not positional
 * `(text, opts)` — matching the real SDK's call contract is load-bearing;
 * getting this wrong makes every cast fail silently at the caller's catch.
 */
export interface NeynarCastClient {
  publishCast(
    params: { text: string; signerUuid?: string } & Record<string, unknown>,
  ): Promise<{ cast?: { hash?: string } } | null>
}

/**
 * Publish a cast via a pre-constructed Neynar client. Deliberately outside
 * the `Platform` union / `AgentPoster` orchestration: Farcaster posting has
 * no cooldown/rate-limit needs shared with Moltx/Moltbook/Clawstr today, so
 * callers manage their own scheduling and just need the raw post call.
 */
export async function publishFarcasterCast(
  client: NeynarCastClient,
  text: string,
  opts?: { embeds?: string[]; channelId?: string; signerUuid?: string },
): Promise<{ externalId: string } | { error: string }> {
  const result = await client.publishCast({ text, ...opts })
  const hash = result?.cast?.hash
  if (!hash) return { error: 'Farcaster cast publish returned no hash' }
  return { externalId: hash }
}

// ── Redis helpers ─────────────────────────────────────────────────────────────

function makeKey(prefix: string, ...parts: string[]): string {
  return `${prefix}${parts.join(':')}`
}

async function isRateLimited(redis: Redis, prefix: string, platform: Platform, account: string): Promise<boolean> {
  const key = makeKey(prefix, 'rate_limit', platform, account)
  const val = await redis.get(key)
  return val !== null
}

async function getRateLimitRemainingSeconds(redis: Redis, prefix: string, platform: Platform, account: string): Promise<number | undefined> {
  const key = makeKey(prefix, 'rate_limit', platform, account)
  const ttl = await redis.ttl(key)
  return ttl > 0 ? ttl : undefined
}

async function setRateLimit(redis: Redis, prefix: string, platform: Platform, account: string, ttlSeconds: number): Promise<void> {
  const key = makeKey(prefix, 'rate_limit', platform, account)
  await redis.set(key, '1', 'EX', ttlSeconds)
}

async function clearEngageCooldown(redis: Redis, prefix: string, platform: Platform, account: string): Promise<void> {
  const key = makeKey(prefix, 'engage_cd', platform, account)
  await redis.del(key)
}

async function getNextTime(redis: Redis, prefix: string, account: string): Promise<Date | null> {
  const key = makeKey(prefix, 'next_time', account)
  const val = await redis.get(key)
  if (!val) return null
  const ts = parseInt(val, 10)
  return isNaN(ts) ? null : new Date(ts)
}

async function setNextTime(redis: Redis, prefix: string, account: string, time: Date): Promise<void> {
  const key = makeKey(prefix, 'next_time', account)
  await redis.set(key, String(time.getTime()))
}

// ── createAgentPoster factory ─────────────────────────────────────────────────

export function createAgentPoster(config: AgentPosterConfig): AgentPoster {
  const { supabase, redis, ai, accounts, onBeforePost, onPickAngle, onHydrateAngle, onGetWinnerPatterns, onGetRecentAnglePosts, onGetRecentPlatformPosts, onPostSuccess, logger, redisKeyPrefix = 'agent_post:' } = config

  const aiClient = createAIClient(ai.apiKey, ai.baseUrl)

  // Per-account platform rotation state
  const accountPlatformIndex: Record<string, number> = {}

  async function getNextPlatform(account: string): Promise<Platform | null> {
    if (!(account in accountPlatformIndex)) accountPlatformIndex[account] = 0
    const currentIndex = accountPlatformIndex[account]
    const accountConfig = accounts[account]
    const available = accountConfig
      ? PLATFORMS.filter(p => {
          if (p === 'moltx') return !!accountConfig.apiKeys.moltxApiKey
          if (p === 'moltbook') return !!accountConfig.apiKeys.moltbookApiKey
          if (p === 'clawstr') return !!accountConfig.apiKeys.clawstrSecretKey
          return false
        })
      : PLATFORMS

    if (available.length === 0) return null

    for (let i = 0; i < available.length; i++) {
      const idx = (currentIndex + i) % available.length
      const platform = available[idx]
      if (!(await isRateLimited(redis, redisKeyPrefix, platform, account))) {
        accountPlatformIndex[account] = (idx + 1) % available.length
        return platform
      }
    }
    return null
  }

  async function isDuplicate(platform: Platform, content: string, account: string): Promise<boolean> {
    try {
      const prefix = content.slice(0, 100).toLowerCase()
      const { data, error } = await supabase
        .from('posts_agent')
        .select('content')
        .eq('platform', platform)
        .eq('account', account)
        .order('created_at', { ascending: false })
        .limit(50) as { data: { content: string }[] | null; error: { message: string } | null }

      if (error || !data) return false
      return data.some(post => {
        const existing = post.content || ''
        return existing.slice(0, 100).toLowerCase() === prefix || isIdeaLevelDuplicate(existing, content)
      })
    } catch { return false }
  }

  async function insertPost(
    platform: Platform,
    externalId: string,
    content: string,
    account: string,
    angleId?: string,
    title?: string,
    url?: string,
  ): Promise<void> {
    const { error } = await supabase
      .from('posts_agent')
      .insert({ platform, account, external_id: externalId, content, title: title || null, url: url || null, angle_id: angleId || null }) as { error: { message: string } | null }
    if (error) logger.error({ error: error.message, platform, account }, 'Failed to insert post')
  }

  async function setAngleCooldown(account: string, angleId: string, ttlSeconds: number): Promise<void> {
    const key = makeKey(redisKeyPrefix, 'angle_cd', account, angleId)
    await redis.set(key, '1', 'EX', ttlSeconds)
  }

  async function pickAngleForPlatform(account: string, platform: Platform): Promise<AgentPostAngle> {
    const accountConfig = accounts[account]
    if (!accountConfig) {
      throw new Error(`No account config for ${account}`)
    }

    let pool: AgentPostAngle[]
    if (platform === 'moltbook' && accountConfig.moltbookAngles && accountConfig.moltbookAngles.length > 0) {
      const useShared = Math.random() < 0.25 && accountConfig.sharedAngles.length > 0
      pool = useShared ? accountConfig.sharedAngles : accountConfig.moltbookAngles
    } else {
      const useShared = Math.random() < 0.25 && accountConfig.sharedAngles.length > 0
      pool = useShared ? accountConfig.sharedAngles : accountConfig.angles
    }

    // Filter cooled-down angles
    const results = await Promise.all(
      pool.map(async angle => {
        const key = makeKey(redisKeyPrefix, 'angle_cd', account, angle.id)
        const val = await redis.get(key)
        return { angle, onCooldown: val !== null }
      }),
    )
    const available = results.filter(r => !r.onCooldown).map(r => r.angle)
    const eligiblePool = available.length > 0 ? available : pool

    if (onPickAngle) {
      return onPickAngle(eligiblePool, platform, account)
    }

    return pickRandom(eligiblePool)
  }

  async function safeSetRateLimit(platform: Platform, account: string, ttlSeconds: number): Promise<void> {
    try {
      await setRateLimit(redis, redisKeyPrefix, platform, account, ttlSeconds)
    } catch (err) {
      logger.error({ platform, account, error: err instanceof Error ? err.message : String(err) }, 'Failed to set rate limit')
    }
  }

  async function postToPlatformDirect(platform: Platform, account: string, retryAfterEngage = false): Promise<AgentPostResult> {
    const accountConfig = accounts[account]
    if (!accountConfig) {
      return { posted: false, platform, account, error: `No config for account ${account}` }
    }

    let angle: AgentPostAngle | undefined

    try {
      if (await isRateLimited(redis, redisKeyPrefix, platform, account)) {
        const retryAfterSeconds = await getRateLimitRemainingSeconds(redis, redisKeyPrefix, platform, account)
        logger.info({ platform, account, retryAfterSeconds }, 'Platform is rate limited, skipping direct post')
        return { posted: false, platform, account, allRateLimited: true, retryAfterSeconds }
      }

      // On the recursive retry after a 429 re-engage, onBeforePost was already
      // called successfully in the catch block below — don't call it again.
      const engaged = retryAfterEngage ? true : await onBeforePost(platform, account)
      if (!engaged) {
        logger.warn({ platform, account }, 'Pre-engagement failed, skipping post')
        return { posted: false, platform, account, error: 'Pre-engagement failed, skipping post' }
      }

      angle = await pickAngleForPlatform(account, platform)
      const generationAngle = onHydrateAngle ? await onHydrateAngle(angle, platform, account) : angle
      const shouldIncludeFooter = Math.random() < 0.3
      const footer = shouldIncludeFooter ? (accountConfig.footers?.[platform] || '') : ''

      const [winnerPatterns, recentSameAngle, recentPlatformPosts, brief] = await Promise.all([
        onGetWinnerPatterns ? onGetWinnerPatterns(platform, account) : Promise.resolve(undefined),
        onGetRecentAnglePosts ? onGetRecentAnglePosts(account, angle.id) : Promise.resolve(undefined),
        onGetRecentPlatformPosts ? onGetRecentPlatformPosts(platform, account) : Promise.resolve(undefined),
        generateInsightBrief(generationAngle, platform, aiClient, ai.model, logger),
      ])

      let generated: { content: string; title?: string } | null = null

      for (let attempt = 1; attempt <= MAX_GENERATION_ATTEMPTS; attempt++) {
        generated = await generateAgentPostContent(platform, generationAngle, accountConfig, aiClient, ai.model, logger, winnerPatterns, recentSameAngle, recentPlatformPosts, brief)

        if (hasRepetitiveOpening(generated.content)) {
          logger.info(
            { platform, account, angle: angle.id, attempt, opening: generated.content.slice(0, 40) },
            'Repetitive opening detected, regenerating',
          )
          generated = null
          continue
        }

        if (await isDuplicate(platform, generated.content + footer, account)) {
          logger.info({ platform, account, angle: angle.id, attempt }, 'Duplicate content, regenerating')
          generated = null
          continue
        }

        break
      }

      if (!generated) {
        return {
          posted: false,
          platform,
          account,
          angleId: angle.id,
          error: `Unable to generate a valid post after ${MAX_GENERATION_ATTEMPTS} attempts`,
        }
      }

      let result: PostResult
      let contentForDb: string
      let postTitle: string | undefined

      switch (platform) {
        case 'moltx': {
          const key = accountConfig.apiKeys.moltxApiKey
          if (!key) throw new Error('Moltx API key not configured')
          contentForDb = generated.content + footer
          result = await postToMoltx(contentForDb, key)
          break
        }
        case 'moltbook': {
          const key = accountConfig.apiKeys.moltbookApiKey
          if (!key) throw new Error('Moltbook API key not configured')
          const submolt = pickSubmoltForAngle(angle.id, accountConfig.moltbookSubmoltMap)
          postTitle = generated.title || angle.angle
          contentForDb = generated.content + footer
          result = await postToMoltbook(submolt, postTitle, contentForDb, key, aiClient, ai.model, logger)
          break
        }
        case 'clawstr': {
          const key = accountConfig.apiKeys.clawstrSecretKey
          if (!key) throw new Error('Clawstr secret key not configured')
          const subclaw = pickRandom(CLAWSTR_SUBCLAWS)
          contentForDb = generated.content + footer
          result = await postToClawstr(subclaw, contentForDb, key)
          break
        }
      }

      // The post is now live — isolate bookkeeping so a Redis/Supabase hiccup here
      // can't turn a successful post into a reported failure or an uncaught throw.
      try {
        await setRateLimit(redis, redisKeyPrefix, platform, account, RATE_LIMIT_SECONDS[platform])
        await insertPost(platform, result.externalId, contentForDb, account, angle.id, postTitle, result.url)
        await setAngleCooldown(account, angle.id, 24 * 60 * 60)
        if (onPostSuccess) await onPostSuccess(platform, account)
      } catch (bookkeepingError) {
        logger.error(
          {
            platform,
            account,
            angleId: angle.id,
            externalId: result.externalId,
            error: bookkeepingError instanceof Error ? bookkeepingError.message : String(bookkeepingError),
          },
          'Post succeeded but post-success bookkeeping failed',
        )
      }

      return { posted: true, platform, account, angleId: angle.id, externalId: result.externalId, title: postTitle }
    } catch (error) {
      const msg = error instanceof Error ? error.message : String(error)
      logger.error({ platform, account, angleId: angle?.id, error: msg }, `Failed to post to ${platform}`)

      if (platform === 'moltbook' && msg.includes('403') && msg.toLowerCase().includes('claim')) {
        const retryAfterSeconds = 24 * 60 * 60
        await safeSetRateLimit(platform, account, retryAfterSeconds)
        return { posted: false, platform, account, angleId: angle?.id, error: msg, retryAfterSeconds }
      }

      if (platform === 'moltx' && msg.includes('403') && msg.toLowerCase().includes('wallet required')) {
        const retryAfterSeconds = 24 * 60 * 60
        await safeSetRateLimit(platform, account, retryAfterSeconds)
        return { posted: false, platform, account, angleId: angle?.id, error: msg, retryAfterSeconds }
      }

      if (msg.includes('429') && msg.toLowerCase().includes('engage')) {
        if (!retryAfterEngage) {
          logger.info({ account, platform }, '429 engage required, re-engaging and retrying')
          await clearEngageCooldown(redis, redisKeyPrefix, platform, account)
          const reEngaged = await onBeforePost(platform, account)
          if (reEngaged) {
            return postToPlatformDirect(platform, account, true)
          }
        }
        const retryAfterSeconds = 60 * 60
        await safeSetRateLimit(platform, account, retryAfterSeconds)
        return { posted: false, platform, account, angleId: angle?.id, error: msg, retryAfterSeconds }
      }

      const retryAfterSeconds = RATE_LIMIT_SECONDS[platform]
      await safeSetRateLimit(platform, account, retryAfterSeconds)
      return { posted: false, platform, account, angleId: angle?.id, error: msg, retryAfterSeconds }
    }
  }

  return {
    async postToNextPlatform(account: string): Promise<AgentPostResult> {
      const platform = await getNextPlatform(account)
      if (!platform) return { posted: false, account, allRateLimited: true }
      return postToPlatformDirect(platform, account)
    },

    postToPlatformDirect,

    async getNextPostTime(account: string): Promise<Date | null> {
      return getNextTime(redis, redisKeyPrefix, account)
    },

    async setNextPostTime(time: Date, account: string): Promise<void> {
      return setNextTime(redis, redisKeyPrefix, account, time)
    },
  }
}

// ── Reply module re-exports ───────────────────────────────────────────────────

export { createAgentReplier } from './reply/replier.js'
export { classifyReply, DEFAULT_CLASSIFICATION } from './reply/classify.js'
export { generateReply, buildReplySystemPrompt, parseGeneratedReplyContent } from './reply/generate.js'
export { fetchPlatformReplies } from './reply/fetch.js'
export { postReplyOnPlatform } from './reply/post.js'
export type {
  AgentReplier,
  ContextProvider,
  ContextProviderInput,
  LiveContext,
  OriginalPost,
  PlatformReply,
  ProcessedReply,
  ReplierApiKeys,
  ReplierConfig,
  ReplierLifecycleHooks,
  ReplyCategory,
  ReplyClassification,
  ReplyPostResult,
  ReplyProcessResult,
} from './reply/types.js'
export { REPLY_CHAR_LIMITS } from './reply/types.js'
