#!/usr/bin/env node
/**
 * Fortnightly patch index refresh — called by .github/workflows/patch-index-refresh.yml
 *
 * Fetches Oracle security advisory pages, sends them + the current patch index
 * to Claude, and writes an updated data/oracle-patches.json if new patches exist.
 *
 * Exit codes:
 *   0 — patch index was updated (workflow creates a PR)
 *   2 — fatal error (workflow fails the run)
 */

'use strict';

// ── Process-level safety nets ─────────────────────────────────────────────────
// Catch any unhandled rejection or exception and log the full stack before
// exiting. Without these, Node 20 kills the process with exit code 1 and
// no output — making CI failures impossible to diagnose.

process.on('unhandledRejection', (reason) => {
  console.error('[fatal] Unhandled promise rejection:');
  console.error(reason instanceof Error ? reason.stack : reason);
  process.exit(2);
});

process.on('uncaughtException', (err) => {
  console.error('[fatal] Uncaught exception:');
  console.error(err.stack || err);
  process.exit(2);
});

const https   = require('https');
const http    = require('http');
const fs      = require('fs');
const path    = require('path');

const INDEX_PATH = path.join(__dirname, '..', 'data', 'oracle-patches.json');

// ── HTTP fetch with redirect + timeout ────────────────────────────────────────

function fetchUrl(url, maxRedirects = 6, timeoutMs = 30000) {
  return new Promise((resolve, reject) => {
    const mod = url.startsWith('https') ? https : http;
    const req = mod.get(url, {
      headers: {
        'User-Agent': 'TuneVault-SecurityResearch/1.0 (+https://tunevault.app)',
        'Accept': 'text/html,application/xml,application/xhtml+xml,*/*',
      },
    }, res => {
      if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location && maxRedirects > 0) {
        res.resume();
        return fetchUrl(res.headers.location, maxRedirects - 1, timeoutMs).then(resolve).catch(reject);
      }
      const chunks = [];
      res.on('data', c => chunks.push(c));
      res.on('end', () => {
        if (res.statusCode !== 200) {
          reject(new Error(`HTTP ${res.statusCode} — ${url}`));
        } else {
          resolve(Buffer.concat(chunks).toString('utf8'));
        }
      });
      res.on('error', reject);
    });
    req.setTimeout(timeoutMs, () => {
      req.destroy(new Error(`Timeout (${timeoutMs}ms) — ${url}`));
    });
    req.on('error', reject);
  });
}

// ── Oracle sources ────────────────────────────────────────────────────────────

async function gatherOracleContent() {
  const sources = [
    { label: 'security-alerts-page',  url: 'https://www.oracle.com/security-alerts/' },
    { label: 'security-alerts-rss',   url: 'https://www.oracle.com/ocom/groups/public/@otn/documents/webcontent/rss-otn-sec.xml' },
    { label: 'db-patch-notes-19c',    url: 'https://www.oracle.com/docs/tech/database/dbru-19c-complete.html' },
  ];

  const results = {};
  await Promise.allSettled(
    sources.map(async ({ label, url }) => {
      try {
        const text = await fetchUrl(url);
        results[label] = text.substring(0, 40000); // cap per source
        console.log(`[fetch] ${label}: ${text.length} bytes`);
      } catch (e) {
        console.warn(`[fetch] ${label} unavailable: ${e.message}`);
        results[label] = '';
      }
    })
  );
  return results;
}

// ── Claude call ───────────────────────────────────────────────────────────────

async function callClaude(currentIndex, oracleContent) {
  const { default: Anthropic } = await import('@anthropic-ai/sdk');
  const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });

  const today = new Date().toISOString().split('T')[0];
  const fortnight = new Date(Date.now() + 14 * 86400 * 1000).toISOString().split('T')[0];

  const prompt = `You are maintaining oracle-patches.json for TuneVault, an Oracle DBA monitoring platform.

<current_index>
${JSON.stringify(currentIndex, null, 2)}
</current_index>

<oracle_security_alerts_page>
${oracleContent['security-alerts-page'] || '(unavailable)'}
</oracle_security_alerts_page>

<oracle_security_rss>
${oracleContent['security-alerts-rss'] || '(unavailable)'}
</oracle_security_rss>

<oracle_db_patch_notes>
${oracleContent['db-patch-notes-19c'] || '(unavailable)'}
</oracle_db_patch_notes>

Today: ${today}
Current LAST_REFRESHED: ${currentIndex._meta.LAST_REFRESHED}

## Task

Scan the Oracle content above for CPU patches, PSU patches, or Out-of-Band (OOB) security alerts
released AFTER ${currentIndex._meta.LAST_REFRESHED} that are NOT already in the index.

Tracked components (only update these):
- rdbms: 19c, 21c, 26ai (was 23ai), 12.2, 18c  — use release_update_patches array
- weblogic: 12.2.1.4, 14.1.1.0                  — use patches array
- ebs: 12.2                                       — use adop_patches array (EBS RUPs via ADOP)

Patch object schema (match exactly):
{
  "patch_number": "<string>",
  "label": "<version> RU/<PSU> (<Quarter> CPU)",
  "release_date": "YYYY-MM-DD",
  "cpu_quarter": "YYYY-QN",
  "severity": "CRITICAL|HIGH|MEDIUM",
  "cvss_max": <number or null>,
  "cves": ["CVE-YYYY-NNNNN", ...],
  "highlights": "<short description>",
  "mos_doc_id": "<string>"
}

Rules:
1. Patches within each version are ordered newest-first. Insert new patches at the top.
2. Only include patches you are confident about from the Oracle content — do not invent data.
3. If you find new patches: update _meta.LAST_REFRESHED to "${today}", set next_refresh_due
   to ~3 months from today (next CPU quarter date), set refresh_cadence to "quarterly".
4. If NO new patches found: update _meta.LAST_REFRESHED to "${today}", set next_refresh_due
   to "${fortnight}", keep refresh_cadence as "fortnightly".
5. Update _meta.note to summarise the most recent CPU or OOB alert (replace old note).
6. Do NOT add entries for WebLogic 10.3.6 / FMW 11.1.x — those are end-of-support (Dec 2022)
   and documented separately via wls_eos_note in the vulnerability scanner.

Return ONLY the complete updated JSON. No markdown fences. No explanation. No comments.`;

  console.log(`[claude] Sending request — model=claude-sonnet-4-6, max_tokens=16000, prompt length=${prompt.length} chars`);

  const msg = await client.messages.create({
    model:      'claude-sonnet-4-6',
    max_tokens: 16000,
    messages:   [{ role: 'user', content: prompt }],
  });

  console.log(`[claude] Response received — stop_reason=${msg.stop_reason}, content blocks=${msg.content?.length}, usage=${JSON.stringify(msg.usage)}`);

  if (!msg.content || !msg.content[0] || msg.content[0].type !== 'text') {
    throw new Error(`Unexpected response structure: stop_reason=${msg.stop_reason}, content=${JSON.stringify(msg.content)}`);
  }
  if (msg.stop_reason === 'max_tokens') {
    throw new Error(`Response truncated (stop_reason=max_tokens) — output token limit hit. Increase max_tokens or reduce prompt size. Usage: ${JSON.stringify(msg.usage)}`);
  }

  const raw = msg.content[0].text.trim()
    .replace(/^```json\s*/i, '')
    .replace(/^```\s*/i, '')
    .replace(/\s*```$/i, '');

  console.log(`[claude] Raw response length: ${raw.length} chars`);

  return JSON.parse(raw);
}

// ── Validation ────────────────────────────────────────────────────────────────

function validate(updated, current) {
  const required = ['_meta', 'rdbms', 'weblogic', 'ebs'];
  for (const k of required) {
    if (!updated[k]) throw new Error(`Missing top-level key: ${k}`);
  }

  // Verify version keys weren't dropped
  for (const ver of Object.keys(current.rdbms || {})) {
    if (!updated.rdbms[ver]) throw new Error(`rdbms.${ver} was removed — rejecting`);
  }
  for (const ver of Object.keys(current.weblogic || {})) {
    if (!updated.weblogic[ver]) throw new Error(`weblogic.${ver} was removed — rejecting`);
  }

  // Verify patch counts didn't shrink (patches are append-only; only added at top)
  for (const [ver, data] of Object.entries(current.rdbms || {})) {
    const oldCount = (data.release_update_patches || []).length;
    const newCount = (updated.rdbms[ver]?.release_update_patches || []).length;
    if (newCount < oldCount) throw new Error(`rdbms.${ver} patch count shrank ${oldCount}→${newCount}`);
  }

  if (!updated._meta.LAST_REFRESHED) throw new Error('_meta.LAST_REFRESHED missing');
  if (!updated._meta.next_refresh_due) throw new Error('_meta.next_refresh_due missing');
}

// ── Summary for PR body ───────────────────────────────────────────────────────

function buildSummary(current, updated) {
  const lines = [];
  for (const [ver, data] of Object.entries(updated.rdbms || {})) {
    const oldCount = (current.rdbms?.[ver]?.release_update_patches || []).length;
    const newCount = (data.release_update_patches || []).length;
    if (newCount > oldCount) {
      const newest = data.release_update_patches[0];
      lines.push(`- **rdbms.${ver}**: +${newCount - oldCount} patch(es) — latest: ${newest.label} (${newest.cpu_quarter})`);
    }
  }
  for (const [ver, data] of Object.entries(updated.weblogic || {})) {
    const oldCount = (current.weblogic?.[ver]?.patches || []).length;
    const newCount = (data.patches || []).length;
    if (newCount > oldCount) {
      lines.push(`- **weblogic.${ver}**: +${newCount - oldCount} patch(es)`);
    }
  }
  const oldEbs = (current.ebs?.['12.2']?.adop_patches || []).length;
  const newEbs  = (updated.ebs?.['12.2']?.adop_patches || []).length;
  if (newEbs > oldEbs) lines.push(`- **ebs.12.2**: +${newEbs - oldEbs} RUP patch(es)`);

  return lines.length
    ? lines.join('\n')
    : '_No new patches found — only metadata timestamps updated._';
}

// ── Main ──────────────────────────────────────────────────────────────────────

async function main() {
  const apiKey = process.env.ANTHROPIC_API_KEY;
  if (!apiKey) {
    console.error('[fatal] ANTHROPIC_API_KEY is not set in the environment');
    process.exit(2);
  }
  console.log(`[init] ANTHROPIC_API_KEY: set (${apiKey.length} chars, prefix=${apiKey.slice(0, 10)}...)`);
  console.log(`[init] Node.js ${process.version}`);

  const current = JSON.parse(fs.readFileSync(INDEX_PATH, 'utf8'));
  console.log(`[init] Index loaded: LAST_REFRESHED=${current._meta.LAST_REFRESHED}, next_refresh_due=${current._meta.next_refresh_due}`);

  const oracleContent = await gatherOracleContent();
  const allEmpty = Object.values(oracleContent).every(v => !v);
  if (allEmpty) {
    console.warn('[warn] All Oracle sources returned HTTP 403 or were unreachable (common on CI IPs).');
    console.warn('[warn] Proceeding with Claude training knowledge only — validation layer will reject any unsafe output.');
  }

  console.log('[main] Calling Claude…');
  let updated;
  try {
    updated = await callClaude(current, oracleContent);
  } catch (e) {
    console.error('[fatal] Claude call failed:', e.message);
    console.error(e.stack);
    process.exit(2);
  }

  try {
    validate(updated, current);
  } catch (e) {
    console.error('[fatal] Validation failed:', e.message);
    console.error(e.stack);
    process.exit(2);
  }

  const summary = buildSummary(current, updated);
  fs.writeFileSync(INDEX_PATH, JSON.stringify(updated, null, 2) + '\n');

  // Write summary for workflow to pick up as PR body
  fs.writeFileSync(
    path.join(__dirname, '..', '.patch-refresh-summary.txt'),
    summary
  );

  console.log('[main] Patch index written.');
  console.log(summary);
  process.exit(0);
}

main().catch(e => {
  console.error('[fatal] Unhandled error in main():');
  console.error(e.stack || e);
  process.exit(2);
});
