/**
 * services/linkHealthChecker.js
 *
 * Check whether a takedown URL is still serving the NCII content.
 * Returns one of:
 *   - 'live'       — 200, body size > threshold, body contains NCII keywords
 *   - 'removed'    — 404 / 410 / 451 (host removed the content)
 *   - 'soft_404'   — 200 but body looks like a "not found" placeholder
 *                     (WordPress / Cloudflare default error pages)
 *   - 'blocked'    — 403 / rate-limited / JS challenge. Couldn't tell.
 *                     Operator should retry later or escalate.
 *   - 'dns_fail'   — DNS or network error. Host may be down.
 *   - 'error'      — anything else
 *
 * All probes go through Scrape.do (Cloudflare-friendly) so we get
 * past the JS challenge that the original scrape layer uses. The
 * Scrape.do call costs 1 credit per check.
 */

'use strict';

const keyRotator = require('./keyRotator');
const crypto = require('crypto');

// Phrases that strongly suggest NCII content is still on the page.
// We do simple substring matching, not NLP — the body is what the
// operator would see, so a body that contains "nude" / "leaked" /
// the victim name in the context of a "free" or "watch" CTA is
// the smoking gun.
const NCII_INDICATORS = [
  /\bnude\b/i, /\bnudes\b/i, /\bnude[- ]leak/i, /\bleak(ed)?\b/i,
  /\bnsfw\b/i, /\bexplicit\b/i, /\bvoyeur\b/i, /\bxxx\b/i,
  /\bonlyfans\b/i, /\bescort\b/i, /\bwebcam\b/i,
  /\bporn\b/i, /\bsex\s*tape/i, /\brevenge\b/i,
];

// Common "soft 404" markers. A page that 200s but contains one of
// these is almost certainly the host's "not found" page, not the
// real content.
const SOFT_404_MARKERS = [
  // Generic
  /page not found/i, /not\s*found/i, /404\.html/i,
  /the page you[\s'’]?re looking for (doesn[\s'’]?t|can[\s'’]?t)/i,
  /we couldn[\s'’]?t find/i, /this (album|video|post) (has been|was) removed/i,
  /content (has been|was) removed/i, /removed by (the )?(user|author|moderator)/i,
  /account has been terminated/i, /page (no longer|is no longer) exists/i,
  /this (item|listing|upload) (has been|was) (removed|deleted|taken down)/i,
  /unavailable[, ]+removed/i, /no longer available/i,
  // EroMe / fapello-style
  /album (was|has been) deleted/i, /this (album|video) is (no longer|private|deleted)/i,
  /post (was|has been) (removed|deleted)/i, /uploader (deleted|removed) (this|their)/i,
  /deleted by (the )?(user|author|owner)/i,
  // WordPress / generic CMS
  /page not found/i, /the page you are looking for/i,
  // Cloudflare / nginx default error
  /the site owner may have (removed|moved|restricted)/i,
];

const MIN_BODY_FOR_LIVE = 1024;        // <1KB probably an error/redirect page
const FETCH_TIMEOUT_MS  = 45000;       // Scrape.do can be slow

async function fetchViaScrape(targetUrl) {
  return keyRotator.runWithRotation('scrape_do', async (k) => {
    const u = new URL('https://api.scrape.do/');
    u.searchParams.set('token', k.key_value);
    u.searchParams.set('url', targetUrl);
    u.searchParams.set('render', 'true');
    u.searchParams.set('super', 'true');     // Cloudflare-friendly
    // Scrape.do's `timeout` param must be 5000-120000 (milliseconds).
    // The previous code passed '30' which Scrape.do interpreted as
    // 30ms (below the minimum) and 400'd. 30000 = 30s, plenty.
    u.searchParams.set('timeout', '30000');
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
    try {
      const r = await fetch(u.toString(), { signal: controller.signal });
      if (!r.ok) {
        const t = await r.text().catch(() => '');
        throw new Error(`scrape ${r.status}: ${t.slice(0, 200)}`);
      }
      const text = await r.text();
      // Scrape.do returns 200 with a JSON body containing `statusCode`
      // and `body` for the upstream response, or just the raw HTML for
      // simple fetches. Detect which one we got.
      if (text.trimStart().startsWith('{') && text.includes('"statusCode"')) {
        try {
          const parsed = JSON.parse(text);
          return {
            status: parsed.statusCode,
            body: parsed.body || '',
            // Scrape.do doesn't always give us the final URL
            finalUrl: parsed.url || targetUrl,
            responseMs: 0,
          };
        } catch (_) { /* fall through to plain text */ }
      }
      return { status: 200, body: text, finalUrl: targetUrl, responseMs: 0 };
    } finally {
      clearTimeout(timer);
    }
  }).then(r => r.result);
}

/**
 * Classify a fetched response into one of our status labels.
 *
 * Pass `previousBodySize` to enable body-shrink detection: if the
 * body is now <20% of its previous size (and was previously large
 * enough to be real content), we call it 'soft_404' even if we
 * can't pattern-match a "removed" phrase. This catches the case
 * where a site replaces a multi-MB page with a 30-byte "OK".
 */
function classify({ status, body, previousBodySize = null }) {
  // Hard status codes that mean "removed"
  if (status === 404 || status === 410 || status === 451) return 'removed';

  // 403 / 429 / 503 — we couldn't tell
  if (status === 403 || status === 429 || status === 503) return 'blocked';
  if (status >= 500 && status < 600) return 'error';

  // 200 — we got a body, but is it the real content or a placeholder?
  if (status >= 200 && status < 300) {
    const sz = (body || '').length;
    if (!body || sz < MIN_BODY_FOR_LIVE) {
      // Body shrank dramatically? That's the "replaced with 30-byte
      // OK" case — call it removed.
      if (previousBodySize && previousBodySize > MIN_BODY_FOR_LIVE * 5 && sz < previousBodySize / 5) {
        return 'soft_404';
      }
      return 'soft_404';
    }
    // Body shrank dramatically but is still above MIN_BODY_FOR_LIVE
    // — could be a real page with placeholder text, or genuine
    // content loss. If we see soft-404 markers, trust them.
    if (previousBodySize && previousBodySize > MIN_BODY_FOR_LIVE * 5 && sz < previousBodySize / 3) {
      // Cross-check with soft-404 markers
      for (const re of SOFT_404_MARKERS) {
        if (re.test(body)) return 'soft_404';
      }
      // No marker, but dramatic shrink. Operator should look at this
      // one — return 'soft_404' to be conservative (false-positive
      // removed is safer than false-positive live).
      return 'soft_404';
    }
    for (const re of SOFT_404_MARKERS) {
      if (re.test(body)) return 'soft_404';
    }
    // Heuristic: still serving NCII if any of the indicators match
    for (const re of NCII_INDICATORS) {
      if (re.test(body)) return 'live';
    }
    // 200, no NCII indicators, no soft-404 markers, no dramatic
    // shrink — could be the content was replaced with a blank page,
    // a "removed" notice we don't recognize, or a landing page. Treat
    // as soft_404 to be safe (operator will see a 'removed' badge,
    // not 'live').
    return 'soft_404';
  }
  return 'error';
}

async function checkUrl(targetUrl, { victimName = null, previousBodySize = null } = {}) {
  const t0 = Date.now();
  let result;
  try {
    result = await fetchViaScrape(targetUrl);
    } catch (e) {
      // Distinguish "host is gone" from "Scrape.do quota exhausted".
      // A 401 with "Monthly request limit exceeded" means WE can't
      // probe right now — not a takedown issue. Treat as 'blocked'
      // so the operator retries later, and the takedown stays 'sent'.
      const msg = String(e && e.message || e);
      const isScrapeQuota = /monthly request limit|quota exceeded|api\s*key/i.test(msg);
      return {
        status: isScrapeQuota ? 'blocked' : 'dns_fail',
        http_status: null,
        reason: isScrapeQuota
          ? 'Scrape.do quota exhausted — retry later'
          : ('fetch failed: ' + msg),
        body_size: 0,
        body_hash: null,
        response_ms: Date.now() - t0,
      };
    }
  const classification = classify({ ...result, previousBodySize });
  const body = result.body || '';
  const body_hash = body ? crypto.createHash('sha1').update(body).digest('hex').slice(0, 16) : null;
  const reason = explain(classification, result, victimName);
  return {
    status: classification,
    http_status: result.status,
    reason,
    body_size: body.length,
    body_hash,
    response_ms: Date.now() - t0,
  };
}

function explain(classification, result, victimName) {
  switch (classification) {
    case 'live':
      return 'Page is still serving NCII content (200, body matches NCII indicators)';
    case 'removed':
      return `Origin returned ${result.status} — content has been removed`;
    case 'soft_404':
      return 'Page returned 200 but body looks like a placeholder (size=' + (result.body || '').length + ' bytes, no NCII indicators found)';
    case 'blocked':
      return `Upstream blocked the probe (${result.status}) — retry later or escalate`;
    case 'dns_fail':
      return 'Host unreachable / DNS failure';
    default:
      return 'Probe error';
  }
}

module.exports = { checkUrl, classify, explain, fetchViaScrape };
