#!/usr/bin/env node
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'

function parseArgs(argv) {
  const args = { _: [] }
  for (let i = 0; i < argv.length; i += 1) {
    const token = argv[i]
    if (token.startsWith('--')) {
      const key = token.slice(2)
      const next = argv[i + 1]
      if (!next || next.startsWith('--')) {
        args[key] = true
      } else {
        args[key] = next
        i += 1
      }
    } else {
      args._.push(token)
    }
  }
  return args
}

function expandHome(filePath) {
  if (!filePath) return filePath
  if (filePath === '~') return process.env.HOME || filePath
  if (filePath.startsWith('~/')) return path.join(process.env.HOME || '.', filePath.slice(2))
  return filePath
}

function readJson(filePath, fallback) {
  const resolved = expandHome(filePath)
  if (!resolved || !fs.existsSync(resolved)) return fallback
  return JSON.parse(fs.readFileSync(resolved, 'utf8'))
}

function writeJson(filePath, value) {
  const resolved = expandHome(filePath)
  fs.mkdirSync(path.dirname(resolved), { recursive: true })
  const tempPath = `${resolved}.tmp-${process.pid}`
  fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`)
  fs.renameSync(tempPath, resolved)
}

function normalizeDate(value) {
  const date = new Date(value || Date.now())
  return Number.isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString()
}

function assertFetchableUrl(url) {
  const parsed = new URL(url)
  if (!['http:', 'https:'].includes(parsed.protocol)) {
    throw new Error(`Unsupported URL protocol for ${url}; use http or https`)
  }
}

function hashEvent(event) {
  const input = [
    event.sourceId,
    event.url,
    event.title,
    event.publishedAt,
    event.summary,
    event.contentFingerprint || '',
  ].join('\n')
  return `sha256:${crypto.createHash('sha256').update(input).digest('hex')}`
}

function fingerprint(value) {
  return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`
}

function stripTags(value = '') {
  return String(value)
    .replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
    .replace(/<[^>]+>/g, ' ')
    .replace(/\s+/g, ' ')
    .trim()
}

function htmlVisibleText(html = '') {
  return stripTags(
    String(html)
      .replace(/<!--([\s\S]*?)-->/g, ' ')
      .replace(/<(script|style|svg|noscript)\b[^>]*>[\s\S]*?<\/\1>/gi, ' '),
  )
}

function htmlTitle(html = '') {
  const match = String(html).match(/<title[^>]*>([\s\S]*?)<\/title>/i)
  return match ? stripTags(match[1]) : ''
}

function htmlDescription(html = '') {
  const patterns = [
    /<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["'][^>]*>/i,
    /<meta[^>]+content=["']([^"']+)["'][^>]+name=["']description["'][^>]*>/i,
    /<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["'][^>]*>/i,
    /<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:description["'][^>]*>/i,
  ]
  for (const pattern of patterns) {
    const match = String(html).match(pattern)
    if (match) return stripTags(match[1])
  }
  return ''
}

function getXmlTag(item, tag) {
  const match = item.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i'))
  return match ? stripTags(match[1]) : ''
}

function getXmlLink(item) {
  const href = item.match(/<link[^>]+href=["']([^"']+)["'][^>]*>/i)
  if (href) return href[1]
  return getXmlTag(item, 'link')
}

function parseRss(xml, source) {
  const blocks = [...xml.matchAll(/<(item|entry)\b[^>]*>([\s\S]*?)<\/\1>/gi)].map((match) => match[2])
  return blocks.map((item) => {
    const title = getXmlTag(item, 'title') || 'Untitled feed item'
    const url = getXmlLink(item)
    const publishedAt = getXmlTag(item, 'pubDate') || getXmlTag(item, 'published') || getXmlTag(item, 'updated') || new Date().toISOString()
    const summary = getXmlTag(item, 'description') || getXmlTag(item, 'summary') || getXmlTag(item, 'content') || ''
    const normalized = {
      sourceId: source.id,
      sourceType: 'rss',
      product: source.product || source.id,
      importance: source.importance || 'medium',
      title,
      url,
      publishedAt: normalizeDate(publishedAt),
      summary,
    }
    return withSourceMetadata(normalized, source)
  }).filter((event) => event.url)
}

function withSourceMetadata(normalized, source) {
  const enriched = {
    ...normalized,
    category: source.category || 'package updates',
    tier: Number.isInteger(source.tier) ? source.tier : 2,
    coverageGroup: source.coverageGroup || 'uncategorized',
    official: source.official === true,
    importanceScore: Number.isInteger(source.importanceScore) ? source.importanceScore : 50,
    suggestedBlsActions: Array.isArray(source.suggestedBlsActions) ? source.suggestedBlsActions : [],
  }
  return { ...enriched, hash: hashEvent(enriched) }
}

function normalizeGithubRelease(release, source) {
  const normalized = {
    sourceId: source.id,
    sourceType: 'github-releases',
    product: source.product || source.id,
    importance: source.importance || 'medium',
    title: release.name || release.tag_name || 'Untitled release',
    url: release.html_url,
    publishedAt: release.published_at || release.created_at || new Date().toISOString(),
    summary: stripTags(release.body || ''),
  }
  return withSourceMetadata(normalized, source)
}

async function fetchResource(url) {
  const response = await fetch(url, {
    signal: AbortSignal.timeout(20000),
    headers: {
      accept: 'application/json, application/rss+xml, application/atom+xml, text/html, text/xml;q=0.9, */*;q=0.8',
      'user-agent': 'buildleansaas-release-source-capture/2.0',
    },
  })
  if (!response.ok) throw new Error(`Fetch failed ${response.status} for ${url}`)
  return {
    body: await response.text(),
    contentType: response.headers.get('content-type') || '',
    etag: response.headers.get('etag') || '',
    lastModified: response.headers.get('last-modified') || '',
    finalUrl: response.url || url,
  }
}

function normalizeWebPage(resource, source) {
  const visibleText = htmlVisibleText(resource.body)
  if (!visibleText) throw new Error(`Web page source had no visible text: ${source.id}`)

  const description = htmlDescription(resource.body)
  const summary = (description || visibleText).slice(0, 1200)
  const normalized = {
    sourceId: source.id,
    sourceType: 'web-page',
    product: source.product || source.id,
    importance: source.importance || 'medium',
    title: source.eventTitle || htmlTitle(resource.body) || `${source.product || source.id} official page updated`,
    url: resource.finalUrl || source.url,
    publishedAt: resource.lastModified ? normalizeDate(resource.lastModified) : '1970-01-01T00:00:00.000Z',
    summary,
    contentFingerprint: fingerprint(visibleText),
  }
  return withSourceMetadata(normalized, source)
}

async function captureSource(source) {
  if (!source.id || !source.type || !source.url) throw new Error(`Invalid source: ${JSON.stringify(source)}`)
  assertFetchableUrl(source.url)
  const resource = await fetchResource(source.url)
  if (source.type === 'github-releases') {
    const releases = JSON.parse(resource.body)
    if (!Array.isArray(releases)) throw new Error(`GitHub releases source did not return an array: ${source.id}`)
    return releases.map((release) => normalizeGithubRelease(release, source)).filter((event) => event.url)
  }
  if (source.type === 'rss') {
    return parseRss(resource.body, source)
  }
  if (source.type === 'web-page') {
    return [normalizeWebPage(resource, source)]
  }
  throw new Error(`Unsupported source type ${source.type}; supported: github-releases, rss, web-page`)
}

function normalizeRegistry(registry) {
  if (Array.isArray(registry)) {
    return {
      sources: registry,
      thresholds: { minimumReportScore: 0 },
      reportSections: ['package updates', 'frontier model updates', 'operator/harness updates', 'content/course opportunities'],
    }
  }
  if (registry && Array.isArray(registry.sources)) {
    return {
      sources: registry.sources,
      thresholds: registry.thresholds || { minimumReportScore: 0 },
      reportSections: registry.reportSections || ['package updates', 'frontier model updates', 'operator/harness updates', 'content/course opportunities'],
    }
  }
  throw new Error('Registry must be a JSON array or an object with a sources array')
}

function buildReportSections(events, reportSections) {
  const grouped = Object.fromEntries(reportSections.map((section) => [section, []]))
  for (const event of events) {
    const section = grouped[event.category] ? event.category : 'package updates'
    grouped[section].push(event)
    if (event.suggestedBlsActions?.length && grouped['content/course opportunities']) {
      grouped['content/course opportunities'].push({
        sourceId: event.sourceId,
        product: event.product,
        title: event.title,
        url: event.url,
        importanceScore: event.importanceScore,
        suggestedBlsActions: event.suggestedBlsActions,
      })
    }
  }
  return grouped
}

function positiveInteger(value, fallback) {
  const parsed = Number.parseInt(value, 10)
  return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
}

function withinSinceWindow(event, sinceDays) {
  if (!sinceDays || event.sourceType === 'web-page') return true
  const published = new Date(event.publishedAt).getTime()
  if (!Number.isFinite(published)) return true
  return published >= Date.now() - sinceDays * 24 * 60 * 60 * 1000
}

async function captureInBatches(registry, concurrency) {
  const results = []
  for (let index = 0; index < registry.length; index += concurrency) {
    const batch = registry.slice(index, index + concurrency)
    const batchResults = await Promise.all(batch.map(async (source) => {
      try {
        return { source, events: await captureSource(source), error: null }
      } catch (error) {
        return { source, events: [], error }
      }
    }))
    results.push(...batchResults)
  }
  return results
}

function buildCoverage(registry, sourceResults, emittedEvents) {
  const failedIds = sourceResults.filter((result) => result.error).map((result) => result.source.id)
  const succeededIds = sourceResults.filter((result) => !result.error).map((result) => result.source.id)
  const eventSourceIds = new Set(emittedEvents.map((event) => event.sourceId))
  const byCoverageGroup = {}
  for (const source of registry) {
    const group = source.coverageGroup || 'uncategorized'
    byCoverageGroup[group] ||= { configured: 0, succeeded: 0, failed: 0, withNewEvents: 0 }
    byCoverageGroup[group].configured += 1
    if (failedIds.includes(source.id)) byCoverageGroup[group].failed += 1
    else byCoverageGroup[group].succeeded += 1
    if (eventSourceIds.has(source.id)) byCoverageGroup[group].withNewEvents += 1
  }
  return {
    configured: registry.length,
    succeeded: succeededIds.length,
    failed: failedIds.length,
    sourcesWithNewEvents: eventSourceIds.size,
    succeededIds,
    failedIds,
    zeroNewEventIds: succeededIds.filter((id) => !eventSourceIds.has(id)),
    byCoverageGroup,
  }
}

async function capture(args) {
  const registryPath = args.registry || process.env.RELEASE_SOURCE_REGISTRY
  const statePath = args.state || process.env.RELEASE_SOURCE_STATE_PATH || '~/.release-source-capture/state.json'
  if (!registryPath) throw new Error('Missing --registry ./release-sources.json')

  const registryConfig = normalizeRegistry(readJson(registryPath, null))
  const registry = registryConfig.sources
  const sinceDays = positiveInteger(args['since-days'], 0)
  const limitPerSource = positiveInteger(args['limit-per-source'], 10)
  const concurrency = positiveInteger(args.concurrency, 6)

  const state = readJson(statePath, { seen: {} })
  const events = []
  const errors = []
  const sourceResults = await captureInBatches(registry, concurrency)

  for (const result of sourceResults) {
    const { source } = result
    if (result.error) {
      errors.push({ sourceId: source.id || 'unknown', message: result.error.message })
      continue
    }

    state.seen[source.id] ||= {}
    const sourceEvents = result.events
      .filter((event) => withinSinceWindow(event, sinceDays))
      .sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime())
      .slice(0, limitPerSource)

    for (const event of sourceEvents) {
      if (event.importanceScore < (registryConfig.thresholds.minimumReportScore || 0)) continue
      if (state.seen[source.id][event.hash]) continue
      events.push(event)
      state.seen[source.id][event.hash] = {
        url: event.url,
        title: event.title,
        seenAt: new Date().toISOString(),
      }
    }
  }

  if (!args['dry-run']) writeJson(statePath, state)

  const output = {
    dryRun: Boolean(args['dry-run']),
    generatedAt: new Date().toISOString(),
    normalized: true,
    statePath: expandHome(statePath),
    options: { sinceDays, limitPerSource, concurrency },
    coverage: buildCoverage(registry, sourceResults, events),
    events,
    reportSections: buildReportSections(events, registryConfig.reportSections),
    thresholds: registryConfig.thresholds,
    errors,
  }
  console.log(JSON.stringify(output, null, 2))

  if (errors.length > 0) process.exitCode = 2
}

async function main() {
  const args = parseArgs(process.argv.slice(2))
  const command = args._[0]
  if (command !== 'capture') {
    console.error('Usage: release-source-capture.mjs capture --registry ./release-sources.json --state ~/.release-source-capture/state.json [--since-days 8] [--limit-per-source 10] [--concurrency 6] [--dry-run]')
    process.exit(1)
  }
  await capture(args)
}

main().catch((error) => {
  console.error(error.stack || error.message)
  process.exit(1)
})
