/**
 * Que hace que un articulo de dev.to funcione, medido sobre el corpus.
 *
 * Ayer cambie las etiquetas y anadi portada a tres articulos y las lecturas
 * pasaron de 2 a 45. Eso es una anecdota con n=3. Esto lo comprueba sobre miles
 * de articulos ajenos, que es la unica forma de saber si la portada importa de
 * verdad o si simplemente ese dia paso algo.
 *
 * Cuidado con la trampa obvia: la mayoria de los articulos tienen 0 reacciones,
 * asi que la media esta dominada por los raros. Se reportan mediana, percentil
 * 90 y la proporcion que supera umbrales, que es lo que de verdad describe una
 * distribucion con cola larga.
 *
 *   node tools/devto-analyse.js data/devto-corpus.csv [--md]
 */
const fs = require('fs');

function parseCsv(text) {
  const rows = []; let row = [], cell = '', q = false;
  for (let i = 0; i < text.length; i++) {
    const c = text[i];
    if (q) {
      if (c === '"' && text[i + 1] === '"') { cell += '"'; i++; }
      else if (c === '"') q = false; else cell += c;
    } else if (c === '"') q = true;
    else if (c === ',') { row.push(cell); cell = ''; }
    else if (c === '\n') { row.push(cell); rows.push(row); row = []; cell = ''; }
    else if (c !== '\r') cell += c;
  }
  if (cell || row.length) { row.push(cell); rows.push(row); }
  return rows;
}

const rows = parseCsv(fs.readFileSync(process.argv[2], 'utf8'));
const head = rows[0];
const ix = Object.fromEntries(head.map((h, i) => [h, i]));
const md = process.argv.includes('--md');

// Un mismo articulo aparece bajo varias etiquetas: se deduplica por id.
const byId = new Map();
for (const r of rows.slice(1)) {
  if (!r[ix.id]) continue;
  if (byId.has(r[ix.id])) continue;
  byId.set(r[ix.id], {
    id: r[ix.id],
    when: new Date(r[ix.published_at]),
    title: r[ix.title],
    titleLen: +r[ix.title_len] || 0,
    cover: r[ix.has_cover] === '1',
    minutes: +r[ix.reading_minutes] || 0,
    tags: (r[ix.tags] || '').split(' ').filter(Boolean),
    reactions: +r[ix.reactions] || 0,
    comments: +r[ix.comments] || 0,
    series: !!r[ix.series],
    author: r[ix.author],
  });
}
const ALL = [...byId.values()];

// Un articulo de hace dos horas ha tenido dos horas para acumular reacciones.
// Comparar por dia de la semana u hora sin filtrar mide sobre todo lo reciente
// que es cada grupo, no lo bueno. Fuera todo lo publicado en las ultimas 72 h.
const NOW = Date.now();
const MIN_AGE_H = 72;
const A = ALL.filter((x) => (NOW - x.when.getTime()) / 36e5 >= MIN_AGE_H);

const pct = (arr, p) => {
  if (!arr.length) return 0;
  const s = [...arr].sort((x, y) => x - y);
  return s[Math.min(s.length - 1, Math.floor(s.length * p))];
};
const share = (a, f) => (a.length ? a.filter(f).length / a.length : 0);
const f1 = (n) => n.toFixed(1);
const pc = (n) => (n * 100).toFixed(1) + '%';

function describe({ name, items }) {
  const rx = items.map((x) => x.reactions);
  return {
    name, n: items.length,
    median: pct(rx, 0.5), p90: pct(rx, 0.9), p99: pct(rx, 0.99),
    zero: share(items, (x) => x.reactions === 0),
    over10: share(items, (x) => x.reactions >= 10),
    over50: share(items, (x) => x.reactions >= 50),
  };
}

function table(title, groups) {
  const rowsOut = groups.map(describe);
  if (md) {
    console.log(`\n### ${title}\n`);
    console.log('| Group | Articles | Median | p90 | p99 | Zero reactions | 10+ | 50+ |');
    console.log('|---|---:|---:|---:|---:|---:|---:|---:|');
    rowsOut.forEach((r) => console.log(
      `| ${r.name} | ${r.n.toLocaleString('en-GB')} | ${r.median} | ${r.p90} | ${r.p99} | ` +
      `${pc(r.zero)} | ${pc(r.over10)} | ${pc(r.over50)} |`));
  } else {
    console.log(`\n=== ${title} ===`);
    rowsOut.forEach((r) => console.log(
      `${r.name.padEnd(26)} n=${String(r.n).padStart(6)}  mediana ${String(r.median).padStart(3)}` +
      `  p90 ${String(r.p90).padStart(4)}  p99 ${String(r.p99).padStart(5)}` +
      `  cero ${pc(r.zero).padStart(6)}  10+ ${pc(r.over10).padStart(6)}  50+ ${pc(r.over50).padStart(6)}`));
  }
}

// Grupo = {name, items}. Colgar el nombre del propio array chocaba con
// Array.prototype.name, que ya existe y es de solo lectura.
const named = (name, items) => ({ name, items });

const ages = A.map((x) => (NOW - x.when.getTime()) / 864e5).sort((a, b) => a - b);
console.log(`recogidos: ${ALL.length.toLocaleString('en-GB')} · ` +
  `analizados (>= ${MIN_AGE_H} h): ${A.length.toLocaleString('en-GB')} · ` +
  `autores: ${new Set(A.map((x) => x.author)).size.toLocaleString('en-GB')} · ` +
  `reacciones totales: ${A.reduce((s, x) => s + x.reactions, 0).toLocaleString('en-GB')}`);
console.log(`antiguedad: mediana ${ages[Math.floor(ages.length / 2)].toFixed(1)} dias, ` +
  `maxima ${ages[ages.length - 1].toFixed(0)} dias`);

table('Cover image', [
  named('with cover', A.filter((x) => x.cover)),
  named('without cover', A.filter((x) => !x.cover)),
]);

table('Title length', [
  named('under 40 chars', A.filter((x) => x.titleLen < 40)),
  named('40-59', A.filter((x) => x.titleLen >= 40 && x.titleLen < 60)),
  named('60-79', A.filter((x) => x.titleLen >= 60 && x.titleLen < 80)),
  named('80 and over', A.filter((x) => x.titleLen >= 80)),
]);

table('Reading time', [
  named('1-2 min', A.filter((x) => x.minutes <= 2)),
  named('3-5 min', A.filter((x) => x.minutes >= 3 && x.minutes <= 5)),
  named('6-10 min', A.filter((x) => x.minutes >= 6 && x.minutes <= 10)),
  named('over 10 min', A.filter((x) => x.minutes > 10)),
]);

table('Number of tags', [
  named('1-2 tags', A.filter((x) => x.tags.length <= 2)),
  named('3 tags', A.filter((x) => x.tags.length === 3)),
  named('4 tags', A.filter((x) => x.tags.length === 4)),
]);

// Serie: el listado de la API no devuelve ese campo, asi que sale vacio para
// todos. No se reporta: un cero por no haberlo medido no es un hallazgo.

const dow = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
table('Day of week (UTC)', dow.map((d, i) => named(d, A.filter((x) => x.when.getUTCDay() === i))));

const hourBand = (h) => (h < 6 ? '00-05 UTC' : h < 12 ? '06-11 UTC' : h < 18 ? '12-17 UTC' : '18-23 UTC');
table('Hour published (UTC)', ['00-05 UTC', '06-11 UTC', '12-17 UTC', '18-23 UTC']
  .map((b) => named(b, A.filter((x) => hourBand(x.when.getUTCHours()) === b))));

// Etiquetas: solo las que aparecen lo bastante como para que la cifra signifique algo.
const tagCount = new Map();
for (const a of A) for (const t of a.tags) tagCount.set(t, (tagCount.get(t) || 0) + 1);
const bigTags = [...tagCount.entries()].filter(([, n]) => n >= 200)
  .sort((a, b) => b[1] - a[1]).slice(0, 20).map(([t]) => t);
table('Tags with 200+ articles in the corpus',
  bigTags.map((t) => named('#' + t, A.filter((x) => x.tags.includes(t)))));
