// PR-native intelligence: the release performance tracker and the
// earned-media radar (new referring domains).

import {
  getSite, isDemo, breakdown, filteredBreakdown, pagesDailyByPrefix,
  resolveRange,
} from './ga4.js';
import { emailOverview } from './email.js';
import { emailReleaseMap, releaseTitles, releasesByDate, releaseViewsDated } from './legacy.js';
import { dbKind, dbIsDemo } from './db.js';
import { currentMarkets, currentLocales } from './market.js';

// ── GA4 ↔ platform reconciliation ──────────────────────────────────────
// The platform DB counts EVERY view server-side; GA4 only sees visitors who
// accepted analytics cookies (GDPR/CCPA consent). This measures the gap on
// releases both systems track, so "why don't the numbers match" has an answer.
export async function reconReport(siteId, rangeStr) {
  const site = getSite(siteId);
  if (!dbKind(site)) return null;
  const { startDate, endDate } = resolveRange(rangeStr || '28d');
  if (dbIsDemo(site)) {
    const rels = (await releasesByDate(siteId, rangeStr, 100).catch(() => []))
      .sort((a, b) => (b.viewed || 0) - (a.viewed || 0)).slice(0, 10);
    const seed = [...String(siteId)].reduce((a, c) => (a * 31 + c.charCodeAt(0)) >>> 0, 7);
    const rows = rels.map((r, i) => ({
      id: r.id, title: r.title, platformViews: Number(r.viewed) || 0,
      gaViews: Math.floor((Number(r.viewed) || 0) * (0.32 + ((seed >> (i % 16)) % 22) / 100)),
    }));
    const sp = rows.reduce((a, x) => a + x.platformViews, 0);
    const sg = rows.reduce((a, x) => a + x.gaViews, 0);
    return { rows, matched: rows.length, totals: { platform: sp, ga: sg }, visibility: sp ? sg / sp : null };
  }
  const [rels, pages] = await Promise.all([
    releasesByDate(siteId, rangeStr, 300).catch(() => []),
    breakdown(siteId, 'pagePath', startDate, endDate, 500).catch(() => []),
  ]);
  if (!rels.length) return null;
  // GA pageviews per release id (a release URL ends in its numeric id)
  const gaById = new Map();
  for (const p of pages) {
    const rid = /(\d{3,})\/?$/.exec(p.dimensionValue)?.[1];
    if (rid) gaById.set(rid, (gaById.get(rid) || 0) + (Number(p.screenPageViews) || 0));
  }
  const rows = rels
    .filter((r) => (r.viewed || 0) > 0)
    .sort((a, b) => (b.viewed || 0) - (a.viewed || 0))
    .slice(0, 12)
    .map((r) => {
      const ga = gaById.get(String(r.id)) || 0;
      const pv = Number(r.viewed) || 0;
      return { id: r.id, title: r.title, platformViews: pv, gaViews: ga, gaHigher: ga > pv };
    });
  if (!rows.length) return null;
  const matchedRows = rows.filter((r) => r.gaViews > 0);
  const sp = matchedRows.reduce((a, x) => a + x.platformViews, 0);
  const sg = matchedRows.reduce((a, x) => a + x.gaViews, 0);
  // Typical visibility = MEDIAN of per-release GA/platform ratios. Outliers
  // where GA is HIGHER (a release page pulling consumer/repeat traffic the
  // platform doesn't count) are excluded so one viral page can't skew it.
  const ratios = matchedRows.filter((r) => r.platformViews > 0).map((r) => r.gaViews / r.platformViews);
  const clean = ratios.filter((x) => x <= 1.2);
  const med = (arr) => { if (!arr.length) return null; const s = [...arr].sort((a, b) => a - b); return s[Math.floor(s.length / 2)]; };
  return {
    rows, matched: matchedRows.length,
    totals: { platform: sp, ga: sg },
    visibility: med(clean.length >= 3 ? clean : ratios),
    outliers: rows.filter((r) => r.gaHigher).length,
  };
}

// ── Unified release performance — the ONE combined view of both trackers ──
// Platform (server-side, every visitor, lifetime-accurate) UNION GA4 (dated,
// consented visitors only), matched per release by the numeric id in the
// page path. Includes what each side has that the other doesn't: platform
// releases GA4 never saw (zero consented visits), and GA4 release pages
// outside the platform window (older releases still being read).
export async function unifiedReleases(siteId, rangeStr, limit = 25) {
  const site = getSite(siteId);
  const { startDate, endDate } = resolveRange(rangeStr || '28d');
  const hasDb = !!dbKind(site);
  const [rels, pages, dated] = await Promise.all([
    hasDb ? releasesByDate(siteId, rangeStr, 500).catch(() => []) : Promise.resolve([]),
    breakdown(siteId, 'pagePath', startDate, endDate, 1000).catch(() => []),
    hasDb ? releaseViewsDated(siteId, rangeStr, { limit: 200 }).catch(() => null) : Promise.resolve(null),
  ]);
  const datedById = new Map((dated?.rows || []).map((r) => [String(r.id), r.views]));
  // GA views/sessions per release id (release URLs end in the numeric id)
  const gaById = new Map();
  for (const p of pages) {
    const rid = /(\d{3,})\/?$/.exec(p.dimensionValue)?.[1];
    if (!rid) continue;
    const cur = gaById.get(rid) || { gaViews: 0, gaSessions: 0, path: p.dimensionValue };
    cur.gaViews += Number(p.screenPageViews) || 0;
    cur.gaSessions += Number(p.sessions) || 0;
    gaById.set(rid, cur);
  }
  const demoGa = dbIsDemo(site) || isDemo(site);
  const seed = [...String(siteId)].reduce((a, c) => (a * 31 + c.charCodeAt(0)) >>> 0, 7);
  const matched = new Set();
  const rows = rels.map((r, i) => {
    let ga = gaById.get(String(r.id));
    if (!ga && demoGa) ga = { gaViews: Math.floor((Number(r.viewed) || 0) * (0.3 + ((seed >> (i % 16)) % 25) / 100)), gaSessions: 0 };
    if (ga && ga.gaViews > 0) matched.add(String(r.id));
    return {
      id: r.id, title: r.title, published: r.published,
      platformViews: Number(r.viewed) || 0,
      // Period-true platform views from the event log when the DB has one —
      // the lifetime counter stays for context, but THIS is the period number.
      platformViewsInPeriod: datedById.has(String(r.id)) ? datedById.get(String(r.id)) : null,
      platformDownloads: r.downloaded != null ? Number(r.downloaded) || 0 : null,
      gaViews: ga?.gaViews || 0, gaSessions: ga?.gaSessions || 0,
      sources: ga?.gaViews > 0 ? ['platform', 'ga4'] : ['platform'],
    };
  });
  // Ranking metric: platform views are the complete count, GA adds the
  // consumer/consented picture — rank by the LARGER of the two so a release
  // strong in either tracker surfaces.
  rows.sort((a, b) =>
    Math.max(b.platformViewsInPeriod ?? 0, b.gaViews, dated ? 0 : b.platformViews)
    - Math.max(a.platformViewsInPeriod ?? 0, a.gaViews, dated ? 0 : a.platformViews));
  // GA4-only: release pages with traffic that aren't in the platform's
  // published-in-period list (evergreen/older releases still being read).
  const gaOnly = [...gaById.entries()]
    .filter(([rid, g]) => !rels.some((r) => String(r.id) === rid) && g.gaViews > 0)
    .map(([rid, g]) => ({ id: rid, path: g.path, gaViews: g.gaViews, gaSessions: g.gaSessions, sources: ['ga4'] }))
    .sort((a, b) => b.gaViews - a.gaViews)
    .slice(0, 20);
  if (hasDb && gaOnly.length) {
    const titles = await releaseTitles(siteId, gaOnly.map((x) => x.id)).catch(() => ({}));
    for (const x of gaOnly) {
      const t = titles[x.id];
      if (t) x.title = typeof t === 'string' ? t : (t.title || null);
    }
  }
  const platformOnly = rows.filter((r) => !r.gaViews).length;
  return {
    period: { startDate, endDate },
    releases: rows.slice(0, Math.max(1, Math.min(100, limit))),
    gaOnlyReleases: gaOnly,
    counts: {
      platformReleasesInPeriod: rels.length,
      matchedInBothTrackers: matched.size,
      platformOnlyNoGaTraffic: platformOnly,
      gaOnlyOlderReleases: gaOnly.length,
    },
    totals: {
      platformViews: rows.reduce((a, r) => a + r.platformViews, 0),
      gaViews: rows.reduce((a, r) => a + r.gaViews, 0),
      gaViewsOlderReleases: gaOnly.reduce((a, r) => a + r.gaViews, 0),
    },
    platformViewsByCountry: dated?.byCountry || null,
    note: dated
      ? 'platformViewsInPeriod = event-log views WITHIN the period (server-side, every visitor) — quote this as the platform number. platformViews = the lifetime counter, for context only. gaViews = GA4 consented visitors incl. consumer traffic. platformViewsByCountry shows where the period\'s readers are. Never sum platform and GA numbers — they overlap.'
      : 'platformViews = server-side, every visitor, the complete count. gaViews = GA4, consented visitors only, but includes consumer/repeat traffic the platform may not count. The two overlap but neither contains the other — quote BOTH, never sum them as if they were distinct people.',
  };
}

// Match an email campaign to a release page path: by the release's trailing
// numeric id appearing in the campaign id/name/subject, or by slug overlap.
export function matchCampaignToPath(path, campaigns) {
  const last = String(path).split('/').filter(Boolean).pop() || '';
  const num = /^(\d{3,})$/.exec(last)?.[1];
  const slug = last.toLowerCase();
  for (const c of campaigns) {
    const keys = [c.id, c.name, c.subject].filter(Boolean).map((s) => String(s).toLowerCase());
    if (num && keys.some((k) => k === num || k.includes(num))) return c;
    if (!num && slug.length >= 6 && keys.some((k) => k.includes(slug))) return c;
  }
  return null;
}

// ── Release tracker ────────────────────────────────────────────────────
// Finds the site's release section (configurable via "releasePath" in
// sites.config.json, else auto-detected), then ranks each release by its
// first-7-days performance against the site's average release.

const CANDIDATE_PATHS = ['/releases', '/press-releases', '/news', '/media/press-releases', '/articles', '/blog'];
const detectCache = new Map();

async function detectReleasePath(siteId, startDate, endDate) {
  const site = getSite(siteId);
  if (site.releasePath) return site.releasePath;
  const key = siteId;
  if (detectCache.has(key)) return detectCache.get(key);
  let best = null, bestCount = 0;
  const results = await Promise.all(CANDIDATE_PATHS.map(async (cand) => {
    try { return [cand, (await breakdown(siteId, 'pagePath', startDate, endDate, 30, { pathPrefix: cand })).length]; }
    catch { return [cand, 0]; }
  }));
  for (const [cand, count] of results) if (count > bestCount) { best = cand; bestCount = count; }
  detectCache.set(key, best);
  return best;
}

const rtCache = new Map();
const RT_TTL = 10 * 60 * 1000;

export async function releaseTracker(siteId, rangeStr) {
  // The market selection changes every GA query underneath — it MUST be part
  // of the cache key or a filtered view would serve another market's rows.
  const mk = (currentLocales() || currentMarkets() || []).join(',');
  const cacheKey = `${siteId}|${rangeStr || '90d'}|${mk}`;
  const hit = rtCache.get(cacheKey);
  if (hit && Date.now() - hit.t < RT_TTL) return hit.v;
  const v = await releaseTrackerUncached(siteId, rangeStr);
  rtCache.set(cacheKey, { t: Date.now(), v });
  return v;
}

async function releaseTrackerUncached(siteId, rangeStr) {
  // Look across a longer window so the "average release" is meaningful.
  const range = resolveRange(rangeStr || '90d');
  const releasePath = await detectReleasePath(siteId, range.startDate, range.endDate);
  if (!releasePath) return { releasePath: null, releases: [], avg7: 0 };

  const daily = await pagesDailyByPrefix(siteId, releasePath, range.startDate, range.endDate, 20);
  const rangeStart = range.startDate;
  const releases = [];
  for (const [path, rows] of daily.entries()) {
    const active = rows.filter((r) => r.views > 0);
    if (!active.length) continue;
    const launch = active[0].date;
    const total = rows.reduce((a, r) => a + r.views, 0);
    const launchIdx = rows.findIndex((r) => r.date === launch);
    const first7 = rows.slice(launchIdx, launchIdx + 7).reduce((a, r) => a + r.views, 0);
    // If a page already had traffic on day one of the window it probably
    // launched earlier — mark it so the UI can label it.
    const preExisting = launch === rangeStart;
    releases.push({ path, launch, first7, total, preExisting });
  }
  // Merge rows that are the same release under different URL forms
  // (e.g. /releases/121270 and /releases/public-view/single-release/121270).
  const byId = new Map();
  const merged = [];
  for (const r of releases) {
    const rid = /(\d{3,})\/?$/.exec(r.path)?.[1];
    if (!rid) { merged.push(r); continue; }
    const prev = byId.get(rid);
    if (!prev) { byId.set(rid, r); merged.push(r); continue; }
    // keep the higher-traffic path as canonical, sum the numbers
    const keep = prev.first7 >= r.first7 ? prev : r;
    const drop = keep === prev ? r : prev;
    keep.first7 += drop.first7; keep.total += drop.total;
    if (drop.launch < keep.launch) keep.launch = drop.launch;
    keep.preExisting = keep.preExisting || drop.preExisting;
    if (keep !== prev) { byId.set(rid, keep); merged[merged.indexOf(prev)] = keep; }
    else { const i = merged.indexOf(drop); if (i > -1) merged.splice(i, 1); }
  }
  releases.length = 0; releases.push(...merged);
  releases.sort((a, b) => b.first7 - a.first7);

  const fresh = releases.filter((r) => !r.preExisting);
  const basis = fresh.length >= 3 ? fresh : releases;
  const avg7 = basis.length ? basis.reduce((a, r) => a + r.first7, 0) / basis.length : 0;
  releases.forEach((r) => (r.index = avg7 ? r.first7 / avg7 : null));
  releases.sort((a, b) => b.first7 - a.first7);

  // Join with email campaigns: id/slug match first, then unique send-date
  // proximity (campaign sent within ~1 day of the release launching).
  try {
    // Exact join via the reports DB (email_releases) — no heuristics needed.
    const exact = await emailReleaseMap(
      siteId,
      releases.map((r) => Number(/(\d{3,})\/?$/.exec(r.path)?.[1])).filter(Boolean)
    ).catch(() => ({}));
    for (const r of releases) {
      const rid = /(\d{3,})\/?$/.exec(r.path)?.[1];
      if (rid && exact[rid]) r.email = exact[rid];
    }
    const em = await emailOverview(siteId, `${range.startDate}..${range.endDate}`);
    if (em.enabled && em.campaigns?.length) {
      for (const r of releases) {
        if (r.email) continue;
        let c = matchCampaignToPath(r.path, em.campaigns);
        if (!c && !r.preExisting) {
          const cand = em.campaigns.filter(
            (x) => x.date && Math.abs(new Date(x.date) - new Date(r.launch)) <= 1.5 * 86400000
          );
          if (cand.length === 1) c = cand[0];
        }
        if (c) {
          r.email = {
            id: c.id, provider: c.provider, name: c.subject || c.name,
            date: c.date, sent: c.sent, opens: c.opens, openRate: c.openRate,
          };
        }
      }
      // Demo mode: guarantee a couple of visible matches so the feature shows.
      if (isDemo(getSite(siteId)) && !releases.some((x) => x.email)) {
        releases
          .filter((x) => !x.preExisting)
          .slice(0, Math.min(2, em.campaigns.length))
          .forEach((x, i) => {
            const c = em.campaigns[i];
            x.email = {
              id: c.id, provider: c.provider, name: c.subject || c.name,
              date: x.launch, sent: c.sent, opens: c.opens, openRate: c.openRate,
            };
          });
      }
    }
  } catch { /* email layer optional */ }

  // Enrich with real titles (+ company) from the reports DB when connected.
  // titleSource tells the UI (admins) exactly why titles are or aren't shown:
  //   'db' = titles joined; 'no-db' = no reports DB configured for this site;
  //   'db-empty' = DB connected but these ids aren't in its releases table;
  //   'error: …' = the lookup query failed (schema/permissions).
  let titleSource = 'no-db';
  try {
    const site = getSite(siteId);
    const { dbKind } = await import('./db.js');
    if (dbKind(site)) {
      const ids = releases.map((r) => /(\d{3,})\/?$/.exec(r.path)?.[1]).filter(Boolean);
      const titleMap = await releaseTitles(siteId, ids, { throwOnError: true });
      let hits = 0;
      for (const r of releases) {
        const rid = /(\d{3,})\/?$/.exec(r.path)?.[1];
        if (rid && titleMap[rid]) { r.title = titleMap[rid].title; r.companyName = titleMap[rid].companyName; hits++; }
      }
      titleSource = hits ? 'db' : ids.length ? 'db-empty' : 'no-ids';
    }
  } catch (err) {
    titleSource = 'error: ' + String(err.message || err).slice(0, 180);
  }

  return { releasePath, range, releases, avg7: Math.round(avg7), titleSource };
}

// ── Earned-media radar ─────────────────────────────────────────────────
// Referral sources active in the current period that sent nothing in the
// 90 days before it = likely new coverage.

const RADAR_POOL = [
  'motoring-weekly.com', 'autoexpress-news.net', 'fleetworld.press', 'thedrive-digest.com',
  'evnews.io', 'industry-insider.co.uk', 'regional-gazette.co.uk', 'tradepress-daily.com',
];

export async function mediaRadar(siteId, rangeStr) {
  const range = resolveRange(rangeStr);
  const site = getSite(siteId);

  if (isDemo(site)) {
    // Deterministic demo: 0–3 "new" domains keyed on site + period.
    const seed = [...(siteId + range.startDate)].reduce((a, c) => (a * 31 + c.charCodeAt(0)) >>> 0, 7);
    const n = seed % 4;
    const rows = Array.from({ length: n }, (_, i) => ({
      dimensionValue: RADAR_POOL[(seed + i * 3) % RADAR_POOL.length],
      sessions: 40 + ((seed >> (i + 2)) % 260),
    })).sort((a, b) => b.sessions - a.sessions);
    return { range, newReferrers: rows, baselineDays: 90 };
  }

  // Baseline: the 90 days before the current period.
  const startD = new Date(range.startDate + 'T00:00:00Z');
  const baseEnd = new Date(startD); baseEnd.setUTCDate(baseEnd.getUTCDate() - 1);
  const baseStart = new Date(baseEnd); baseStart.setUTCDate(baseStart.getUTCDate() - 89);
  const fmt = (d) => d.toISOString().slice(0, 10);

  const spec = { dimension: 'sessionSource', metric: 'sessions', filterField: 'sessionDefaultChannelGroup', filterValue: 'Referral' };
  const [current, baseline] = await Promise.all([
    filteredBreakdown(siteId, spec, range.startDate, range.endDate, 100),
    filteredBreakdown(siteId, spec, fmt(baseStart), fmt(baseEnd), 250),
  ]);
  const known = new Set(baseline.map((r) => r.dimensionValue));
  const newReferrers = current
    .filter((r) => !known.has(r.dimensionValue) && (r.sessions || 0) >= 5)
    .slice(0, 12);
  return { range, newReferrers, baselineDays: 90 };
}
