Appends one row of 20 account-level metrics per day to a Report tab and e-mails a yesterday / two days ago / a week ago comparison.

// account-summary — EN build 2026-09-14
// Copyright 2026 Gökhan Güzel — gokhanguzel.com. All Rights Reserved. Generated file: edit src/ and i18n/, then run build.js.
// Do not share configured Slack webhook URLs.
'use strict';
const SCRIPT_ID = "account-summary";
const L = {"lang":"en","locale":"en-US","text_lang":"en","dec":".","thou":","};
const ATTRIBUTION = String.fromCharCode(169,32,103,111,107,104,97,110,103,117,122,101,108,46,99,111,109);
const T = {"kpi":"KPI","kpi.now":"This period","kpi.prev":"Previous","yes":"yes","no":"no","mail.full_report":"Full report","mail.open_report":"Open report","note.no_rows":"No rows.","note.truncated":"Showing {shown} of {total} rows (max_rows_written).","note.no_campaigns":"No campaigns with impressions match the current filters.","settings.key":"Key","settings.value":"Value","settings.notes":"Notes","settings.script":"script that owns this Sheet (do not edit)","settings.account":"Google Ads account that owns this Sheet (do not edit)","note.truncated_queries":"⚠ {n} quer(y/ies) hit the read limit — results incomplete; narrow the window","note.account_neg_failed":"⚠ account-level negatives could not be read:","note.skipped_time":"skipped — execution time budget","note.data_error":"data error:","note.failed":"FAILED —","note.no_final_url":"(no final URL)","alias.settings":["Settings","Ayarlar","Einstellungen"],"alias.history":["History","Kronoloji","Verlauf","Runs","Calismalar","Laeufe"],"alias.snapshot":["Snapshot","Snapshots","Kesit","Kesitler","Momentaufnahme","Momentaufnahmen"],"alias.state":["State","DurumBellegi","Zustand"],"alias.baseline":["Baseline","TemelCizgi","Basislinie"],"m.impressions":"Impr.","m.clicks":"Clicks","m.ctr":"CTR","m.cost":"Cost","m.cpc":"Avg. CPC","m.conversions":"Conv.","m.value":"Conv. value","m.cr":"Conv. rate","m.cpa":"CPA","m.roas":"ROAS","m.aov":"AOV","m.vpc":"Value / click","col.account":"Account","col.period":"Period","col.currency":"Currency","col.date":"Date","col.campaign":"Campaign","col.ad_group":"Ad group","col.n":"N","col.phrase":"Phrase","col.queries":"Queries","col.source":"Source","col.why":"Why","title":"Account Summary","tab.report":"Report","alias.report":["Rapor","Bericht"],"settings.email":"comma-separated recipients; blank = no e-mail","settings.last_check":"last day written (yyyy-MM-dd); set to an earlier date to backfill (max 90 days)","mail.subject":"Google Ads {cid} — account summary {date}","mail.yesterday":"Yesterday","mail.two_days_ago":"Two days ago","mail.week_ago":"A week ago","f.cost_micros":"Cost","f.average_cpc":"Avg. CPC","f.ctr":"CTR","f.search_impression_share":"Search impr. share","f.impressions":"Impressions","f.clicks":"Clicks","f.conversions":"Conversions","f.conversions_value":"Conv. value","f.cost_per_conversion":"Cost / conv.","f.conversions_from_interactions_rate":"Conv. rate","f.all_conversions":"All conv.","f.view_through_conversions":"View-through conv.","f.search_budget_lost_impression_share":"Lost IS (budget)","f.search_rank_lost_impression_share":"Lost IS (rank)","f.search_top_impression_share":"Top IS","f.search_absolute_top_impression_share":"Abs. top IS","f.average_cpm":"Avg. CPM","f.interactions":"Interactions","f.interaction_rate":"Interaction rate","f.invalid_clicks":"Invalid clicks"};

/* ===================== common/core ===================== */
/* Pure helpers. No Sheets, no network. Everything here is unit-tested in test/run.js. */

/* AdsApp.search is report-backed and is not subject to selector entity limits. Keep a defensive count check only for
   runtimes/iterators that expose totalNumEntities(); normally this stays at zero. */
let TRUNCATED_ = 0;
function query_(gaql) {
  const out = [];
  const it = AdsApp.search(gaql);
  while (it.hasNext()) out.push(it.next());
  try { if (typeof it.totalNumEntities === 'function') { const n = it.totalNumEntities(); if (n > out.length) { TRUNCATED_++; Logger.log(`Query truncated: ${out.length} of ${n} rows read — narrow the window or filters. ${gaql.slice(0, 120)}`); } } } catch (e) {}
  return out;
}
/* Seconds left in the 30-minute execution window (Infinity when the runtime does not expose it, e.g. in the mock). */
function timeLeft_() { try { return AdsApp.getExecutionInfo().getRemainingTime(); } catch (e) { return Infinity; } }
function num_(v) { const x = parseFloat(v); return isFinite(x) ? x : 0; }
function nz_(v) { return isFinite(v) ? v : ''; }
function esc_(s) { return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'"); }
function escLike_(s) { const m = { '[': '[[]', ']': '[]]', '%': '[%]', '_': '[_]' }; return esc_(s).replace(/[\[\]%_]/g, c => m[c]); }
function chunks_(arr, n) { const o = []; for (let i = 0; i < arr.length; i += n) o.push(arr.slice(i, i + n)); return o; }
function pct_(a, b) { return (typeof a === 'number' && typeof b === 'number' && isFinite(a) && isFinite(b) && b) ? a / b - 1 : NaN; }

/* Number formatting for e-mail / Slack. Deterministic: separators come from the build (L.dec / L.thou), not from
   the runtime's ICU data. */
function fmtNum_(v, digits) {
  if (typeof v !== 'number' || !isFinite(v)) return '–';
  const d = digits === undefined ? (Math.abs(v) >= 1000 ? 0 : Math.abs(v) < 1 ? 3 : 2) : digits;
  const parts = Math.abs(v).toFixed(d).split('.');
  return (v < 0 ? '-' : '') + parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, L.thou) + (parts[1] ? L.dec + parts[1] : '');
}
function fmtPct_(v, digits) { return isFinite(v) ? fmtNum_(v * 100, digits === undefined ? 2 : digits) + '%' : '–'; }

/* Metric bag: raw sums + derived rates. NaN means "undefined", never 0. */
function zeroM_() { return { impressions: 0, clicks: 0, cost: 0, conversions: 0, value: 0 }; }
function rawOf_(r, opts) {
  const m = (r && r.metrics) || {}; opts = opts || {};
  return {
    impressions: num_(m.impressions), clicks: num_(m.clicks),
    cost: opts.noCost ? 0 : num_(m.costMicros) / 1e6,
    conversions: opts.noConv ? NaN : num_(m.conversions),
    value: opts.noConv ? NaN : num_(m.conversionsValue)
  };
}
function addM_(a, b) {
  const sumKnown = (x, y) => isFinite(x) && isFinite(y) ? x + y : NaN;
  return {
    impressions: a.impressions + b.impressions, clicks: a.clicks + b.clicks, cost: a.cost + b.cost,
    conversions: sumKnown(a.conversions, b.conversions),
    value: sumKnown(a.value, b.value)
  };
}
function derive_(o) {
  o.ctr = o.impressions ? o.clicks / o.impressions : NaN;
  o.cpc = o.clicks ? o.cost / o.clicks : NaN;
  o.cr = o.clicks && isFinite(o.conversions) ? o.conversions / o.clicks : NaN;
  o.cpa = o.conversions > 0 ? o.cost / o.conversions : NaN;
  o.roas = o.cost > 0 && isFinite(o.value) ? o.value / o.cost : NaN;
  o.aov = o.conversions > 0 ? o.value / o.conversions : NaN;
  o.vpc = o.clicks ? o.value / o.clicks : NaN;
  return o;
}

/* Text normalisation for search terms / negatives. language: 'tr' handles İ/ı. */
function normalize_(s, language) {
  s = String(s || '');
  s = language === 'tr' ? s.replace(/I/g, 'ı').replace(/İ/g, 'i').toLowerCase() : s.toLowerCase();
  return s.replace(/[+\[\]"“”'’`^*]/g, ' ')
          .replace(/[^\p{L}\p{N}\s\-\.]/gu, ' ')
          .split(/\s+/).map(w => w.replace(/^[.\-]+|[.\-]+$/g, '')).filter(Boolean).join(' ');
}
function tokens_(s, language) { const n = normalize_(s, language); return n ? n.split(' ') : []; }
function hasSubseq_(hay, needle) {
  outer: for (let i = 0; i + needle.length <= hay.length; i++) {
    for (let j = 0; j < needle.length; j++) if (hay[i + j] !== needle[j]) continue outer;
    return true;
  }
  return false;
}
/* exact = same tokens; phrase = contiguous; broad = every word present. */
function blocked_(q, negs) {
  if (!negs) return false;
  for (const n of negs) {
    if (!n.words.length) continue;
    if (n.mt === 'EXACT') { if (n.words.length === q.length && n.words.every((w, i) => w === q[i])) return true; }
    else if (n.mt === 'PHRASE') { if (hasSubseq_(q, n.words)) return true; }
    else if (n.words.every(w => q.includes(w))) return true;
  }
  return false;
}

/* Robust stats: median / MAD (σ-scaled), SD fallback. */
function median_(sorted) { const n = sorted.length; if (!n) return NaN; return n % 2 ? sorted[(n - 1) / 2] : (sorted[n / 2 - 1] + sorted[n / 2]) / 2; }
function robust_(arr) {
  if (!arr.length) return { median: NaN, mad: NaN, scale: 0 };
  const s = arr.slice().sort((a, b) => a - b), med = median_(s);
  const mad = median_(s.map(v => Math.abs(v - med)).sort((a, b) => a - b)) * 1.4826;
  let scale = mad;
  if (!(scale > 0)) { const mean = arr.reduce((x, v) => x + v, 0) / arr.length; scale = Math.sqrt(arr.reduce((x, v) => x + (v - mean) * (v - mean), 0) / Math.max(1, arr.length - 1)); }
  return { median: med, mad: mad, scale: scale };
}

/* Minimal HTML escape for anything user-generated that lands in e-mail. */
function h_(s) { return String(s === undefined || s === null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
/* Text lookup. T is injected by the build; keys are stable, values are per language. */
function t_(key, vars) {
  let s = T[key]; if (s === undefined) s = key;
  return String(s).replace(/\{(\w+)\}/g, (m, k) => (vars && vars[k] !== undefined) ? vars[k] : m);
}

/* ===================== common/dates ===================== */
/* Calendar-based date arithmetic in the account time zone. No 864e5 shifts,
   so DST transitions cannot skip or repeat a day. */
function tz_() { return AdsApp.currentAccount().getTimeZone(); }
function todayStr_(tz, base) { return Utilities.formatDate(base || new Date(), tz, 'yyyy-MM-dd'); }
function shiftDay_(dateStr, days) {
  const p = dateStr.split('-').map(Number);
  const d = new Date(Date.UTC(p[0], p[1] - 1, p[2] + days));
  return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + String(d.getUTCDate()).padStart(2, '0');
}
/* window_(days, offset): `days` complete days, ending `offset+1` days before today. offset 0 → ends yesterday. */
function window_(days, offset, tz, base) {
  const today = todayStr_(tz, base);
  const end = shiftDay_(today, -(offset + 1)), start = shiftDay_(today, -(offset + days));
  return { start: start, end: end, label: start + ' → ' + end };
}
const DOW_ = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY'];
function dowOf_(dateStr) { const p = dateStr.split('-').map(Number); return DOW_[new Date(Date.UTC(p[0], p[1] - 1, p[2])).getUTCDay()]; }
/* Sheets may hand back a Date where we wrote a 'yyyy-MM-dd' string. Always normalise on read. */
function dateStrOf_(v, tz) {
  if (v instanceof Date) return Utilities.formatDate(v, tz, 'yyyy-MM-dd');
  return String(v === null || v === undefined ? '' : v).trim();   // strings are never truncated: keys like 'yyyy-MM-dd HH:mm' or '123-456-7890' stay intact
}

/* ===================== common/manager ===================== */
/* Runs as a single account, or selects one client account when started from a manager account. */
function isManager_() { try { if (typeof AdsManagerApp === 'undefined') return false; AdsManagerApp.accounts().withLimit(1).get(); return true; } catch (e) { return false; } }
function selectAccount_(accountId) {
  if (!isManager_()) return;
  if (!accountId) throw new Error('Running from a manager account: set CONFIG.account_id (e.g. 123-456-7890).');
  const it = AdsManagerApp.accounts().withIds([String(accountId)]).get();
  if (!it.hasNext()) throw new Error('Account ' + accountId + ' is not a client of this manager account.');
  AdsManagerApp.select(it.next());
}

/* ===================== common/sheets ===================== */
/* Technical tabs are language-independent. User-facing tab names come from T. */
const TECH = { settings: '_settings', state: '_state', snapshot: '_snapshot', history: '_history', baseline: '_baseline' };

function openSpreadsheet_(url) {
  if (!url || url === 'YOUR_SPREADSHEET_URL') throw new Error('Set CONFIG.spreadsheet_url to a Google Sheet you own.');
  const ss = SpreadsheetApp.openByUrl(url);
  claimSheet_(ss);              // ownership first: nothing is written to a Sheet that belongs to another script or account
  return seal_(ss);             // every script opens its Sheet through the attribution layer
}
/* One Sheet per script. The first run records SCRIPT_ID in _settings; a different script pointed at the same Sheet stops
   before it can overwrite tabs (ngram-lens and pmax-ngram-lens share tab names). */
function claimSheet_(ss) {
  const sh = tab_(ss, TECH.settings, T['alias.settings']);
  const cid = AdsApp.currentAccount().getCustomerId();
  const kv = {}; if (sh.getLastRow() > 1) sh.getRange(2, 1, sh.getLastRow() - 1, 2).getValues().forEach(r => kv[String(r[0])] = String(r[1]));
  if (kv.script && kv.script !== SCRIPT_ID) throw new Error(`This Sheet belongs to ${kv.script}. Use a separate Sheet for ${SCRIPT_ID}.`);
  if (kv.account && kv.account !== cid) throw new Error(`This Sheet belongs to account ${kv.account}; running as ${cid}. Use a separate Sheet per account.`);
  if (!kv.script || !kv.account) ensureSettings_(ss, []);
}
/* Title in A1, header in row `top` (2). Writes only what is missing, so the attribution cell in H1 never masks an empty
   header row. Returns true when the header was (re)written. */
function ensureTitledHeader_(sheet, title, header) {
  ensureSize_(sheet, 3, header.length);
  if (String(sheet.getRange(1, 1).getValue()) === '') sheet.getRange(1, 1).setValue(title).setFontWeight('bold').setFontSize(13);
  const a2 = sheet.getRange(2, 1).getValue();
  if (String(a2) === header[0]) return false;
  if (String(a2) !== '') sheet.insertRowsBefore(2, 1);   // data written under a missing header (v2.2 layout): push it down, then add the header
  styleHeader_(sheet.getRange(2, 1, 1, header.length).setValues([header])); sheet.setFrozenRows(2);
  return true;
}
/* Find a tab by its current name or any legacy alias; rename legacy tabs once so state survives a language switch. */
function tab_(ss, name, aliases) {
  let sh = ss.getSheetByName(name);
  if (!sh && aliases) for (const a of aliases) { const old = ss.getSheetByName(a); if (old) { old.setName(name); sh = old; break; } }
  if (!sh) sh = ss.insertSheet(name);
  return sh;
}
/* specs: [{name, aliases:[...]}]. Removes the empty default tab whatever its locale name is. */
function ensureTabs_(ss, specs) {
  const wanted = new Set(specs.map(s => s.name));
  specs.forEach(s => tab_(ss, s.name, s.aliases));
  const first = ss.getSheets()[0];
  if (first && !wanted.has(first.getName()) && ss.getSheets().length > 1 && onlyAttribution_(first)) ss.deleteSheet(first);
}
/* A default tab that holds nothing but the attribution cell written by the early seal_ is still "empty". */
function onlyAttribution_(sheet) {
  if (sheet.getLastRow() === 0) return true;
  if (sheet.getLastRow() > 1) return false;
  const row = sheet.getRange(1, 1, 1, Math.max(1, sheet.getLastColumn())).getValues()[0];
  return row.every((v, i) => v === '' || (i === 7 && v === ATTRIBUTION));
}
/* Grow the grid BEFORE any getRange: Sheets throws on coordinates outside the current dimensions. */
function ensureSize_(sheet, rows, cols) {
  if (cols > sheet.getMaxColumns()) sheet.insertColumnsAfter(sheet.getMaxColumns(), cols - sheet.getMaxColumns());
  if (rows > sheet.getMaxRows()) sheet.insertRowsAfter(sheet.getMaxRows(), rows - sheet.getMaxRows());
}
function styleHeader_(rng) { return rng.setFontWeight('bold').setBackground('#1E293B').setFontColor('#ffffff'); }

/* Write a table. Rows above `top` are never touched (callers put titles there). formats[i] is a number format per column ('@' = text, applied BEFORE setValues so Sheets never auto-parses).
   Rows beyond `cap` are dropped and a note is written. Returns the number of rows written. */
function table_(sheet, top, header, rows, formats, cap) {
  let out = rows;
  if (cap && rows.length > cap) out = rows.slice(0, cap);
  ensureSize_(sheet, top + out.length + 1, header.length);
  const maxR = sheet.getMaxRows(), maxC = sheet.getMaxColumns();
  if (maxR >= top) sheet.getRange(top, 1, maxR - top + 1, maxC).clearContent();   // contents only: user notes/formats outside the table survive
  styleHeader_(sheet.getRange(top, 1, 1, header.length).setValues([header]));
  if (out.length) {
    (formats || []).forEach((f, i) => { if (f) sheet.getRange(top + 1, i + 1, out.length, 1).setNumberFormat(f); });
    sheet.getRange(top + 1, 1, out.length, header.length).setValues(out);
    if (out.length < rows.length) sheet.getRange(top + 1 + out.length, 1).setValue(t_('note.truncated', { shown: out.length, total: rows.length }));
  } else {
    sheet.getRange(top + 1, 1).setValue(t_('note.no_rows'));
  }
  sheet.setFrozenRows(top);
  return out.length;
}
/* Append rows with text formats applied first (appendRow cannot do that). */
function appendRows_(sheet, rows, formats) {
  if (!rows.length) return;
  const r0 = sheet.getLastRow() + 1, n = rows.length, w = rows[0].length;
  ensureSize_(sheet, r0 + n - 1, w);
  (formats || []).forEach((f, i) => { if (f) sheet.getRange(r0, i + 1, n, 1).setNumberFormat(f); });
  sheet.getRange(r0, 1, n, w).setValues(rows);
}
/* A technical tab whose header row differs from what this version writes (older layout) is renamed to an archive tab and a
   fresh one is created — rows of two schemas are never mixed. Returns the sheet to write to. */
function ensureSchema_(ss, name, header) {
  const sh = ss.getSheetByName(name); if (!sh || sh.getLastRow() === 0) return sh;
  const cur = sh.getRange(1, 1, 1, Math.max(1, sh.getLastColumn())).getValues()[0].map(String);
  while (cur.length && cur[cur.length - 1] === '') cur.pop();
  if (cur.join('|') === header.join('|')) return sh;
  let n = 1, archive = name + '_archive'; while (ss.getSheetByName(archive)) archive = name + '_archive' + (++n);
  sh.setName(archive); Logger.log(`${name}: old layout archived as ${archive}`);
  return ss.insertSheet(name);
}
/* Idempotent history: one row per key (first `keyCols` columns, joined). Existing row with the same key is replaced. */
function upsertRow_(sheet, header, row, keyCols, formats, tz) {
  ensureSize_(sheet, 2, Math.max(header.length, row.length));
  const cur = sheet.getLastRow() === 0 ? [] : sheet.getRange(1, 1, 1, Math.max(1, sheet.getLastColumn())).getValues()[0].map(String).filter(Boolean);
  if (cur.join('|') !== header.join('|')) {
    const w = Math.max(header.length, sheet.getLastColumn()); sheet.getRange(1, 1, 1, w).clearContent();
    styleHeader_(sheet.getRange(1, 1, 1, header.length).setValues([header])); sheet.setFrozenRows(1);
  }   // new/changed schema: clear stale cells, then rewrite header
  const key = row.slice(0, keyCols).map(v => dateStrOf_(v, tz)).join('|');
  const last = sheet.getLastRow();
  if (last > 1) {
    const keys = sheet.getRange(2, 1, last - 1, keyCols).getValues().map(r => r.map(v => dateStrOf_(v, tz)).join('|'));
    const i = keys.indexOf(key);
    if (i >= 0) {
      const w = Math.max(row.length, sheet.getLastColumn()); sheet.getRange(i + 2, 1, 1, w).clearContent();
      sheet.getRange(i + 2, 1, 1, row.length).setValues([row]); return;
    }
  }
  appendRows_(sheet, [row], formats);
}
/* Replace a whole table safely: write the new rows first, then clear whatever is left below. */
function replaceRows_(sheet, header, rows, formats) {
  const n = rows.length;
  ensureSize_(sheet, n + 1, header.length);
  if (sheet.getLastRow() === 0) styleHeader_(sheet.getRange(1, 1, 1, header.length).setValues([header]));
  if (n) {
    (formats || []).forEach((f, i) => { if (f) sheet.getRange(2, i + 1, n, 1).setNumberFormat(f); });
    sheet.getRange(2, 1, n, header.length).setValues(rows);
  }
  const last = sheet.getLastRow();
  if (last > n + 1) sheet.getRange(n + 2, 1, last - n - 1, Math.max(header.length, sheet.getLastColumn())).clearContent();
}

/* _settings: Key | Value | Notes. Keys are stable identifiers (never translated); notes are per language. */
function ensureSettings_(ss, defaults) {
  const sh = tab_(ss, TECH.settings, T['alias.settings']);
  ensureSize_(sh, 2 + defaults.length, 3);
  if (sh.getLastRow() === 0) {
    sh.getRange(1, 1, 1, 3).setNumberFormat('@').setValues([[t_('settings.key'), t_('settings.value'), t_('settings.notes')]]);
    styleHeader_(sh.getRange(1, 1, 1, 3)); sh.setFrozenRows(1); sh.setColumnWidths(1, 1, 200); sh.setColumnWidths(3, 1, 420);
  }
  const all = [['script', SCRIPT_ID, t_('settings.script')], ['account', AdsApp.currentAccount().getCustomerId(), t_('settings.account')]].concat(defaults);
  const last = sh.getLastRow();
  const keys = last > 1 ? sh.getRange(2, 1, last - 1, 1).getValues().map(r => String(r[0])) : [];
  const missing = all.filter(d => keys.indexOf(d[0]) < 0);
  if (missing.length) { const r0 = sh.getLastRow() + 1; ensureSize_(sh, r0 + missing.length, 3); sh.getRange(r0, 1, missing.length, 3).setNumberFormat('@').setValues(missing.map(d => [d[0], d[1], d[2]])); }
  if (keys.length) {   // notes follow the current language; one batched write, only when something differs; values are never touched
    const rng = sh.getRange(2, 3, keys.length, 1), cur = rng.getValues().map(r => String(r[0]));
    const want = keys.map((k, i) => { const d = all.find(x => x[0] === k); return d && d[2] ? d[2] : cur[i]; });
    if (want.join('\u0001') !== cur.join('\u0001')) rng.setValues(want.map(v => [v]));
  }
  return sh;
}
/* Reads _settings into cfg. Only keys present in `schema` are accepted; type comes from schema: 'num' | 'bool' | 'str' | 'list'. */
function readSettings_(ss, cfg, schema) {
  const out = JSON.parse(JSON.stringify(cfg));
  const sh = ss.getSheetByName(TECH.settings);
  if (!sh || sh.getLastRow() < 2) return out;
  sh.getRange(2, 1, sh.getLastRow() - 1, 2).getValues().forEach(([k, v]) => {
    const type = schema[k]; if (!type || v === '' || v === null) return;
    if (type === 'num') {
      if (typeof v === 'number') { out[k] = v; return; }
      const s = String(v).trim().replace(/\s/g, '');
      if (!/^-?\d+([.,]\d+)?$/.test(s)) throw new Error(`_settings.${k}: "${v}" is not a number (use 0.5 or 0,5)`);
      if (/^-?\d{1,3}[.,]\d{3}$/.test(s)) throw new Error(`_settings.${k}: "${v}" is ambiguous; do not use thousands separators (write 1000 or 0.5 / 0,5)`);
      out[k] = parseFloat(s.replace(',', '.'));   // decimal comma/dot accepted; ambiguous 1.000 / 1,000 is rejected
    }
    else if (type === 'bool') out[k] = /^(true|yes|1|on|evet|ja)$/i.test(String(v).trim());
    else if (type === 'list') out[k] = String(v).split(',').map(s => s.trim()).filter(Boolean);
    else out[k] = String(v).trim();
  });
  return out;
}
function writeSetting_(ss, key, value) {
  const sh = ss.getSheetByName(TECH.settings); if (!sh) return;
  const last = sh.getLastRow();
  const keys = last > 1 ? sh.getRange(2, 1, last - 1, 1).getValues().map(r => String(r[0])) : [];
  const i = keys.indexOf(key);
  const cell = i >= 0 ? sh.getRange(i + 2, 2) : sh.getRange(last + 1, 2);
  if (i < 0) sh.getRange(last + 1, 1).setNumberFormat('@').setValue(key);
  cell.setNumberFormat('@').setValue(value);
}

/* ===================== common/seal ===================== */
/* Attribution cell. Warning-only protection (no editor management → no Session dependency, no lock-out of the
   running user). Never throws: a report must not fail because a Sheet cell was edited.
   Called twice: from openSpreadsheet_ (first tab, before anything else) and at the end of main() on the report tab.
   Returns the spreadsheet so the open path depends on it. This is attribution, not DRM: anyone who edits the
   source can remove it. */
function seal_(ss, tabName) {
  try {
    const sheet = (tabName && ss.getSheetByName(tabName)) || ss.getSheets().find(s => !s.getName().startsWith('_')) || ss.getSheets()[0];
    const want = ATTRIBUTION;
    const range = sheet.getRange('H1:K1');
    let cur = ''; try { cur = String(range.getCell(1, 1).getValue()); } catch (e) {}
    if (cur !== want) {
      try { range.breakApart(); range.merge(); } catch (e) {}
      range.getCell(1, 1).setValue(want).setFontWeight('bold').setFontColor('#0F766E').setHorizontalAlignment('center').setNote(want);
    }
    const has = sheet.getProtections(SpreadsheetApp.ProtectionType.RANGE).some(p => p.getDescription() === want);
    if (!has) range.protect().setDescription(want).setWarningOnly(true);
  } catch (e) { Logger.log('Attribution skipped: ' + e); }
  return ss;
}

/* ===================== common/notify ===================== */
function htmlShell_(title, subtitle, body, url) {
  return '<div style="font:13px Arial,sans-serif;color:#1E293B;max-width:760px">' +
    '<div style="background:#1E293B;color:#fff;padding:12px 16px;font-size:15px;font-weight:bold">' + h_(title) + '</div>' +
    '<div style="padding:10px 16px;color:#64748B">' + h_(subtitle) + '</div>' + body +
    '<div style="padding:12px 16px;color:#94A3B8;font-size:11px">' + (url ? h_(t_('mail.full_report')) + ': <a href="' + h_(url) + '">' + h_(url) + '</a><br>' : '') + h_(ATTRIBUTION) + '</div></div>';
}
function kpiTable_(rows) {
  /* rows: [[label, now, prev, delta_html]] */
  const th = (s, r) => '<th style="padding:5px 10px;text-align:' + (r ? 'right' : 'left') + '">' + h_(s) + '</th>';
  return '<table style="border-collapse:collapse;width:100%"><tr style="background:#F1F5F9;color:#64748B;font-size:11px">' +
    th(t_('kpi'), false) + th(t_('kpi.now'), true) + th(t_('kpi.prev'), true) + th('Δ', true) + '</tr>' +
    rows.map(r => '<tr><td style="padding:5px 10px">' + h_(r[0]) + '</td><td style="padding:5px 10px;text-align:right;font-weight:bold">' + h_(r[1]) +
      '</td><td style="padding:5px 10px;text-align:right;color:#64748B">' + h_(r[2]) + '</td><td style="padding:5px 10px;text-align:right">' + r[3] + '</td></tr>').join('') + '</table>';
}
function delta_(a, b, invert) {
  const v = pct_(a, b); if (!isFinite(v)) return '';
  const good = invert ? v < 0 : v > 0;
  return '<span style="color:' + (good ? '#15803D' : '#B91C1C') + '">' + (v >= 0 ? '▲' : '▼') + ' ' + fmtPct_(Math.abs(v), 0) + '</span>';
}
function sendMail_(to, subject, html, text) {
  if (!to) return false;
  try { MailApp.sendEmail({ to: to, subject: subject, htmlBody: html, body: text || subject }); return true; }
  catch (e) { Logger.log('E-mail failed: ' + e); return false; }
}
function slackText_(s) { return String(s === undefined || s === null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
function sendSlack_(webhook, text) {
  if (!webhook) return false;
  try {
    const res = UrlFetchApp.fetch(webhook, { method: 'post', contentType: 'application/json', muteHttpExceptions: true, payload: JSON.stringify({ text: text + '\n_' + slackText_(ATTRIBUTION) + '_' }) });
    const ok = res.getResponseCode() === 200;
    if (!ok) Logger.log('Slack returned ' + res.getResponseCode() + ': ' + res.getContentText().slice(0, 200));
    return ok;
  } catch (e) { Logger.log('Slack failed: ' + e); return false; }
}

/* ===================== common/negatives ===================== */
/* Negative keywords by ID: ad group, campaign, shared lists attached to campaigns, and account level. */
function fetchNegatives_(campaignIds, language, opts) {
  opts = opts || {};
  const neg = { adGroup: {}, campaign: {}, account: [] };
  const item = (text, mt) => ({ words: tokens_(text, language), mt: String(mt || '').toUpperCase() });
  const push = (bucket, key, text, mt) => { (bucket[key] = bucket[key] || []).push(item(text, mt)); };
  chunks_(campaignIds, 200).forEach(chunk => {
    if (opts.adGroupLevel !== false) {
      query_(`SELECT ad_group.id, ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type
        FROM ad_group_criterion WHERE ad_group_criterion.negative = TRUE AND ad_group_criterion.type = 'KEYWORD'
        AND ad_group_criterion.status != 'REMOVED' AND campaign.id IN (${chunk.join(',')})`)
        .forEach(r => push(neg.adGroup, String(r.adGroup.id), r.adGroupCriterion.keyword.text, r.adGroupCriterion.keyword.matchType));
    }
    query_(`SELECT campaign.id, campaign_criterion.keyword.text, campaign_criterion.keyword.match_type
      FROM campaign_criterion WHERE campaign_criterion.negative = TRUE AND campaign_criterion.type = 'KEYWORD'
      AND campaign_criterion.status != 'REMOVED' AND campaign.id IN (${chunk.join(',')})`)
      .forEach(r => push(neg.campaign, String(r.campaign.id), r.campaignCriterion.keyword.text, r.campaignCriterion.keyword.matchType));
  });
  const setToCampaigns = {};
  chunks_(campaignIds, 200).forEach(chunk => {
    query_(`SELECT campaign.id, shared_set.id FROM campaign_shared_set
      WHERE shared_set.type = 'NEGATIVE_KEYWORDS' AND campaign_shared_set.status = 'ENABLED' AND shared_set.status = 'ENABLED'
      AND campaign.id IN (${chunk.join(',')})`)
      .forEach(r => (setToCampaigns[String(r.sharedSet.id)] = setToCampaigns[String(r.sharedSet.id)] || []).push(String(r.campaign.id)));
  });
  const setIds = Object.keys(setToCampaigns);
  chunks_(setIds, 200).forEach(chunk => {
    query_(`SELECT shared_set.id, shared_criterion.keyword.text, shared_criterion.keyword.match_type
      FROM shared_criterion WHERE shared_criterion.type = 'KEYWORD' AND shared_set.id IN (${chunk.join(',')})`)
      .forEach(r => setToCampaigns[String(r.sharedSet.id)].forEach(cid => push(neg.campaign, cid, r.sharedCriterion.keyword.text, r.sharedCriterion.keyword.matchType)));
  });
  /* Account-level negatives are a shared set of type ACCOUNT_LEVEL_NEGATIVE_KEYWORDS (customer_negative_criterion has no
     keyword field; it only points at that list). */
  try {
    query_(`SELECT shared_criterion.keyword.text, shared_criterion.keyword.match_type
      FROM shared_criterion WHERE shared_set.type = 'ACCOUNT_LEVEL_NEGATIVE_KEYWORDS' AND shared_criterion.type = 'KEYWORD' AND shared_set.status = 'ENABLED'`)
      .forEach(r => neg.account.push(item(r.sharedCriterion.keyword.text, r.sharedCriterion.keyword.matchType)));
  } catch (e) { Logger.log('Account-level negatives not readable: ' + e); neg.accountError = String(e); }
  return neg;
}
function isBlocked_(tok, neg, campaignId, adGroupId) {
  return blocked_(tok, neg.account) || blocked_(tok, neg.campaign[campaignId]) || (adGroupId ? blocked_(tok, neg.adGroup[adGroupId]) : false);
}

/* ===================== common/metrics ===================== */
/* Standard metric block for tables: header / row / number formats, all driven by T and one metric bag. */
const M_KEYS = ['impressions', 'clicks', 'ctr', 'cost', 'cpc', 'conversions', 'value', 'cr', 'cpa', 'roas'];
const M_FMT = ['#,##0', '#,##0', '0.00%', '#,##0.00', '#,##0.00', '#,##0.0', '#,##0.00', '0.00%', '#,##0.00', '0.00'];
function mHead_(keys) { return (keys || M_KEYS).map(k => t_('m.' + k)); }
function mRow_(m, keys) { return (keys || M_KEYS).map(k => nz_(m[k])); }
function mFmt_(keys) { return (keys || M_KEYS).map(k => M_FMT[M_KEYS.indexOf(k)] || null); }

/* ===================== common/ngram ===================== */
/* Shared n-gram engine for N-Gram Lens and PMax N-Gram Lens. terms: [{campaignId, campaign, adGroupId, term, source, categories?, m}] */
function ngramAnalyse_(terms, neg, opts) {
  const lang = opts.language, stop = new Set((opts.stopwords || []).map(w => normalize_(w, lang)));
  const brand = (opts.brand_terms || []).map(b => tokens_(b, lang)).filter(t => t.length);
  const acc = {}, camp = {}, cat = {}, wc = {}, totals = zeroM_(), split = { brand: zeroM_(), generic: zeroM_() };
  const blockedKeys = new Set(); let excluded = 0, queries = 0;
  const bump = (map, key, seed, m) => { const a = map[key] = map[key] || Object.assign(zeroM_(), { queries: 0 }, seed); Object.assign(a, addM_(a, m)); a.queries++; };
  terms.forEach(t => {
    const tok = tokens_(t.term, lang); if (!tok.length) return;
    if (neg && isBlocked_(tok, neg, t.campaignId, t.adGroupId)) { excluded++; blockedKeys.add(t.campaignId + '|' + t.term.toLowerCase()); return; }
    queries++; Object.assign(totals, addM_(totals, t.m));
    const isBrand = brand.length && brand.some(b => hasSubseq_(tok, b));
    const side = isBrand ? split.brand : split.generic; Object.assign(side, addM_(side, t.m)); side.queries = (side.queries || 0) + 1;
    bump(wc, tok.length > 6 ? '7+' : String(tok.length), { words: tok.length > 6 ? '7+' : String(tok.length) }, t.m);
    const seen = new Set();
    for (let n = opts.min_ngram; n <= opts.max_ngram && n <= tok.length; n++) for (let i = 0; i + n <= tok.length; i++) {
      const words = tok.slice(i, i + n); if (n === 1 && stop.has(words[0])) continue;
      const g = words.join(' '), k = n + '|' + g; if (seen.has(k)) continue; seen.add(k);
      bump(acc, k, { n: n, gram: g }, t.m);
      bump(camp, t.campaignId + '|' + k, { n: n, gram: g, campaign: t.campaign, source: t.source }, t.m);
      (t.categories || []).forEach(c => bump(cat, t.campaignId + '|' + c + '|' + k, { n: n, gram: g, campaign: t.campaign, category: c }, t.m));
    }
  });
  derive_(totals); derive_(split.brand); derive_(split.generic);
  const th = opts.thresholds, keep = a => a.queries >= th.query_count && a.impressions >= th.impressions && a.clicks >= th.clicks && (a.cost || 0) >= (th.cost || 0);
  const rank = opts.rankBy === 'clicks' ? ((x, y) => y.clicks - x.clicks || y.impressions - x.impressions || x.gram.localeCompare(y.gram))
                                        : ((x, y) => y.cost - x.cost || y.impressions - x.impressions || x.gram.localeCompare(y.gram));
  return { queries, excluded, totals, split, blockedKeys,
    account: Object.values(acc).map(derive_).filter(keep).sort(rank),
    campaign: Object.values(camp).map(derive_).filter(keep).sort((x, y) => x.campaign.localeCompare(y.campaign) || rank(x, y)),
    category: Object.values(cat).map(derive_).filter(keep).sort((x, y) => x.campaign.localeCompare(y.campaign) || x.category.localeCompare(y.category) || rank(x, y)),
    wordCount: ['1', '2', '3', '4', '5', '6', '7+'].map(k => derive_(Object.assign({ words: k, queries: 0 }, zeroM_(), wc[k] || {}))) };
}
const NG_KEYS = ['queries'].concat(M_KEYS);
function ngHead_(keys) { return (keys || NG_KEYS).map(k => k === 'queries' ? t_('col.queries') : t_('m.' + k)); }
function ngFmt_(keys) { return (keys || NG_KEYS).map(k => k === 'queries' ? '#,##0' : M_FMT[M_KEYS.indexOf(k)] || null); }
function ngRow_(a, keys) { return (keys || NG_KEYS).map(k => nz_(a[k])); }
function writeNgrams_(ss, tabKey, rows, segKeys, keys, cap) {
  const head = segKeys.map(k => t_('col.' + k)).concat([t_('col.n'), t_('col.phrase')]).concat(ngHead_(keys));
  const fmt = segKeys.map(() => '@').concat(['0', '@']).concat(ngFmt_(keys));
  table_(ss.getSheetByName(t_('tab.' + tabKey)), 1, head, rows.map(r => segKeys.map(k => r[k]).concat([r.n, r.gram]).concat(ngRow_(r, keys))), fmt, cap);
}


/**
 * @name Account Summary
 * @overview Daily account-level metrics appended to one Report tab, plus a
 *   yesterday / two days ago / a week ago comparison e-mail. Self-contained:
 *   the script creates its own tabs and header row — no template, no named ranges.
 * Schedule: Daily, 04:00 account time or later.
 */
const CONFIG = {
  spreadsheet_url: 'YOUR_SPREADSHEET_URL',
  email: '',                  // comma-separated; blank = no e-mail. Can be overridden in _settings.
  backfill_days: 1,           // first run: how many past days to fill (max 90)
  refresh_days: 30,           // re-query the last N days every run so late conversions are picked up (match your longest conversion window)
  account_id: '',             // only when running from a manager account
  fields: [                   // customer-resource metrics; the header row is generated from T
    'metrics.cost_micros', 'metrics.average_cpc', 'metrics.ctr', 'metrics.search_impression_share',
    'metrics.impressions', 'metrics.clicks', 'metrics.conversions', 'metrics.conversions_value',
    'metrics.cost_per_conversion', 'metrics.conversions_from_interactions_rate', 'metrics.all_conversions',
    'metrics.view_through_conversions', 'metrics.search_budget_lost_impression_share',
    'metrics.search_rank_lost_impression_share', 'metrics.search_top_impression_share',
    'metrics.search_absolute_top_impression_share', 'metrics.average_cpm', 'metrics.interactions',
    'metrics.interaction_rate', 'metrics.invalid_clicks'
  ]
};
const SETTINGS_SCHEMA = { email: 'str', last_check: 'str' };
const MICROS = /(_micros|average_cpc|average_cpm|cost_per_conversion|average_cost|average_cpv)$/;
const SHARE = /impression_share$/;
const RATE = /(_rate|\.ctr)$/;

function main() {
  selectAccount_(CONFIG.account_id);
  const ss = openSpreadsheet_(CONFIG.spreadsheet_url);
  const tz = tz_(), cid = AdsApp.currentAccount().getCustomerId(), currency = AdsApp.currentAccount().getCurrencyCode();
  ss.setSpreadsheetTimeZone(tz);
  ensureTabs_(ss, [{ name: t_('tab.report'), aliases: T['alias.report'] }, { name: TECH.settings, aliases: T['alias.settings'] }]);
  ensureSettings_(ss, [['email', CONFIG.email, t_('settings.email')], ['last_check', '', t_('settings.last_check')]]);
  const cfg = readSettings_(ss, CONFIG, SETTINGS_SCHEMA);
  const sheet = ss.getSheetByName(t_('tab.report'));
  const header = [t_('col.date')].concat(CONFIG.fields.map(f => t_('f.' + f.replace('metrics.', ''))));
  const TOP = 2;   // row 1 = title + attribution (H1:K1), row 2 = header, data from row 3
  if (isLegacyTemplate_(ss, sheet)) throw new Error('This Sheet uses the old Google Account Summary template layout (named ranges, data from B6). Point the script at a new empty Sheet; the old report stays intact.');
  ensureSize_(sheet, TOP + 1, header.length);
  if (sheet.getLastRow() < TOP) { sheet.getRange('A1').setValue(t_('title')).setFontWeight('bold').setFontSize(13); styleHeader_(sheet.getRange(TOP, 1, 1, header.length).setValues([header])); sheet.setFrozenRows(TOP); }

  const yesterday = window_(1, 0, tz).end;
  const refresh = Math.max(1, Math.round(CONFIG.refresh_days) || 1), backfill = Math.min(90, Math.max(1, Math.round(CONFIG.backfill_days) || 1));
  const firstNew = cfg.last_check ? shiftDay_(dateStrOf_(cfg.last_check, tz), 1) : shiftDay_(yesterday, -(backfill - 1));
  const from = cfg.last_check ? [firstNew, shiftDay_(yesterday, -(refresh - 1))].sort()[0] : firstNew;   // new days + refresh window, one query
  const byDate = {};
  query_(`SELECT segments.date, ${CONFIG.fields.join(', ')} FROM customer WHERE segments.date BETWEEN '${from}' AND '${yesterday}'`).forEach(r => byDate[r.segments.date] = r.metrics || {});
  const existing = {}; if (sheet.getLastRow() > TOP) sheet.getRange(TOP + 1, 1, sheet.getLastRow() - TOP, 1).getValues().forEach((r, i) => existing[dateStrOf_(r[0], tz)] = TOP + 1 + i);
  const rows = [], fmts = ['@'].concat(CONFIG.fields.map(fmtFor_)); let updated = 0;
  for (let d = from; d <= yesterday; d = shiftDay_(d, 1)) {
    const row = [d].concat(CONFIG.fields.map(f => valueOf_(f, byDate[d] || {})));
    if (existing[d]) { sheet.getRange(existing[d], 2, 1, header.length - 1).setValues([row.slice(1)]); updated++; }   // late conversions: refresh in place
    else rows.push(row);
  }
  if (rows.length) { appendRows_(sheet, rows, fmts); sheet.getRange(TOP + 1, 1, sheet.getLastRow() - TOP, header.length).sort({ column: 1, ascending: true }); }
  writeSetting_(ss, 'last_check', yesterday);
  if (rows.length && cfg.email) sendSummary_(cfg.email, cid, currency, tz, ss.getUrl(), byDate);
  seal_(ss, t_('tab.report'));
  Logger.log(`${rows.length} day(s) added, ${updated} refreshed, up to ${yesterday}.`);
}
function isLegacyTemplate_(ss, sheet) {
  try { if (ss.getRangeByName && (ss.getRangeByName('last_check') || ss.getRangeByName('account_id_report'))) return true; } catch (e) {}
  return String(sheet.getRange(1, 1).getValue()) === '' && sheet.getLastRow() >= 5 && /^(Date|Tarih|Datum)$/i.test(String(sheet.getRange(5, 2).getValue()));
}
function fmtFor_(f) { return SHARE.test(f) || RATE.test(f) ? '0.00%' : MICROS.test(f) ? '#,##0.00' : '#,##0.##'; }
function rowFor_(byDate, d) {
  if (byDate[d]) return byDate[d];
  const rows = query_(`SELECT segments.date, ${CONFIG.fields.join(', ')} FROM customer WHERE segments.date BETWEEN '${d}' AND '${d}'`);
  return (byDate[d] = rows.length ? rows[0].metrics || {} : {});
}
/* Higher is worse for these: colour the change the other way round. */
const INVERT = /(cost_micros|average_cpc|average_cpm|cost_per_conversion|lost_impression_share|invalid_clicks)$/;
function camel_(f) { return f.replace('metrics.', '').replace(/_([a-z])/g, (m, c) => c.toUpperCase()); }
/* Numbers stay numbers in the sheet; the number format does the display. Impression share < 0.1 is returned by the API as 0.0999 → shown as "<10%" only in e-mail. */
function valueOf_(f, m) { const v = m[camel_(f)]; if (v === undefined || v === null || v === '') return ''; return MICROS.test(f) ? num_(v) / 1e6 : num_(v); }
function display_(f, v) {
  if (v === '' || !isFinite(v)) return '–';
  if (SHARE.test(f)) return v <= 0.0999 ? '<10%' : fmtPct_(v, 1);
  if (RATE.test(f)) return fmtPct_(v);
  return fmtNum_(v, MICROS.test(f) ? 2 : (Number.isInteger(v) ? 0 : 2));
}
function change_(f, a, b) {
  if (typeof a !== 'number' || typeof b !== 'number' || !isFinite(a) || !isFinite(b) || (SHARE.test(f) && (a <= 0.0999 || b <= 0.0999))) return '';
  const d = a - b, s = (RATE.test(f) || SHARE.test(f)) ? fmtPct_(Math.abs(d)) : fmtNum_(Math.abs(d), MICROS.test(f) ? 2 : 0);
  const good = INVERT.test(f) ? d <= 0 : d >= 0;
  return '<span style="font-size:8pt;color:' + (good ? '#38761d' : '#cc0000') + '"> (' + (d >= 0 ? '+' : '−') + s + ')</span>';
}
function sendSummary_(to, cid, currency, tz, url, byDate) {
  const y = window_(1, 0, tz).end, d2 = shiftDay_(y, -1), w = shiftDay_(y, -6);
  const a = rowFor_(byDate, y), b = rowFor_(byDate, d2), c = rowFor_(byDate, w);
  const th = s => '<th style="padding:5px 10px;text-align:right">' + h_(s) + '</th>';
  const body = '<table style="border-collapse:collapse;width:100%"><tr style="background:#F1F5F9;color:#64748B;font-size:11px"><th></th>' +
    th(t_('mail.yesterday')) + th(t_('mail.two_days_ago')) + th(t_('mail.week_ago')) + '</tr>' +
    CONFIG.fields.map(f => { const va = valueOf_(f, a), vb = valueOf_(f, b), vc = valueOf_(f, c);
      return '<tr><td style="padding:5px 10px">' + h_(t_('f.' + f.replace('metrics.', ''))) + '</td><td style="padding:5px 10px;text-align:right;font-weight:bold">' + display_(f, va) +
        '</td><td style="padding:5px 10px;text-align:right">' + display_(f, vb) + change_(f, va, vb) + '</td><td style="padding:5px 10px;text-align:right">' + display_(f, vc) + change_(f, va, vc) + '</td></tr>'; }).join('') + '</table>';
  const subject = t_('mail.subject', { cid: cid, date: y });
  sendMail_(to, subject, htmlShell_(t_('title'), `${cid} · ${y} · ${currency}`, body, url), subject + '\n' + url);
}
3 langsSchedule: Daily

Hourly account-health monitor: compares today with the same weekday over the past weeks using median/MAD z-scores and sends de-duplicated alerts with recovery notices.

// anomaly-sentinel — EN build 2026-09-14
// Copyright 2026 Gökhan Güzel — gokhanguzel.com. All Rights Reserved. Generated file: edit src/ and i18n/, then run build.js.
// Do not share configured Slack webhook URLs.
'use strict';
const SCRIPT_ID = "anomaly-sentinel";
const L = {"lang":"en","locale":"en-US","text_lang":"en","dec":".","thou":","};
const ATTRIBUTION = String.fromCharCode(169,32,103,111,107,104,97,110,103,117,122,101,108,46,99,111,109);
const T = {"kpi":"KPI","kpi.now":"This period","kpi.prev":"Previous","yes":"yes","no":"no","mail.full_report":"Full report","mail.open_report":"Open report","note.no_rows":"No rows.","note.truncated":"Showing {shown} of {total} rows (max_rows_written).","note.no_campaigns":"No campaigns with impressions match the current filters.","settings.key":"Key","settings.value":"Value","settings.notes":"Notes","settings.script":"script that owns this Sheet (do not edit)","settings.account":"Google Ads account that owns this Sheet (do not edit)","note.truncated_queries":"⚠ {n} quer(y/ies) hit the read limit — results incomplete; narrow the window","note.account_neg_failed":"⚠ account-level negatives could not be read:","note.skipped_time":"skipped — execution time budget","note.data_error":"data error:","note.failed":"FAILED —","note.no_final_url":"(no final URL)","alias.settings":["Settings","Ayarlar","Einstellungen"],"alias.history":["History","Kronoloji","Verlauf","Runs","Calismalar","Laeufe"],"alias.snapshot":["Snapshot","Snapshots","Kesit","Kesitler","Momentaufnahme","Momentaufnahmen"],"alias.state":["State","DurumBellegi","Zustand"],"alias.baseline":["Baseline","TemelCizgi","Basislinie"],"m.impressions":"Impr.","m.clicks":"Clicks","m.ctr":"CTR","m.cost":"Cost","m.cpc":"Avg. CPC","m.conversions":"Conv.","m.value":"Conv. value","m.cr":"Conv. rate","m.cpa":"CPA","m.roas":"ROAS","m.aov":"AOV","m.vpc":"Value / click","col.account":"Account","col.period":"Period","col.currency":"Currency","col.date":"Date","col.campaign":"Campaign","col.ad_group":"Ad group","col.n":"N","col.phrase":"Phrase","col.queries":"Queries","col.source":"Source","col.why":"Why","title":"Anomaly Sentinel","tab.alerts":"Alerts","alias.alerts":["Uyarılar","Alarme","Alerts Log","Uyarı Günlüğü","Alarmprotokoll"],"settings.email":"comma-separated recipients; blank = no e-mail","settings.weeks":"same-weekday history depth (default 12)","settings.lag_hours":"reporting delay tolerance in hours (default 3)","settings.quiet_hours_end":"no rate-based alerts before this hour (default 6)","settings.min_impressions":"volume gate for rate metrics (default 1000)","settings.min_clicks":"volume gate for rate metrics (default 50)","settings.min_conversions_baseline":"skip CPA / conv. rate / ROAS alerts when baseline conversions are below this (default 3)","settings.z_warning":"default 2.0","settings.z_critical":"default 3.0","settings.min_pct_deviation":"ignore deviations below this share, e.g. 0.20 (default)","settings.exception_dates":"yyyy-MM-dd, comma-separated (holidays, launches)","settings.campaign_hints":"top-N campaigns explaining a deviation (default 3)","settings.conversion_metrics_max_severity":"WARNING (default) or CRITICAL — cap for conversion-based metrics until the snapshot baseline has enough history","col.timestamp":"Timestamp","col.hour":"Hour","col.metric":"Metric","col.status":"Status","col.severity":"Severity","col.observed":"Observed","col.expected":"Expected","col.deviation":"Deviation","col.samples":"Samples","col.hints":"Campaign hints","col.delivered":"Delivered via","sev.OK":"OK","sev.WARNING":"WARNING","sev.CRITICAL":"CRITICAL","status.NEW":"NEW","status.ESCALATED":"ESCALATED","status.RECOVERED":"RECOVERED","mail.subject":"Google Ads {cid} — {sev} anomaly ({date} {hour}:00)","mail.subject_recovered":"✅ Google Ads {cid} — anomalies recovered ({date} {hour}:00)","mail.sub":"{date} · evaluated through {hour}:00 · {currency}"};

/* ===================== common/core ===================== */
/* Pure helpers. No Sheets, no network. Everything here is unit-tested in test/run.js. */

/* AdsApp.search is report-backed and is not subject to selector entity limits. Keep a defensive count check only for
   runtimes/iterators that expose totalNumEntities(); normally this stays at zero. */
let TRUNCATED_ = 0;
function query_(gaql) {
  const out = [];
  const it = AdsApp.search(gaql);
  while (it.hasNext()) out.push(it.next());
  try { if (typeof it.totalNumEntities === 'function') { const n = it.totalNumEntities(); if (n > out.length) { TRUNCATED_++; Logger.log(`Query truncated: ${out.length} of ${n} rows read — narrow the window or filters. ${gaql.slice(0, 120)}`); } } } catch (e) {}
  return out;
}
/* Seconds left in the 30-minute execution window (Infinity when the runtime does not expose it, e.g. in the mock). */
function timeLeft_() { try { return AdsApp.getExecutionInfo().getRemainingTime(); } catch (e) { return Infinity; } }
function num_(v) { const x = parseFloat(v); return isFinite(x) ? x : 0; }
function nz_(v) { return isFinite(v) ? v : ''; }
function esc_(s) { return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'"); }
function escLike_(s) { const m = { '[': '[[]', ']': '[]]', '%': '[%]', '_': '[_]' }; return esc_(s).replace(/[\[\]%_]/g, c => m[c]); }
function chunks_(arr, n) { const o = []; for (let i = 0; i < arr.length; i += n) o.push(arr.slice(i, i + n)); return o; }
function pct_(a, b) { return (typeof a === 'number' && typeof b === 'number' && isFinite(a) && isFinite(b) && b) ? a / b - 1 : NaN; }

/* Number formatting for e-mail / Slack. Deterministic: separators come from the build (L.dec / L.thou), not from
   the runtime's ICU data. */
function fmtNum_(v, digits) {
  if (typeof v !== 'number' || !isFinite(v)) return '–';
  const d = digits === undefined ? (Math.abs(v) >= 1000 ? 0 : Math.abs(v) < 1 ? 3 : 2) : digits;
  const parts = Math.abs(v).toFixed(d).split('.');
  return (v < 0 ? '-' : '') + parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, L.thou) + (parts[1] ? L.dec + parts[1] : '');
}
function fmtPct_(v, digits) { return isFinite(v) ? fmtNum_(v * 100, digits === undefined ? 2 : digits) + '%' : '–'; }

/* Metric bag: raw sums + derived rates. NaN means "undefined", never 0. */
function zeroM_() { return { impressions: 0, clicks: 0, cost: 0, conversions: 0, value: 0 }; }
function rawOf_(r, opts) {
  const m = (r && r.metrics) || {}; opts = opts || {};
  return {
    impressions: num_(m.impressions), clicks: num_(m.clicks),
    cost: opts.noCost ? 0 : num_(m.costMicros) / 1e6,
    conversions: opts.noConv ? NaN : num_(m.conversions),
    value: opts.noConv ? NaN : num_(m.conversionsValue)
  };
}
function addM_(a, b) {
  const sumKnown = (x, y) => isFinite(x) && isFinite(y) ? x + y : NaN;
  return {
    impressions: a.impressions + b.impressions, clicks: a.clicks + b.clicks, cost: a.cost + b.cost,
    conversions: sumKnown(a.conversions, b.conversions),
    value: sumKnown(a.value, b.value)
  };
}
function derive_(o) {
  o.ctr = o.impressions ? o.clicks / o.impressions : NaN;
  o.cpc = o.clicks ? o.cost / o.clicks : NaN;
  o.cr = o.clicks && isFinite(o.conversions) ? o.conversions / o.clicks : NaN;
  o.cpa = o.conversions > 0 ? o.cost / o.conversions : NaN;
  o.roas = o.cost > 0 && isFinite(o.value) ? o.value / o.cost : NaN;
  o.aov = o.conversions > 0 ? o.value / o.conversions : NaN;
  o.vpc = o.clicks ? o.value / o.clicks : NaN;
  return o;
}

/* Text normalisation for search terms / negatives. language: 'tr' handles İ/ı. */
function normalize_(s, language) {
  s = String(s || '');
  s = language === 'tr' ? s.replace(/I/g, 'ı').replace(/İ/g, 'i').toLowerCase() : s.toLowerCase();
  return s.replace(/[+\[\]"“”'’`^*]/g, ' ')
          .replace(/[^\p{L}\p{N}\s\-\.]/gu, ' ')
          .split(/\s+/).map(w => w.replace(/^[.\-]+|[.\-]+$/g, '')).filter(Boolean).join(' ');
}
function tokens_(s, language) { const n = normalize_(s, language); return n ? n.split(' ') : []; }
function hasSubseq_(hay, needle) {
  outer: for (let i = 0; i + needle.length <= hay.length; i++) {
    for (let j = 0; j < needle.length; j++) if (hay[i + j] !== needle[j]) continue outer;
    return true;
  }
  return false;
}
/* exact = same tokens; phrase = contiguous; broad = every word present. */
function blocked_(q, negs) {
  if (!negs) return false;
  for (const n of negs) {
    if (!n.words.length) continue;
    if (n.mt === 'EXACT') { if (n.words.length === q.length && n.words.every((w, i) => w === q[i])) return true; }
    else if (n.mt === 'PHRASE') { if (hasSubseq_(q, n.words)) return true; }
    else if (n.words.every(w => q.includes(w))) return true;
  }
  return false;
}

/* Robust stats: median / MAD (σ-scaled), SD fallback. */
function median_(sorted) { const n = sorted.length; if (!n) return NaN; return n % 2 ? sorted[(n - 1) / 2] : (sorted[n / 2 - 1] + sorted[n / 2]) / 2; }
function robust_(arr) {
  if (!arr.length) return { median: NaN, mad: NaN, scale: 0 };
  const s = arr.slice().sort((a, b) => a - b), med = median_(s);
  const mad = median_(s.map(v => Math.abs(v - med)).sort((a, b) => a - b)) * 1.4826;
  let scale = mad;
  if (!(scale > 0)) { const mean = arr.reduce((x, v) => x + v, 0) / arr.length; scale = Math.sqrt(arr.reduce((x, v) => x + (v - mean) * (v - mean), 0) / Math.max(1, arr.length - 1)); }
  return { median: med, mad: mad, scale: scale };
}

/* Minimal HTML escape for anything user-generated that lands in e-mail. */
function h_(s) { return String(s === undefined || s === null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
/* Text lookup. T is injected by the build; keys are stable, values are per language. */
function t_(key, vars) {
  let s = T[key]; if (s === undefined) s = key;
  return String(s).replace(/\{(\w+)\}/g, (m, k) => (vars && vars[k] !== undefined) ? vars[k] : m);
}

/* ===================== common/dates ===================== */
/* Calendar-based date arithmetic in the account time zone. No 864e5 shifts,
   so DST transitions cannot skip or repeat a day. */
function tz_() { return AdsApp.currentAccount().getTimeZone(); }
function todayStr_(tz, base) { return Utilities.formatDate(base || new Date(), tz, 'yyyy-MM-dd'); }
function shiftDay_(dateStr, days) {
  const p = dateStr.split('-').map(Number);
  const d = new Date(Date.UTC(p[0], p[1] - 1, p[2] + days));
  return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + String(d.getUTCDate()).padStart(2, '0');
}
/* window_(days, offset): `days` complete days, ending `offset+1` days before today. offset 0 → ends yesterday. */
function window_(days, offset, tz, base) {
  const today = todayStr_(tz, base);
  const end = shiftDay_(today, -(offset + 1)), start = shiftDay_(today, -(offset + days));
  return { start: start, end: end, label: start + ' → ' + end };
}
const DOW_ = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY'];
function dowOf_(dateStr) { const p = dateStr.split('-').map(Number); return DOW_[new Date(Date.UTC(p[0], p[1] - 1, p[2])).getUTCDay()]; }
/* Sheets may hand back a Date where we wrote a 'yyyy-MM-dd' string. Always normalise on read. */
function dateStrOf_(v, tz) {
  if (v instanceof Date) return Utilities.formatDate(v, tz, 'yyyy-MM-dd');
  return String(v === null || v === undefined ? '' : v).trim();   // strings are never truncated: keys like 'yyyy-MM-dd HH:mm' or '123-456-7890' stay intact
}

/* ===================== common/manager ===================== */
/* Runs as a single account, or selects one client account when started from a manager account. */
function isManager_() { try { if (typeof AdsManagerApp === 'undefined') return false; AdsManagerApp.accounts().withLimit(1).get(); return true; } catch (e) { return false; } }
function selectAccount_(accountId) {
  if (!isManager_()) return;
  if (!accountId) throw new Error('Running from a manager account: set CONFIG.account_id (e.g. 123-456-7890).');
  const it = AdsManagerApp.accounts().withIds([String(accountId)]).get();
  if (!it.hasNext()) throw new Error('Account ' + accountId + ' is not a client of this manager account.');
  AdsManagerApp.select(it.next());
}

/* ===================== common/sheets ===================== */
/* Technical tabs are language-independent. User-facing tab names come from T. */
const TECH = { settings: '_settings', state: '_state', snapshot: '_snapshot', history: '_history', baseline: '_baseline' };

function openSpreadsheet_(url) {
  if (!url || url === 'YOUR_SPREADSHEET_URL') throw new Error('Set CONFIG.spreadsheet_url to a Google Sheet you own.');
  const ss = SpreadsheetApp.openByUrl(url);
  claimSheet_(ss);              // ownership first: nothing is written to a Sheet that belongs to another script or account
  return seal_(ss);             // every script opens its Sheet through the attribution layer
}
/* One Sheet per script. The first run records SCRIPT_ID in _settings; a different script pointed at the same Sheet stops
   before it can overwrite tabs (ngram-lens and pmax-ngram-lens share tab names). */
function claimSheet_(ss) {
  const sh = tab_(ss, TECH.settings, T['alias.settings']);
  const cid = AdsApp.currentAccount().getCustomerId();
  const kv = {}; if (sh.getLastRow() > 1) sh.getRange(2, 1, sh.getLastRow() - 1, 2).getValues().forEach(r => kv[String(r[0])] = String(r[1]));
  if (kv.script && kv.script !== SCRIPT_ID) throw new Error(`This Sheet belongs to ${kv.script}. Use a separate Sheet for ${SCRIPT_ID}.`);
  if (kv.account && kv.account !== cid) throw new Error(`This Sheet belongs to account ${kv.account}; running as ${cid}. Use a separate Sheet per account.`);
  if (!kv.script || !kv.account) ensureSettings_(ss, []);
}
/* Title in A1, header in row `top` (2). Writes only what is missing, so the attribution cell in H1 never masks an empty
   header row. Returns true when the header was (re)written. */
function ensureTitledHeader_(sheet, title, header) {
  ensureSize_(sheet, 3, header.length);
  if (String(sheet.getRange(1, 1).getValue()) === '') sheet.getRange(1, 1).setValue(title).setFontWeight('bold').setFontSize(13);
  const a2 = sheet.getRange(2, 1).getValue();
  if (String(a2) === header[0]) return false;
  if (String(a2) !== '') sheet.insertRowsBefore(2, 1);   // data written under a missing header (v2.2 layout): push it down, then add the header
  styleHeader_(sheet.getRange(2, 1, 1, header.length).setValues([header])); sheet.setFrozenRows(2);
  return true;
}
/* Find a tab by its current name or any legacy alias; rename legacy tabs once so state survives a language switch. */
function tab_(ss, name, aliases) {
  let sh = ss.getSheetByName(name);
  if (!sh && aliases) for (const a of aliases) { const old = ss.getSheetByName(a); if (old) { old.setName(name); sh = old; break; } }
  if (!sh) sh = ss.insertSheet(name);
  return sh;
}
/* specs: [{name, aliases:[...]}]. Removes the empty default tab whatever its locale name is. */
function ensureTabs_(ss, specs) {
  const wanted = new Set(specs.map(s => s.name));
  specs.forEach(s => tab_(ss, s.name, s.aliases));
  const first = ss.getSheets()[0];
  if (first && !wanted.has(first.getName()) && ss.getSheets().length > 1 && onlyAttribution_(first)) ss.deleteSheet(first);
}
/* A default tab that holds nothing but the attribution cell written by the early seal_ is still "empty". */
function onlyAttribution_(sheet) {
  if (sheet.getLastRow() === 0) return true;
  if (sheet.getLastRow() > 1) return false;
  const row = sheet.getRange(1, 1, 1, Math.max(1, sheet.getLastColumn())).getValues()[0];
  return row.every((v, i) => v === '' || (i === 7 && v === ATTRIBUTION));
}
/* Grow the grid BEFORE any getRange: Sheets throws on coordinates outside the current dimensions. */
function ensureSize_(sheet, rows, cols) {
  if (cols > sheet.getMaxColumns()) sheet.insertColumnsAfter(sheet.getMaxColumns(), cols - sheet.getMaxColumns());
  if (rows > sheet.getMaxRows()) sheet.insertRowsAfter(sheet.getMaxRows(), rows - sheet.getMaxRows());
}
function styleHeader_(rng) { return rng.setFontWeight('bold').setBackground('#1E293B').setFontColor('#ffffff'); }

/* Write a table. Rows above `top` are never touched (callers put titles there). formats[i] is a number format per column ('@' = text, applied BEFORE setValues so Sheets never auto-parses).
   Rows beyond `cap` are dropped and a note is written. Returns the number of rows written. */
function table_(sheet, top, header, rows, formats, cap) {
  let out = rows;
  if (cap && rows.length > cap) out = rows.slice(0, cap);
  ensureSize_(sheet, top + out.length + 1, header.length);
  const maxR = sheet.getMaxRows(), maxC = sheet.getMaxColumns();
  if (maxR >= top) sheet.getRange(top, 1, maxR - top + 1, maxC).clearContent();   // contents only: user notes/formats outside the table survive
  styleHeader_(sheet.getRange(top, 1, 1, header.length).setValues([header]));
  if (out.length) {
    (formats || []).forEach((f, i) => { if (f) sheet.getRange(top + 1, i + 1, out.length, 1).setNumberFormat(f); });
    sheet.getRange(top + 1, 1, out.length, header.length).setValues(out);
    if (out.length < rows.length) sheet.getRange(top + 1 + out.length, 1).setValue(t_('note.truncated', { shown: out.length, total: rows.length }));
  } else {
    sheet.getRange(top + 1, 1).setValue(t_('note.no_rows'));
  }
  sheet.setFrozenRows(top);
  return out.length;
}
/* Append rows with text formats applied first (appendRow cannot do that). */
function appendRows_(sheet, rows, formats) {
  if (!rows.length) return;
  const r0 = sheet.getLastRow() + 1, n = rows.length, w = rows[0].length;
  ensureSize_(sheet, r0 + n - 1, w);
  (formats || []).forEach((f, i) => { if (f) sheet.getRange(r0, i + 1, n, 1).setNumberFormat(f); });
  sheet.getRange(r0, 1, n, w).setValues(rows);
}
/* A technical tab whose header row differs from what this version writes (older layout) is renamed to an archive tab and a
   fresh one is created — rows of two schemas are never mixed. Returns the sheet to write to. */
function ensureSchema_(ss, name, header) {
  const sh = ss.getSheetByName(name); if (!sh || sh.getLastRow() === 0) return sh;
  const cur = sh.getRange(1, 1, 1, Math.max(1, sh.getLastColumn())).getValues()[0].map(String);
  while (cur.length && cur[cur.length - 1] === '') cur.pop();
  if (cur.join('|') === header.join('|')) return sh;
  let n = 1, archive = name + '_archive'; while (ss.getSheetByName(archive)) archive = name + '_archive' + (++n);
  sh.setName(archive); Logger.log(`${name}: old layout archived as ${archive}`);
  return ss.insertSheet(name);
}
/* Idempotent history: one row per key (first `keyCols` columns, joined). Existing row with the same key is replaced. */
function upsertRow_(sheet, header, row, keyCols, formats, tz) {
  ensureSize_(sheet, 2, Math.max(header.length, row.length));
  const cur = sheet.getLastRow() === 0 ? [] : sheet.getRange(1, 1, 1, Math.max(1, sheet.getLastColumn())).getValues()[0].map(String).filter(Boolean);
  if (cur.join('|') !== header.join('|')) {
    const w = Math.max(header.length, sheet.getLastColumn()); sheet.getRange(1, 1, 1, w).clearContent();
    styleHeader_(sheet.getRange(1, 1, 1, header.length).setValues([header])); sheet.setFrozenRows(1);
  }   // new/changed schema: clear stale cells, then rewrite header
  const key = row.slice(0, keyCols).map(v => dateStrOf_(v, tz)).join('|');
  const last = sheet.getLastRow();
  if (last > 1) {
    const keys = sheet.getRange(2, 1, last - 1, keyCols).getValues().map(r => r.map(v => dateStrOf_(v, tz)).join('|'));
    const i = keys.indexOf(key);
    if (i >= 0) {
      const w = Math.max(row.length, sheet.getLastColumn()); sheet.getRange(i + 2, 1, 1, w).clearContent();
      sheet.getRange(i + 2, 1, 1, row.length).setValues([row]); return;
    }
  }
  appendRows_(sheet, [row], formats);
}
/* Replace a whole table safely: write the new rows first, then clear whatever is left below. */
function replaceRows_(sheet, header, rows, formats) {
  const n = rows.length;
  ensureSize_(sheet, n + 1, header.length);
  if (sheet.getLastRow() === 0) styleHeader_(sheet.getRange(1, 1, 1, header.length).setValues([header]));
  if (n) {
    (formats || []).forEach((f, i) => { if (f) sheet.getRange(2, i + 1, n, 1).setNumberFormat(f); });
    sheet.getRange(2, 1, n, header.length).setValues(rows);
  }
  const last = sheet.getLastRow();
  if (last > n + 1) sheet.getRange(n + 2, 1, last - n - 1, Math.max(header.length, sheet.getLastColumn())).clearContent();
}

/* _settings: Key | Value | Notes. Keys are stable identifiers (never translated); notes are per language. */
function ensureSettings_(ss, defaults) {
  const sh = tab_(ss, TECH.settings, T['alias.settings']);
  ensureSize_(sh, 2 + defaults.length, 3);
  if (sh.getLastRow() === 0) {
    sh.getRange(1, 1, 1, 3).setNumberFormat('@').setValues([[t_('settings.key'), t_('settings.value'), t_('settings.notes')]]);
    styleHeader_(sh.getRange(1, 1, 1, 3)); sh.setFrozenRows(1); sh.setColumnWidths(1, 1, 200); sh.setColumnWidths(3, 1, 420);
  }
  const all = [['script', SCRIPT_ID, t_('settings.script')], ['account', AdsApp.currentAccount().getCustomerId(), t_('settings.account')]].concat(defaults);
  const last = sh.getLastRow();
  const keys = last > 1 ? sh.getRange(2, 1, last - 1, 1).getValues().map(r => String(r[0])) : [];
  const missing = all.filter(d => keys.indexOf(d[0]) < 0);
  if (missing.length) { const r0 = sh.getLastRow() + 1; ensureSize_(sh, r0 + missing.length, 3); sh.getRange(r0, 1, missing.length, 3).setNumberFormat('@').setValues(missing.map(d => [d[0], d[1], d[2]])); }
  if (keys.length) {   // notes follow the current language; one batched write, only when something differs; values are never touched
    const rng = sh.getRange(2, 3, keys.length, 1), cur = rng.getValues().map(r => String(r[0]));
    const want = keys.map((k, i) => { const d = all.find(x => x[0] === k); return d && d[2] ? d[2] : cur[i]; });
    if (want.join('\u0001') !== cur.join('\u0001')) rng.setValues(want.map(v => [v]));
  }
  return sh;
}
/* Reads _settings into cfg. Only keys present in `schema` are accepted; type comes from schema: 'num' | 'bool' | 'str' | 'list'. */
function readSettings_(ss, cfg, schema) {
  const out = JSON.parse(JSON.stringify(cfg));
  const sh = ss.getSheetByName(TECH.settings);
  if (!sh || sh.getLastRow() < 2) return out;
  sh.getRange(2, 1, sh.getLastRow() - 1, 2).getValues().forEach(([k, v]) => {
    const type = schema[k]; if (!type || v === '' || v === null) return;
    if (type === 'num') {
      if (typeof v === 'number') { out[k] = v; return; }
      const s = String(v).trim().replace(/\s/g, '');
      if (!/^-?\d+([.,]\d+)?$/.test(s)) throw new Error(`_settings.${k}: "${v}" is not a number (use 0.5 or 0,5)`);
      if (/^-?\d{1,3}[.,]\d{3}$/.test(s)) throw new Error(`_settings.${k}: "${v}" is ambiguous; do not use thousands separators (write 1000 or 0.5 / 0,5)`);
      out[k] = parseFloat(s.replace(',', '.'));   // decimal comma/dot accepted; ambiguous 1.000 / 1,000 is rejected
    }
    else if (type === 'bool') out[k] = /^(true|yes|1|on|evet|ja)$/i.test(String(v).trim());
    else if (type === 'list') out[k] = String(v).split(',').map(s => s.trim()).filter(Boolean);
    else out[k] = String(v).trim();
  });
  return out;
}
function writeSetting_(ss, key, value) {
  const sh = ss.getSheetByName(TECH.settings); if (!sh) return;
  const last = sh.getLastRow();
  const keys = last > 1 ? sh.getRange(2, 1, last - 1, 1).getValues().map(r => String(r[0])) : [];
  const i = keys.indexOf(key);
  const cell = i >= 0 ? sh.getRange(i + 2, 2) : sh.getRange(last + 1, 2);
  if (i < 0) sh.getRange(last + 1, 1).setNumberFormat('@').setValue(key);
  cell.setNumberFormat('@').setValue(value);
}

/* ===================== common/seal ===================== */
/* Attribution cell. Warning-only protection (no editor management → no Session dependency, no lock-out of the
   running user). Never throws: a report must not fail because a Sheet cell was edited.
   Called twice: from openSpreadsheet_ (first tab, before anything else) and at the end of main() on the report tab.
   Returns the spreadsheet so the open path depends on it. This is attribution, not DRM: anyone who edits the
   source can remove it. */
function seal_(ss, tabName) {
  try {
    const sheet = (tabName && ss.getSheetByName(tabName)) || ss.getSheets().find(s => !s.getName().startsWith('_')) || ss.getSheets()[0];
    const want = ATTRIBUTION;
    const range = sheet.getRange('H1:K1');
    let cur = ''; try { cur = String(range.getCell(1, 1).getValue()); } catch (e) {}
    if (cur !== want) {
      try { range.breakApart(); range.merge(); } catch (e) {}
      range.getCell(1, 1).setValue(want).setFontWeight('bold').setFontColor('#0F766E').setHorizontalAlignment('center').setNote(want);
    }
    const has = sheet.getProtections(SpreadsheetApp.ProtectionType.RANGE).some(p => p.getDescription() === want);
    if (!has) range.protect().setDescription(want).setWarningOnly(true);
  } catch (e) { Logger.log('Attribution skipped: ' + e); }
  return ss;
}

/* ===================== common/notify ===================== */
function htmlShell_(title, subtitle, body, url) {
  return '<div style="font:13px Arial,sans-serif;color:#1E293B;max-width:760px">' +
    '<div style="background:#1E293B;color:#fff;padding:12px 16px;font-size:15px;font-weight:bold">' + h_(title) + '</div>' +
    '<div style="padding:10px 16px;color:#64748B">' + h_(subtitle) + '</div>' + body +
    '<div style="padding:12px 16px;color:#94A3B8;font-size:11px">' + (url ? h_(t_('mail.full_report')) + ': <a href="' + h_(url) + '">' + h_(url) + '</a><br>' : '') + h_(ATTRIBUTION) + '</div></div>';
}
function kpiTable_(rows) {
  /* rows: [[label, now, prev, delta_html]] */
  const th = (s, r) => '<th style="padding:5px 10px;text-align:' + (r ? 'right' : 'left') + '">' + h_(s) + '</th>';
  return '<table style="border-collapse:collapse;width:100%"><tr style="background:#F1F5F9;color:#64748B;font-size:11px">' +
    th(t_('kpi'), false) + th(t_('kpi.now'), true) + th(t_('kpi.prev'), true) + th('Δ', true) + '</tr>' +
    rows.map(r => '<tr><td style="padding:5px 10px">' + h_(r[0]) + '</td><td style="padding:5px 10px;text-align:right;font-weight:bold">' + h_(r[1]) +
      '</td><td style="padding:5px 10px;text-align:right;color:#64748B">' + h_(r[2]) + '</td><td style="padding:5px 10px;text-align:right">' + r[3] + '</td></tr>').join('') + '</table>';
}
function delta_(a, b, invert) {
  const v = pct_(a, b); if (!isFinite(v)) return '';
  const good = invert ? v < 0 : v > 0;
  return '<span style="color:' + (good ? '#15803D' : '#B91C1C') + '">' + (v >= 0 ? '▲' : '▼') + ' ' + fmtPct_(Math.abs(v), 0) + '</span>';
}
function sendMail_(to, subject, html, text) {
  if (!to) return false;
  try { MailApp.sendEmail({ to: to, subject: subject, htmlBody: html, body: text || subject }); return true; }
  catch (e) { Logger.log('E-mail failed: ' + e); return false; }
}
function slackText_(s) { return String(s === undefined || s === null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
function sendSlack_(webhook, text) {
  if (!webhook) return false;
  try {
    const res = UrlFetchApp.fetch(webhook, { method: 'post', contentType: 'application/json', muteHttpExceptions: true, payload: JSON.stringify({ text: text + '\n_' + slackText_(ATTRIBUTION) + '_' }) });
    const ok = res.getResponseCode() === 200;
    if (!ok) Logger.log('Slack returned ' + res.getResponseCode() + ': ' + res.getContentText().slice(0, 200));
    return ok;
  } catch (e) { Logger.log('Slack failed: ' + e); return false; }
}

/* ===================== common/negatives ===================== */
/* Negative keywords by ID: ad group, campaign, shared lists attached to campaigns, and account level. */
function fetchNegatives_(campaignIds, language, opts) {
  opts = opts || {};
  const neg = { adGroup: {}, campaign: {}, account: [] };
  const item = (text, mt) => ({ words: tokens_(text, language), mt: String(mt || '').toUpperCase() });
  const push = (bucket, key, text, mt) => { (bucket[key] = bucket[key] || []).push(item(text, mt)); };
  chunks_(campaignIds, 200).forEach(chunk => {
    if (opts.adGroupLevel !== false) {
      query_(`SELECT ad_group.id, ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type
        FROM ad_group_criterion WHERE ad_group_criterion.negative = TRUE AND ad_group_criterion.type = 'KEYWORD'
        AND ad_group_criterion.status != 'REMOVED' AND campaign.id IN (${chunk.join(',')})`)
        .forEach(r => push(neg.adGroup, String(r.adGroup.id), r.adGroupCriterion.keyword.text, r.adGroupCriterion.keyword.matchType));
    }
    query_(`SELECT campaign.id, campaign_criterion.keyword.text, campaign_criterion.keyword.match_type
      FROM campaign_criterion WHERE campaign_criterion.negative = TRUE AND campaign_criterion.type = 'KEYWORD'
      AND campaign_criterion.status != 'REMOVED' AND campaign.id IN (${chunk.join(',')})`)
      .forEach(r => push(neg.campaign, String(r.campaign.id), r.campaignCriterion.keyword.text, r.campaignCriterion.keyword.matchType));
  });
  const setToCampaigns = {};
  chunks_(campaignIds, 200).forEach(chunk => {
    query_(`SELECT campaign.id, shared_set.id FROM campaign_shared_set
      WHERE shared_set.type = 'NEGATIVE_KEYWORDS' AND campaign_shared_set.status = 'ENABLED' AND shared_set.status = 'ENABLED'
      AND campaign.id IN (${chunk.join(',')})`)
      .forEach(r => (setToCampaigns[String(r.sharedSet.id)] = setToCampaigns[String(r.sharedSet.id)] || []).push(String(r.campaign.id)));
  });
  const setIds = Object.keys(setToCampaigns);
  chunks_(setIds, 200).forEach(chunk => {
    query_(`SELECT shared_set.id, shared_criterion.keyword.text, shared_criterion.keyword.match_type
      FROM shared_criterion WHERE shared_criterion.type = 'KEYWORD' AND shared_set.id IN (${chunk.join(',')})`)
      .forEach(r => setToCampaigns[String(r.sharedSet.id)].forEach(cid => push(neg.campaign, cid, r.sharedCriterion.keyword.text, r.sharedCriterion.keyword.matchType)));
  });
  /* Account-level negatives are a shared set of type ACCOUNT_LEVEL_NEGATIVE_KEYWORDS (customer_negative_criterion has no
     keyword field; it only points at that list). */
  try {
    query_(`SELECT shared_criterion.keyword.text, shared_criterion.keyword.match_type
      FROM shared_criterion WHERE shared_set.type = 'ACCOUNT_LEVEL_NEGATIVE_KEYWORDS' AND shared_criterion.type = 'KEYWORD' AND shared_set.status = 'ENABLED'`)
      .forEach(r => neg.account.push(item(r.sharedCriterion.keyword.text, r.sharedCriterion.keyword.matchType)));
  } catch (e) { Logger.log('Account-level negatives not readable: ' + e); neg.accountError = String(e); }
  return neg;
}
function isBlocked_(tok, neg, campaignId, adGroupId) {
  return blocked_(tok, neg.account) || blocked_(tok, neg.campaign[campaignId]) || (adGroupId ? blocked_(tok, neg.adGroup[adGroupId]) : false);
}

/* ===================== common/metrics ===================== */
/* Standard metric block for tables: header / row / number formats, all driven by T and one metric bag. */
const M_KEYS = ['impressions', 'clicks', 'ctr', 'cost', 'cpc', 'conversions', 'value', 'cr', 'cpa', 'roas'];
const M_FMT = ['#,##0', '#,##0', '0.00%', '#,##0.00', '#,##0.00', '#,##0.0', '#,##0.00', '0.00%', '#,##0.00', '0.00'];
function mHead_(keys) { return (keys || M_KEYS).map(k => t_('m.' + k)); }
function mRow_(m, keys) { return (keys || M_KEYS).map(k => nz_(m[k])); }
function mFmt_(keys) { return (keys || M_KEYS).map(k => M_FMT[M_KEYS.indexOf(k)] || null); }

/* ===================== common/ngram ===================== */
/* Shared n-gram engine for N-Gram Lens and PMax N-Gram Lens. terms: [{campaignId, campaign, adGroupId, term, source, categories?, m}] */
function ngramAnalyse_(terms, neg, opts) {
  const lang = opts.language, stop = new Set((opts.stopwords || []).map(w => normalize_(w, lang)));
  const brand = (opts.brand_terms || []).map(b => tokens_(b, lang)).filter(t => t.length);
  const acc = {}, camp = {}, cat = {}, wc = {}, totals = zeroM_(), split = { brand: zeroM_(), generic: zeroM_() };
  const blockedKeys = new Set(); let excluded = 0, queries = 0;
  const bump = (map, key, seed, m) => { const a = map[key] = map[key] || Object.assign(zeroM_(), { queries: 0 }, seed); Object.assign(a, addM_(a, m)); a.queries++; };
  terms.forEach(t => {
    const tok = tokens_(t.term, lang); if (!tok.length) return;
    if (neg && isBlocked_(tok, neg, t.campaignId, t.adGroupId)) { excluded++; blockedKeys.add(t.campaignId + '|' + t.term.toLowerCase()); return; }
    queries++; Object.assign(totals, addM_(totals, t.m));
    const isBrand = brand.length && brand.some(b => hasSubseq_(tok, b));
    const side = isBrand ? split.brand : split.generic; Object.assign(side, addM_(side, t.m)); side.queries = (side.queries || 0) + 1;
    bump(wc, tok.length > 6 ? '7+' : String(tok.length), { words: tok.length > 6 ? '7+' : String(tok.length) }, t.m);
    const seen = new Set();
    for (let n = opts.min_ngram; n <= opts.max_ngram && n <= tok.length; n++) for (let i = 0; i + n <= tok.length; i++) {
      const words = tok.slice(i, i + n); if (n === 1 && stop.has(words[0])) continue;
      const g = words.join(' '), k = n + '|' + g; if (seen.has(k)) continue; seen.add(k);
      bump(acc, k, { n: n, gram: g }, t.m);
      bump(camp, t.campaignId + '|' + k, { n: n, gram: g, campaign: t.campaign, source: t.source }, t.m);
      (t.categories || []).forEach(c => bump(cat, t.campaignId + '|' + c + '|' + k, { n: n, gram: g, campaign: t.campaign, category: c }, t.m));
    }
  });
  derive_(totals); derive_(split.brand); derive_(split.generic);
  const th = opts.thresholds, keep = a => a.queries >= th.query_count && a.impressions >= th.impressions && a.clicks >= th.clicks && (a.cost || 0) >= (th.cost || 0);
  const rank = opts.rankBy === 'clicks' ? ((x, y) => y.clicks - x.clicks || y.impressions - x.impressions || x.gram.localeCompare(y.gram))
                                        : ((x, y) => y.cost - x.cost || y.impressions - x.impressions || x.gram.localeCompare(y.gram));
  return { queries, excluded, totals, split, blockedKeys,
    account: Object.values(acc).map(derive_).filter(keep).sort(rank),
    campaign: Object.values(camp).map(derive_).filter(keep).sort((x, y) => x.campaign.localeCompare(y.campaign) || rank(x, y)),
    category: Object.values(cat).map(derive_).filter(keep).sort((x, y) => x.campaign.localeCompare(y.campaign) || x.category.localeCompare(y.category) || rank(x, y)),
    wordCount: ['1', '2', '3', '4', '5', '6', '7+'].map(k => derive_(Object.assign({ words: k, queries: 0 }, zeroM_(), wc[k] || {}))) };
}
const NG_KEYS = ['queries'].concat(M_KEYS);
function ngHead_(keys) { return (keys || NG_KEYS).map(k => k === 'queries' ? t_('col.queries') : t_('m.' + k)); }
function ngFmt_(keys) { return (keys || NG_KEYS).map(k => k === 'queries' ? '#,##0' : M_FMT[M_KEYS.indexOf(k)] || null); }
function ngRow_(a, keys) { return (keys || NG_KEYS).map(k => nz_(a[k])); }
function writeNgrams_(ss, tabKey, rows, segKeys, keys, cap) {
  const head = segKeys.map(k => t_('col.' + k)).concat([t_('col.n'), t_('col.phrase')]).concat(ngHead_(keys));
  const fmt = segKeys.map(() => '@').concat(['0', '@']).concat(ngFmt_(keys));
  table_(ss.getSheetByName(t_('tab.' + tabKey)), 1, head, rows.map(r => segKeys.map(k => r[k]).concat([r.n, r.gram]).concat(ngRow_(r, keys))), fmt, cap);
}


/**
 * @name Anomaly Sentinel
 * @overview Hourly account-health monitor. Compares today (through the last complete hour) with the same
 *   weekday over the past N weeks using median / MAD z-scores, absolute floors for zero baselines, volume
 *   gates, quiet hours, exception dates, campaign-level hints, de-duplicated alerts with recovery notices.
 *   Conversion metrics are capped at WARNING until the hour-of-day snapshot baseline has enough history
 *   (conversion lag makes "today so far" structurally lower than matured history).
 * Schedule: Hourly.
 */
const CONFIG = {
  spreadsheet_url: 'YOUR_SPREADSHEET_URL',
  email: '',
  slack_webhook: '',            // keep here, not in the sheet
  account_id: '',
  weeks: 12,
  lag_hours: 3,
  quiet_hours_end: 6,
  min_impressions: 1000,
  min_clicks: 50,
  min_conversions_baseline: 3,
  z_warning: 2.0,
  z_critical: 3.0,
  min_pct_deviation: 0.20,
  abs_floor: { impressions: 200, clicks: 20, cost: 20, conversions: 2, value: 50 },   // zero-baseline rule
  conversion_metrics_max_severity: 'WARNING',
  snapshot_baseline_min_samples: 12,  // switch conversion metrics to the hour-of-day snapshot baseline after this many
  snapshot_keep_weeks: 26,            // _snapshot rows kept (hourly); older rows are pruned
  exception_dates: [],
  campaign_hints: 3
};
const SETTINGS_SCHEMA = { email: 'str', weeks: 'num', lag_hours: 'num', quiet_hours_end: 'num', min_impressions: 'num', min_clicks: 'num',
  min_conversions_baseline: 'num', z_warning: 'num', z_critical: 'num', min_pct_deviation: 'num', exception_dates: 'list', campaign_hints: 'num',
  conversion_metrics_max_severity: 'str' };
const METRICS = [
  { key: 'impressions', bad: 'low',  kind: 'volume' }, { key: 'clicks', bad: 'both', kind: 'volume' },
  { key: 'cost',        bad: 'both', kind: 'volume' }, { key: 'conversions', bad: 'low', kind: 'volume', conv: true },
  { key: 'value',       bad: 'low',  kind: 'volume', conv: true },
  { key: 'ctr', bad: 'both', kind: 'rate' }, { key: 'cpc', bad: 'high', kind: 'rate' },
  { key: 'cr', bad: 'low', kind: 'rate', conv: true }, { key: 'cpa', bad: 'high', kind: 'rate', conv: true }, { key: 'roas', bad: 'low', kind: 'rate', conv: true }
];
const RAW = 'metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions, metrics.conversions_value';

function main() {
  selectAccount_(CONFIG.account_id);
  const ss = openSpreadsheet_(CONFIG.spreadsheet_url);
  const tz = tz_(), cid = AdsApp.currentAccount().getCustomerId(), currency = AdsApp.currentAccount().getCurrencyCode();
  ss.setSpreadsheetTimeZone(tz);
  ensureTabs_(ss, [{ name: t_('tab.alerts'), aliases: T['alias.alerts'] }, { name: TECH.settings, aliases: T['alias.settings'] },
    { name: TECH.snapshot, aliases: T['alias.snapshot'] }, { name: TECH.baseline, aliases: T['alias.baseline'] }, { name: TECH.state, aliases: T['alias.state'] }]);
  ensureSettings_(ss, Object.keys(SETTINGS_SCHEMA).map(k => [k, '', t_('settings.' + k)]));
  ensureTitledHeader_(ss.getSheetByName(t_('tab.alerts')), t_('title'), alertsHead_());   // header exists from the first run, events or not
  const cfg = readSettings_(ss, CONFIG, SETTINGS_SCHEMA);
  cfg.conversion_metrics_max_severity = String(cfg.conversion_metrics_max_severity || '').toUpperCase() === 'CRITICAL' ? 'CRITICAL' : 'WARNING';
  ['weeks', 'campaign_hints'].forEach(k => cfg[k] = Math.max(1, Math.round(cfg[k]) || 1)); cfg.lag_hours = Math.min(23, Math.max(0, cfg.lag_hours || 0));
  const now = new Date(), upTo = new Date(now.getTime() - cfg.lag_hours * 3600 * 1000);
  const hour = parseInt(Utilities.formatDate(upTo, tz, 'H'), 10);
  const today = todayStr_(tz, upTo), dow = dowOf_(today);
  const start = shiftDay_(today, -7 * cfg.weeks), yesterday = shiftDay_(today, -1);
  const exceptions = new Set(cfg.exception_dates);
  if (hour === 0) { Logger.log('Hour 0 — nothing complete yet.'); return; }

  const observedRaw = sumUpTo_(query_(`SELECT segments.hour, ${RAW} FROM customer WHERE segments.date = '${today}'`), hour);
  const observed = derive_(observedRaw);
  const byDate = {};
  query_(`SELECT segments.date, segments.hour, segments.day_of_week, ${RAW} FROM customer WHERE segments.day_of_week = '${dow}' AND segments.date BETWEEN '${start}' AND '${yesterday}'`)
    .forEach(r => { const d = r.segments.date; if (exceptions.has(d) || parseInt(r.segments.hour, 10) >= hour) return; byDate[d] = addM_(byDate[d] || zeroM_(), rawOf_(r)); });
  const samples = Object.keys(byDate).sort().map(d => derive_(byDate[d]));
  const snap = readSnapshotBaseline_(ss, dow, hour, tz, exceptions);   // hour-of-day observed history for conversion metrics
  const convBaseline = robust_(samples.map(s => s.conversions)).median || 0;

  const evals = METRICS.map(m => {
    const useSnap = m.conv && snap[m.key] && snap[m.key].length >= cfg.snapshot_baseline_min_samples;
    const hist = (useSnap ? snap[m.key] : samples.map(s => s[m.key])).filter(v => isFinite(v));
    const st = robust_(hist);
    const ev = { m: m, observed: observed[m.key], expected: st.median, mad: st.mad, z: NaN, pct: NaN, n: hist.length, severity: 'OK', basis: useSnap ? 'snapshot' : 'history' };
    if (hist.length >= 4 && isFinite(ev.observed)) {
      ev.z = st.scale > 0 ? (ev.observed - st.median) / st.scale : NaN;   // NaN = constant history, judged by % only
      ev.pct = st.median !== 0 ? ev.observed / st.median - 1 : NaN;
      ev.severity = classify_(ev, cfg, observedRaw, convBaseline, hour, exceptions.has(today), useSnap);
    }
    return ev;
  });

  const alerting = evals.filter(e => e.severity !== 'OK');
  let hints = '';
  if (alerting.length) { const lead = alerting.filter(e => e.m.kind === 'volume').sort((a, b) => mag_(b) - mag_(a))[0] || alerting[0]; hints = campaignHints_(lead.m.key, today, dow, start, yesterday, hour, exceptions, cfg.campaign_hints); }

  const state = readState_(ss, tz), events = [];
  evals.forEach(ev => {
    const prev = state[ev.m.key] || { date: '', severity: 'OK' }, sameDay = prev.date === today;
    let status = null;
    if (ev.severity !== 'OK') { if (!sameDay || prev.severity === 'OK') status = 'NEW'; else if (rank_(ev.severity) > rank_(prev.severity)) status = 'ESCALATED'; }
    else if (sameDay && prev.severity !== 'OK') status = 'RECOVERED';
    if (status) { events.push(Object.assign({ status: status, hints: ev.m.kind === 'volume' ? hints : '' }, ev)); state[ev.m.key] = { date: today, severity: ev.severity }; }
  });

  writeSnapshot_(ss, now, today, hour, dow, cid, evals);
  writeBaseline_(ss, dow, hour, evals);
  if (events.length) {
    const delivered = notify_(cfg, cid, currency, today, hour, events, ss.getUrl());
    if (delivered) appendAlerts_(ss, now, today, hour, cid, currency, events);
    else events.forEach(e => { if (e.status !== 'RECOVERED') delete state[e.m.key]; });  // nothing delivered: no log row, state rolled back → same events next hour
  }
  writeState_(ss, state);
  seal_(ss, t_('tab.alerts'));
  Logger.log(`${today} ${hour}:00 — ${samples.length} samples, ${alerting.length} anomalies, ${events.length} events.`);
}

function classify_(ev, cfg, raw, convBaseline, hour, isException, snapBasis) {
  const m = ev.m;
  if (isException) return 'OK';
  if (m.conv && !snapBasis && convBaseline < cfg.min_conversions_baseline) return 'OK';   // too few baseline conversions: neither rates nor conv/value volumes are judged
  if (m.kind === 'rate') {
    if (hour < cfg.quiet_hours_end) return 'OK';
    if (raw.impressions < cfg.min_impressions || raw.clicks < cfg.min_clicks) return 'OK';
  }
  const dir = ev.observed > ev.expected ? 'high' : 'low';
  if (m.bad !== 'both' && m.bad !== dir) return 'OK';
  let sev = 'OK';
  if (ev.expected === 0 || !isFinite(ev.pct)) {                       // zero baseline: absolute floor rule
    const floor = cfg.abs_floor[m.key];
    if (floor !== undefined && ev.observed >= floor && dir === 'high') sev = 'WARNING';
  } else {
    if (Math.abs(ev.pct) < cfg.min_pct_deviation) return 'OK';
    if (!isFinite(ev.z)) sev = Math.abs(ev.pct) >= 0.5 ? 'WARNING' : 'OK';   // constant baseline (scale 0)
    else { const az = Math.abs(ev.z); sev = az >= cfg.z_critical ? 'CRITICAL' : az >= cfg.z_warning ? 'WARNING' : 'OK'; }
  }
  if (m.conv && !snapBasis && rank_(sev) > rank_(cfg.conversion_metrics_max_severity)) sev = cfg.conversion_metrics_max_severity;
  return sev;
}
function mag_(e) { return isFinite(e.z) ? Math.abs(e.z) : isFinite(e.pct) ? Math.abs(e.pct) : 0; }
function rank_(s) { return s === 'CRITICAL' ? 2 : s === 'WARNING' ? 1 : 0; }
function sumUpTo_(rows, hour) { return rows.filter(r => parseInt(r.segments.hour, 10) < hour).reduce((a, r) => addM_(a, rawOf_(r)), zeroM_()); }

function campaignHints_(key, today, dow, start, yesterday, hour, exceptions, topN) {
  try {
    const cur = {}, past = {}, names = {}, dates = new Set();
    query_(`SELECT campaign.id, campaign.name, segments.date, segments.hour, segments.day_of_week, ${RAW} FROM campaign WHERE segments.date = '${today}'`).forEach(r => { if (parseInt(r.segments.hour, 10) >= hour) return; const id = r.campaign.id; names[id] = r.campaign.name; cur[id] = addM_(cur[id] || zeroM_(), rawOf_(r)); });
    query_(`SELECT campaign.id, campaign.name, segments.date, segments.hour, segments.day_of_week, ${RAW} FROM campaign WHERE segments.day_of_week = '${dow}' AND segments.date BETWEEN '${start}' AND '${yesterday}'`).forEach(r => {
      if (exceptions.has(r.segments.date) || parseInt(r.segments.hour, 10) >= hour) return; const id = r.campaign.id; names[id] = r.campaign.name; dates.add(r.segments.date); past[id] = addM_(past[id] || zeroM_(), rawOf_(r)); });
    const weeks = Math.max(1, dates.size), ids = new Set([...Object.keys(cur), ...Object.keys(past)]), rows = [];
    ids.forEach(id => { const c = derive_(cur[id] || zeroM_())[key], p = derive_(past[id] || zeroM_())[key] / weeks; if (isFinite(c) || isFinite(p)) rows.push({ name: names[id] || id, c: c || 0, p: p || 0 }); });
    rows.sort((a, b) => Math.abs(b.c - b.p) - Math.abs(a.c - a.p));
    return rows.slice(0, topN).map(r => `${r.name}: ${fmtNum_(r.c)} vs ~${fmtNum_(r.p)}`).join(' | ');
  } catch (e) { Logger.log('Campaign hints skipped: ' + e); return ''; }
}

/* ---- sheets ---- */
function readState_(ss, tz) {
  const sh = ss.getSheetByName(TECH.state), st = {};
  if (sh.getLastRow() < 2) return st;
  sh.getRange(2, 1, sh.getLastRow() - 1, 3).getValues().forEach(([m, d, s]) => { if (m) st[m] = { date: dateStrOf_(d, tz), severity: String(s) }; });
  return st;
}
function writeState_(ss, state) {
  replaceRows_(ss.getSheetByName(TECH.state), ['metric', 'date', 'severity'], Object.keys(state).map(k => [k, state[k].date, state[k].severity]), ['@', '@', '@']);
}
function snapshotHeader_() { return ['timestamp', 'date', 'hour', 'weekday', 'account'].concat(METRICS.reduce((a, m) => a.concat([m.key + '_obs', m.key + '_exp', m.key + '_z', m.key + '_sev']), [])); }
function writeSnapshot_(ss, now, today, hour, dow, cid, evals) {
  const sh = ensureSchema_(ss, TECH.snapshot, snapshotHeader_()), head = snapshotHeader_();
  ensureSize_(sh, 2, head.length);
  if (sh.getLastRow() === 0) { styleHeader_(sh.getRange(1, 1, 1, head.length).setValues([head])); sh.setFrozenRows(1); }
  const row = [now, today, hour, dow, cid]; evals.forEach(e => row.push(nz_(e.observed), nz_(e.expected), nz_(e.z), e.severity));
  appendRows_(sh, [row], [null, '@', '0', '@', '@']);
  const keep = Math.max(1, cfg_keep_()), extra = sh.getLastRow() - 1 - keep;
  if (extra > 0) sh.deleteRows(2, extra);   // oldest rows first; baseline needs only the last `weeks`
}
function cfg_keep_() { return (CONFIG.snapshot_keep_weeks || 26) * 7 * 24; }
/* Observed values at this weekday+hour on past dates → baseline that already includes conversion lag. */
function readSnapshotBaseline_(ss, dow, hour, tz, exceptions) {
  const sh = ss.getSheetByName(TECH.snapshot), out = {};
  if (sh.getLastRow() < 2) return out;
  const head = sh.getRange(1, 1, 1, sh.getLastColumn()).getValues()[0].map(String);
  const iD = head.indexOf('date'), iH = head.indexOf('hour'), iW = head.indexOf('weekday');
  const seen = new Set();
  sh.getRange(2, 1, sh.getLastRow() - 1, sh.getLastColumn()).getValues().forEach(r => {
    const d = dateStrOf_(r[iD], tz);
    if (String(r[iW]) !== dow || parseInt(r[iH], 10) !== hour || exceptions.has(d) || seen.has(d)) return;
    seen.add(d);
    METRICS.forEach(m => { const i = head.indexOf(m.key + '_obs'); if (i >= 0 && r[i] !== '' && isFinite(r[i])) (out[m.key] = out[m.key] || []).push(Number(r[i])); });
  });
  return out;
}
function writeBaseline_(ss, dow, hour, evals) {
  replaceRows_(ss.getSheetByName(TECH.baseline), ['weekday', 'hour', 'metric', 'median', 'mad', 'samples', 'basis', 'updated'],
    evals.map(e => [dow, hour, e.m.key, nz_(e.expected), nz_(e.mad), e.n, e.basis, new Date()]), ['@', '0', '@']);
}
function alertsHead_() {
  return [t_('col.timestamp'), t_('col.date'), t_('col.hour'), t_('col.account'), t_('col.metric'), t_('col.status'), t_('col.severity'),
    t_('col.observed'), t_('col.expected'), t_('col.deviation'), 'z', t_('col.samples'), t_('col.currency'), t_('col.hints')];   // 14 columns — dashboard contract, do not widen
}
function appendAlerts_(ss, now, today, hour, cid, currency, events) {
  const sh = ss.getSheetByName(t_('tab.alerts'));
  ensureTitledHeader_(sh, t_('title'), alertsHead_());
  /* status / severity are written as API-style codes (NEW / CRITICAL …) in every language so dashboards can filter on them; labels are localised in e-mail and Slack only. */
  appendRows_(sh, events.map(e => [now, today, hour, cid, t_('m.' + e.m.key), e.status, e.severity, nz_(e.observed), nz_(e.expected), nz_(e.pct), nz_(e.z), e.n, currency, e.hints || '']),
    [null, '@', '0', '@', null, null, null, null, null, '0.0%', '0.0']);
  const r0 = sh.getLastRow() - events.length + 1;
  events.forEach((e, i) => { if (e.delivered) sh.getRange(r0 + i, 6).setNote(t_('col.delivered') + ': ' + e.delivered); });   // delivery info as a cell note: schema stays 14 columns
}
function notify_(cfg, cid, currency, today, hour, events, url) {
  const worst = events.reduce((w, e) => rank_(e.severity) > rank_(w) ? e.severity : w, 'OK');
  const allRecovered = events.every(e => e.status === 'RECOVERED');
  const subject = allRecovered ? t_('mail.subject_recovered', { cid: cid, date: today, hour: hour })
                               : (worst === 'CRITICAL' ? '🔴 ' : '🟠 ') + t_('mail.subject', { cid: cid, sev: t_('sev.' + worst), date: today, hour: hour });
  const line = e => `${t_('status.' + e.status)} · ${t_('m.' + e.m.key)}: ${fmtNum_(e.observed)} vs ${fmtNum_(e.expected)} ${isFinite(e.pct) ? (e.pct >= 0 ? '▲' : '▼') + ' ' + fmtPct_(Math.abs(e.pct), 0) : ''} (z ${isFinite(e.z) ? e.z.toFixed(1) : '–'}, ${t_('sev.' + e.severity)})` + (e.hints ? `\n    ↳ ${e.hints}` : '');
  const rows = events.map(e => '<tr><td style="padding:6px 10px;font-weight:bold;color:' + (e.status === 'RECOVERED' ? '#15803D' : e.severity === 'CRITICAL' ? '#B91C1C' : '#D97706') + '">' + h_(t_('status.' + e.status)) +
    '</td><td style="padding:6px 10px">' + h_(t_('m.' + e.m.key)) + '</td><td style="padding:6px 10px;text-align:right">' + fmtNum_(e.observed) + '</td><td style="padding:6px 10px;text-align:right;color:#64748B">' + fmtNum_(e.expected) +
    '</td><td style="padding:6px 10px;text-align:right">' + (isFinite(e.pct) ? fmtPct_(e.pct, 0) : '') + '</td></tr>' + (e.hints ? '<tr><td></td><td colspan="4" style="padding:0 10px 8px;color:#64748B;font-size:11px">↳ ' + h_(e.hints) + '</td></tr>' : '')).join('');
  const body = '<table style="border-collapse:collapse;width:100%">' + rows + '</table>';
  const html = htmlShell_(t_('title') + ' · ' + cid, t_('mail.sub', { date: today, hour: hour, currency: currency }), body, url);
  const a = sendMail_(cfg.email, subject, html, events.map(line).join('\n'));
  const b = sendSlack_(cfg.slack_webhook, `*${slackText_(subject)}*\n` + events.map(e => '• ' + slackText_(line(e))).join('\n'));
  const wanted = [cfg.email ? 'mail' : null, cfg.slack_webhook ? 'slack' : null].filter(Boolean), got = [a ? 'mail' : null, b ? 'slack' : null].filter(Boolean);
  if (got.length < wanted.length) Logger.log('Partial delivery: ' + got.join('+') + ' of ' + wanted.join('+'));
  events.forEach(e => e.delivered = wanted.length ? got.join('+') + (got.length < wanted.length ? ' (' + wanted.filter(w => got.indexOf(w) < 0).join('+') + ' failed)' : '') : 'none configured');
  return a || b || !wanted.length;
}
3 langsSchedule: Hourly

Weekly creative report: ad-level metrics for two periods, RSA asset labels, pinning, ad strength, approval status, landing-page economics and a prioritised fix list.

// creative-lens — EN build 2026-09-14
// Copyright 2026 Gökhan Güzel — gokhanguzel.com. All Rights Reserved. Generated file: edit src/ and i18n/, then run build.js.
// Do not share configured Slack webhook URLs.
'use strict';
const SCRIPT_ID = "creative-lens";
const L = {"lang":"en","locale":"en-US","text_lang":"en","dec":".","thou":","};
const ATTRIBUTION = String.fromCharCode(169,32,103,111,107,104,97,110,103,117,122,101,108,46,99,111,109);
const T = {"kpi":"KPI","kpi.now":"This period","kpi.prev":"Previous","yes":"yes","no":"no","mail.full_report":"Full report","mail.open_report":"Open report","note.no_rows":"No rows.","note.truncated":"Showing {shown} of {total} rows (max_rows_written).","note.no_campaigns":"No campaigns with impressions match the current filters.","settings.key":"Key","settings.value":"Value","settings.notes":"Notes","settings.script":"script that owns this Sheet (do not edit)","settings.account":"Google Ads account that owns this Sheet (do not edit)","note.truncated_queries":"⚠ {n} quer(y/ies) hit the read limit — results incomplete; narrow the window","note.account_neg_failed":"⚠ account-level negatives could not be read:","note.skipped_time":"skipped — execution time budget","note.data_error":"data error:","note.failed":"FAILED —","note.no_final_url":"(no final URL)","alias.settings":["Settings","Ayarlar","Einstellungen"],"alias.history":["History","Kronoloji","Verlauf","Runs","Calismalar","Laeufe"],"alias.snapshot":["Snapshot","Snapshots","Kesit","Kesitler","Momentaufnahme","Momentaufnahmen"],"alias.state":["State","DurumBellegi","Zustand"],"alias.baseline":["Baseline","TemelCizgi","Basislinie"],"m.impressions":"Impr.","m.clicks":"Clicks","m.ctr":"CTR","m.cost":"Cost","m.cpc":"Avg. CPC","m.conversions":"Conv.","m.value":"Conv. value","m.cr":"Conv. rate","m.cpa":"CPA","m.roas":"ROAS","m.aov":"AOV","m.vpc":"Value / click","col.account":"Account","col.period":"Period","col.currency":"Currency","col.date":"Date","col.campaign":"Campaign","col.ad_group":"Ad group","col.n":"N","col.phrase":"Phrase","col.queries":"Queries","col.source":"Source","col.why":"Why","title":"Creative Lens — weekly creative report","tab.summary":"Summary","tab.ads":"Ads","tab.assets":"Assets","tab.headlines":"Headlines","tab.urls":"Final URLs","alias.summary":["Özet","Zusammenfassung"],"alias.ads":["Reklamlar","Anzeigen"],"alias.assets":["Öğeler","Metinler"],"alias.headlines":["Başlıklar","Überschriften"],"alias.urls":["Son URL’ler","Finale URLs","Son URL Listesi"],"col.channel":"Channel","col.ad_id":"Ad ID","col.ad_name":"Ad name","col.type":"Type","col.status":"Status","col.strength":"Ad strength","col.approval":"Approval","col.final_url":"Final URL","col.prev_impressions":"Prev impr.","col.prev_clicks":"Prev clicks","col.prev_ctr":"Prev CTR","col.prev_cost":"Prev cost","col.prev_conversions":"Prev conv.","col.wow_impressions":"Impr. WoW","col.wow_ctr":"CTR WoW","col.wow_cost":"Cost WoW","col.wow_conversions":"Conv. WoW","col.field":"Field","col.asset_text":"Asset text","col.label":"Performance label","col.pinned":"Pinned","col.enabled":"Enabled","col.ads_using":"Ads using it","col.dominant_label":"Dominant label","col.times_pinned":"Times pinned","col.ads":"Ads","col.assets":"Assets","col.shared_urls":"Shared (not counted)","col.where":"Where","col.what":"What","col.cost_in_period":"Cost in period","sum.ads":"Ads with impressions","sum.assets":"RSA assets with impressions","sum.strength":"Ad strength","sum.labels":"Asset label","sum.health":"Health","sum.disapproved":"Disapproved ads","sum.limited":"Limited ads","sum.poor":"Ads rated POOR / AVERAGE","sum.low":"LOW-rated assets","sum.pinned":"Pinned assets","sum.top_headlines":"Top headlines by CTR (≥ {n} impressions)","fix.title":"Fix list — do these this week","fix.none":"Nothing to fix — nice week.","fix.disapproved":"Disapproved ad","fix.disapproved_what":"Ad {id} is disapproved","fix.no_conv":"Spend, no conversions","fix.no_conv_what":"Ad {id}: {clicks} clicks, {cost} spent, 0 conv.","fix.poor":"Poor ad strength","fix.poor_what":"Ad {id} rated POOR with {impr} impressions","fix.low_assets":"Replace LOW assets","fix.low_assets_what":"Ad {id}: {n} LOW-rated assets, e.g. {ex}","fix.ctr_drop":"CTR drop WoW","fix.ctr_drop_what":"Ad {id}: CTR {now} vs {prev} last period","fix.pinned_low":"Pinned but LOW","fix.pinned_low_what":"\"{text}\" pinned to {pos} yet rated LOW","mail.subject":"Creative Lens · {cid} · {period} · {n} fixes","mail.sub":"{period} vs previous {days} days · {currency}","mail.health":"{dis} disapproved · {poor} ads POOR/AVERAGE · {low} LOW assets"};

/* ===================== common/core ===================== */
/* Pure helpers. No Sheets, no network. Everything here is unit-tested in test/run.js. */

/* AdsApp.search is report-backed and is not subject to selector entity limits. Keep a defensive count check only for
   runtimes/iterators that expose totalNumEntities(); normally this stays at zero. */
let TRUNCATED_ = 0;
function query_(gaql) {
  const out = [];
  const it = AdsApp.search(gaql);
  while (it.hasNext()) out.push(it.next());
  try { if (typeof it.totalNumEntities === 'function') { const n = it.totalNumEntities(); if (n > out.length) { TRUNCATED_++; Logger.log(`Query truncated: ${out.length} of ${n} rows read — narrow the window or filters. ${gaql.slice(0, 120)}`); } } } catch (e) {}
  return out;
}
/* Seconds left in the 30-minute execution window (Infinity when the runtime does not expose it, e.g. in the mock). */
function timeLeft_() { try { return AdsApp.getExecutionInfo().getRemainingTime(); } catch (e) { return Infinity; } }
function num_(v) { const x = parseFloat(v); return isFinite(x) ? x : 0; }
function nz_(v) { return isFinite(v) ? v : ''; }
function esc_(s) { return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'"); }
function escLike_(s) { const m = { '[': '[[]', ']': '[]]', '%': '[%]', '_': '[_]' }; return esc_(s).replace(/[\[\]%_]/g, c => m[c]); }
function chunks_(arr, n) { const o = []; for (let i = 0; i < arr.length; i += n) o.push(arr.slice(i, i + n)); return o; }
function pct_(a, b) { return (typeof a === 'number' && typeof b === 'number' && isFinite(a) && isFinite(b) && b) ? a / b - 1 : NaN; }

/* Number formatting for e-mail / Slack. Deterministic: separators come from the build (L.dec / L.thou), not from
   the runtime's ICU data. */
function fmtNum_(v, digits) {
  if (typeof v !== 'number' || !isFinite(v)) return '–';
  const d = digits === undefined ? (Math.abs(v) >= 1000 ? 0 : Math.abs(v) < 1 ? 3 : 2) : digits;
  const parts = Math.abs(v).toFixed(d).split('.');
  return (v < 0 ? '-' : '') + parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, L.thou) + (parts[1] ? L.dec + parts[1] : '');
}
function fmtPct_(v, digits) { return isFinite(v) ? fmtNum_(v * 100, digits === undefined ? 2 : digits) + '%' : '–'; }

/* Metric bag: raw sums + derived rates. NaN means "undefined", never 0. */
function zeroM_() { return { impressions: 0, clicks: 0, cost: 0, conversions: 0, value: 0 }; }
function rawOf_(r, opts) {
  const m = (r && r.metrics) || {}; opts = opts || {};
  return {
    impressions: num_(m.impressions), clicks: num_(m.clicks),
    cost: opts.noCost ? 0 : num_(m.costMicros) / 1e6,
    conversions: opts.noConv ? NaN : num_(m.conversions),
    value: opts.noConv ? NaN : num_(m.conversionsValue)
  };
}
function addM_(a, b) {
  const sumKnown = (x, y) => isFinite(x) && isFinite(y) ? x + y : NaN;
  return {
    impressions: a.impressions + b.impressions, clicks: a.clicks + b.clicks, cost: a.cost + b.cost,
    conversions: sumKnown(a.conversions, b.conversions),
    value: sumKnown(a.value, b.value)
  };
}
function derive_(o) {
  o.ctr = o.impressions ? o.clicks / o.impressions : NaN;
  o.cpc = o.clicks ? o.cost / o.clicks : NaN;
  o.cr = o.clicks && isFinite(o.conversions) ? o.conversions / o.clicks : NaN;
  o.cpa = o.conversions > 0 ? o.cost / o.conversions : NaN;
  o.roas = o.cost > 0 && isFinite(o.value) ? o.value / o.cost : NaN;
  o.aov = o.conversions > 0 ? o.value / o.conversions : NaN;
  o.vpc = o.clicks ? o.value / o.clicks : NaN;
  return o;
}

/* Text normalisation for search terms / negatives. language: 'tr' handles İ/ı. */
function normalize_(s, language) {
  s = String(s || '');
  s = language === 'tr' ? s.replace(/I/g, 'ı').replace(/İ/g, 'i').toLowerCase() : s.toLowerCase();
  return s.replace(/[+\[\]"“”'’`^*]/g, ' ')
          .replace(/[^\p{L}\p{N}\s\-\.]/gu, ' ')
          .split(/\s+/).map(w => w.replace(/^[.\-]+|[.\-]+$/g, '')).filter(Boolean).join(' ');
}
function tokens_(s, language) { const n = normalize_(s, language); return n ? n.split(' ') : []; }
function hasSubseq_(hay, needle) {
  outer: for (let i = 0; i + needle.length <= hay.length; i++) {
    for (let j = 0; j < needle.length; j++) if (hay[i + j] !== needle[j]) continue outer;
    return true;
  }
  return false;
}
/* exact = same tokens; phrase = contiguous; broad = every word present. */
function blocked_(q, negs) {
  if (!negs) return false;
  for (const n of negs) {
    if (!n.words.length) continue;
    if (n.mt === 'EXACT') { if (n.words.length === q.length && n.words.every((w, i) => w === q[i])) return true; }
    else if (n.mt === 'PHRASE') { if (hasSubseq_(q, n.words)) return true; }
    else if (n.words.every(w => q.includes(w))) return true;
  }
  return false;
}

/* Robust stats: median / MAD (σ-scaled), SD fallback. */
function median_(sorted) { const n = sorted.length; if (!n) return NaN; return n % 2 ? sorted[(n - 1) / 2] : (sorted[n / 2 - 1] + sorted[n / 2]) / 2; }
function robust_(arr) {
  if (!arr.length) return { median: NaN, mad: NaN, scale: 0 };
  const s = arr.slice().sort((a, b) => a - b), med = median_(s);
  const mad = median_(s.map(v => Math.abs(v - med)).sort((a, b) => a - b)) * 1.4826;
  let scale = mad;
  if (!(scale > 0)) { const mean = arr.reduce((x, v) => x + v, 0) / arr.length; scale = Math.sqrt(arr.reduce((x, v) => x + (v - mean) * (v - mean), 0) / Math.max(1, arr.length - 1)); }
  return { median: med, mad: mad, scale: scale };
}

/* Minimal HTML escape for anything user-generated that lands in e-mail. */
function h_(s) { return String(s === undefined || s === null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
/* Text lookup. T is injected by the build; keys are stable, values are per language. */
function t_(key, vars) {
  let s = T[key]; if (s === undefined) s = key;
  return String(s).replace(/\{(\w+)\}/g, (m, k) => (vars && vars[k] !== undefined) ? vars[k] : m);
}

/* ===================== common/dates ===================== */
/* Calendar-based date arithmetic in the account time zone. No 864e5 shifts,
   so DST transitions cannot skip or repeat a day. */
function tz_() { return AdsApp.currentAccount().getTimeZone(); }
function todayStr_(tz, base) { return Utilities.formatDate(base || new Date(), tz, 'yyyy-MM-dd'); }
function shiftDay_(dateStr, days) {
  const p = dateStr.split('-').map(Number);
  const d = new Date(Date.UTC(p[0], p[1] - 1, p[2] + days));
  return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + String(d.getUTCDate()).padStart(2, '0');
}
/* window_(days, offset): `days` complete days, ending `offset+1` days before today. offset 0 → ends yesterday. */
function window_(days, offset, tz, base) {
  const today = todayStr_(tz, base);
  const end = shiftDay_(today, -(offset + 1)), start = shiftDay_(today, -(offset + days));
  return { start: start, end: end, label: start + ' → ' + end };
}
const DOW_ = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY'];
function dowOf_(dateStr) { const p = dateStr.split('-').map(Number); return DOW_[new Date(Date.UTC(p[0], p[1] - 1, p[2])).getUTCDay()]; }
/* Sheets may hand back a Date where we wrote a 'yyyy-MM-dd' string. Always normalise on read. */
function dateStrOf_(v, tz) {
  if (v instanceof Date) return Utilities.formatDate(v, tz, 'yyyy-MM-dd');
  return String(v === null || v === undefined ? '' : v).trim();   // strings are never truncated: keys like 'yyyy-MM-dd HH:mm' or '123-456-7890' stay intact
}

/* ===================== common/manager ===================== */
/* Runs as a single account, or selects one client account when started from a manager account. */
function isManager_() { try { if (typeof AdsManagerApp === 'undefined') return false; AdsManagerApp.accounts().withLimit(1).get(); return true; } catch (e) { return false; } }
function selectAccount_(accountId) {
  if (!isManager_()) return;
  if (!accountId) throw new Error('Running from a manager account: set CONFIG.account_id (e.g. 123-456-7890).');
  const it = AdsManagerApp.accounts().withIds([String(accountId)]).get();
  if (!it.hasNext()) throw new Error('Account ' + accountId + ' is not a client of this manager account.');
  AdsManagerApp.select(it.next());
}

/* ===================== common/sheets ===================== */
/* Technical tabs are language-independent. User-facing tab names come from T. */
const TECH = { settings: '_settings', state: '_state', snapshot: '_snapshot', history: '_history', baseline: '_baseline' };

function openSpreadsheet_(url) {
  if (!url || url === 'YOUR_SPREADSHEET_URL') throw new Error('Set CONFIG.spreadsheet_url to a Google Sheet you own.');
  const ss = SpreadsheetApp.openByUrl(url);
  claimSheet_(ss);              // ownership first: nothing is written to a Sheet that belongs to another script or account
  return seal_(ss);             // every script opens its Sheet through the attribution layer
}
/* One Sheet per script. The first run records SCRIPT_ID in _settings; a different script pointed at the same Sheet stops
   before it can overwrite tabs (ngram-lens and pmax-ngram-lens share tab names). */
function claimSheet_(ss) {
  const sh = tab_(ss, TECH.settings, T['alias.settings']);
  const cid = AdsApp.currentAccount().getCustomerId();
  const kv = {}; if (sh.getLastRow() > 1) sh.getRange(2, 1, sh.getLastRow() - 1, 2).getValues().forEach(r => kv[String(r[0])] = String(r[1]));
  if (kv.script && kv.script !== SCRIPT_ID) throw new Error(`This Sheet belongs to ${kv.script}. Use a separate Sheet for ${SCRIPT_ID}.`);
  if (kv.account && kv.account !== cid) throw new Error(`This Sheet belongs to account ${kv.account}; running as ${cid}. Use a separate Sheet per account.`);
  if (!kv.script || !kv.account) ensureSettings_(ss, []);
}
/* Title in A1, header in row `top` (2). Writes only what is missing, so the attribution cell in H1 never masks an empty
   header row. Returns true when the header was (re)written. */
function ensureTitledHeader_(sheet, title, header) {
  ensureSize_(sheet, 3, header.length);
  if (String(sheet.getRange(1, 1).getValue()) === '') sheet.getRange(1, 1).setValue(title).setFontWeight('bold').setFontSize(13);
  const a2 = sheet.getRange(2, 1).getValue();
  if (String(a2) === header[0]) return false;
  if (String(a2) !== '') sheet.insertRowsBefore(2, 1);   // data written under a missing header (v2.2 layout): push it down, then add the header
  styleHeader_(sheet.getRange(2, 1, 1, header.length).setValues([header])); sheet.setFrozenRows(2);
  return true;
}
/* Find a tab by its current name or any legacy alias; rename legacy tabs once so state survives a language switch. */
function tab_(ss, name, aliases) {
  let sh = ss.getSheetByName(name);
  if (!sh && aliases) for (const a of aliases) { const old = ss.getSheetByName(a); if (old) { old.setName(name); sh = old; break; } }
  if (!sh) sh = ss.insertSheet(name);
  return sh;
}
/* specs: [{name, aliases:[...]}]. Removes the empty default tab whatever its locale name is. */
function ensureTabs_(ss, specs) {
  const wanted = new Set(specs.map(s => s.name));
  specs.forEach(s => tab_(ss, s.name, s.aliases));
  const first = ss.getSheets()[0];
  if (first && !wanted.has(first.getName()) && ss.getSheets().length > 1 && onlyAttribution_(first)) ss.deleteSheet(first);
}
/* A default tab that holds nothing but the attribution cell written by the early seal_ is still "empty". */
function onlyAttribution_(sheet) {
  if (sheet.getLastRow() === 0) return true;
  if (sheet.getLastRow() > 1) return false;
  const row = sheet.getRange(1, 1, 1, Math.max(1, sheet.getLastColumn())).getValues()[0];
  return row.every((v, i) => v === '' || (i === 7 && v === ATTRIBUTION));
}
/* Grow the grid BEFORE any getRange: Sheets throws on coordinates outside the current dimensions. */
function ensureSize_(sheet, rows, cols) {
  if (cols > sheet.getMaxColumns()) sheet.insertColumnsAfter(sheet.getMaxColumns(), cols - sheet.getMaxColumns());
  if (rows > sheet.getMaxRows()) sheet.insertRowsAfter(sheet.getMaxRows(), rows - sheet.getMaxRows());
}
function styleHeader_(rng) { return rng.setFontWeight('bold').setBackground('#1E293B').setFontColor('#ffffff'); }

/* Write a table. Rows above `top` are never touched (callers put titles there). formats[i] is a number format per column ('@' = text, applied BEFORE setValues so Sheets never auto-parses).
   Rows beyond `cap` are dropped and a note is written. Returns the number of rows written. */
function table_(sheet, top, header, rows, formats, cap) {
  let out = rows;
  if (cap && rows.length > cap) out = rows.slice(0, cap);
  ensureSize_(sheet, top + out.length + 1, header.length);
  const maxR = sheet.getMaxRows(), maxC = sheet.getMaxColumns();
  if (maxR >= top) sheet.getRange(top, 1, maxR - top + 1, maxC).clearContent();   // contents only: user notes/formats outside the table survive
  styleHeader_(sheet.getRange(top, 1, 1, header.length).setValues([header]));
  if (out.length) {
    (formats || []).forEach((f, i) => { if (f) sheet.getRange(top + 1, i + 1, out.length, 1).setNumberFormat(f); });
    sheet.getRange(top + 1, 1, out.length, header.length).setValues(out);
    if (out.length < rows.length) sheet.getRange(top + 1 + out.length, 1).setValue(t_('note.truncated', { shown: out.length, total: rows.length }));
  } else {
    sheet.getRange(top + 1, 1).setValue(t_('note.no_rows'));
  }
  sheet.setFrozenRows(top);
  return out.length;
}
/* Append rows with text formats applied first (appendRow cannot do that). */
function appendRows_(sheet, rows, formats) {
  if (!rows.length) return;
  const r0 = sheet.getLastRow() + 1, n = rows.length, w = rows[0].length;
  ensureSize_(sheet, r0 + n - 1, w);
  (formats || []).forEach((f, i) => { if (f) sheet.getRange(r0, i + 1, n, 1).setNumberFormat(f); });
  sheet.getRange(r0, 1, n, w).setValues(rows);
}
/* A technical tab whose header row differs from what this version writes (older layout) is renamed to an archive tab and a
   fresh one is created — rows of two schemas are never mixed. Returns the sheet to write to. */
function ensureSchema_(ss, name, header) {
  const sh = ss.getSheetByName(name); if (!sh || sh.getLastRow() === 0) return sh;
  const cur = sh.getRange(1, 1, 1, Math.max(1, sh.getLastColumn())).getValues()[0].map(String);
  while (cur.length && cur[cur.length - 1] === '') cur.pop();
  if (cur.join('|') === header.join('|')) return sh;
  let n = 1, archive = name + '_archive'; while (ss.getSheetByName(archive)) archive = name + '_archive' + (++n);
  sh.setName(archive); Logger.log(`${name}: old layout archived as ${archive}`);
  return ss.insertSheet(name);
}
/* Idempotent history: one row per key (first `keyCols` columns, joined). Existing row with the same key is replaced. */
function upsertRow_(sheet, header, row, keyCols, formats, tz) {
  ensureSize_(sheet, 2, Math.max(header.length, row.length));
  const cur = sheet.getLastRow() === 0 ? [] : sheet.getRange(1, 1, 1, Math.max(1, sheet.getLastColumn())).getValues()[0].map(String).filter(Boolean);
  if (cur.join('|') !== header.join('|')) {
    const w = Math.max(header.length, sheet.getLastColumn()); sheet.getRange(1, 1, 1, w).clearContent();
    styleHeader_(sheet.getRange(1, 1, 1, header.length).setValues([header])); sheet.setFrozenRows(1);
  }   // new/changed schema: clear stale cells, then rewrite header
  const key = row.slice(0, keyCols).map(v => dateStrOf_(v, tz)).join('|');
  const last = sheet.getLastRow();
  if (last > 1) {
    const keys = sheet.getRange(2, 1, last - 1, keyCols).getValues().map(r => r.map(v => dateStrOf_(v, tz)).join('|'));
    const i = keys.indexOf(key);
    if (i >= 0) {
      const w = Math.max(row.length, sheet.getLastColumn()); sheet.getRange(i + 2, 1, 1, w).clearContent();
      sheet.getRange(i + 2, 1, 1, row.length).setValues([row]); return;
    }
  }
  appendRows_(sheet, [row], formats);
}
/* Replace a whole table safely: write the new rows first, then clear whatever is left below. */
function replaceRows_(sheet, header, rows, formats) {
  const n = rows.length;
  ensureSize_(sheet, n + 1, header.length);
  if (sheet.getLastRow() === 0) styleHeader_(sheet.getRange(1, 1, 1, header.length).setValues([header]));
  if (n) {
    (formats || []).forEach((f, i) => { if (f) sheet.getRange(2, i + 1, n, 1).setNumberFormat(f); });
    sheet.getRange(2, 1, n, header.length).setValues(rows);
  }
  const last = sheet.getLastRow();
  if (last > n + 1) sheet.getRange(n + 2, 1, last - n - 1, Math.max(header.length, sheet.getLastColumn())).clearContent();
}

/* _settings: Key | Value | Notes. Keys are stable identifiers (never translated); notes are per language. */
function ensureSettings_(ss, defaults) {
  const sh = tab_(ss, TECH.settings, T['alias.settings']);
  ensureSize_(sh, 2 + defaults.length, 3);
  if (sh.getLastRow() === 0) {
    sh.getRange(1, 1, 1, 3).setNumberFormat('@').setValues([[t_('settings.key'), t_('settings.value'), t_('settings.notes')]]);
    styleHeader_(sh.getRange(1, 1, 1, 3)); sh.setFrozenRows(1); sh.setColumnWidths(1, 1, 200); sh.setColumnWidths(3, 1, 420);
  }
  const all = [['script', SCRIPT_ID, t_('settings.script')], ['account', AdsApp.currentAccount().getCustomerId(), t_('settings.account')]].concat(defaults);
  const last = sh.getLastRow();
  const keys = last > 1 ? sh.getRange(2, 1, last - 1, 1).getValues().map(r => String(r[0])) : [];
  const missing = all.filter(d => keys.indexOf(d[0]) < 0);
  if (missing.length) { const r0 = sh.getLastRow() + 1; ensureSize_(sh, r0 + missing.length, 3); sh.getRange(r0, 1, missing.length, 3).setNumberFormat('@').setValues(missing.map(d => [d[0], d[1], d[2]])); }
  if (keys.length) {   // notes follow the current language; one batched write, only when something differs; values are never touched
    const rng = sh.getRange(2, 3, keys.length, 1), cur = rng.getValues().map(r => String(r[0]));
    const want = keys.map((k, i) => { const d = all.find(x => x[0] === k); return d && d[2] ? d[2] : cur[i]; });
    if (want.join('\u0001') !== cur.join('\u0001')) rng.setValues(want.map(v => [v]));
  }
  return sh;
}
/* Reads _settings into cfg. Only keys present in `schema` are accepted; type comes from schema: 'num' | 'bool' | 'str' | 'list'. */
function readSettings_(ss, cfg, schema) {
  const out = JSON.parse(JSON.stringify(cfg));
  const sh = ss.getSheetByName(TECH.settings);
  if (!sh || sh.getLastRow() < 2) return out;
  sh.getRange(2, 1, sh.getLastRow() - 1, 2).getValues().forEach(([k, v]) => {
    const type = schema[k]; if (!type || v === '' || v === null) return;
    if (type === 'num') {
      if (typeof v === 'number') { out[k] = v; return; }
      const s = String(v).trim().replace(/\s/g, '');
      if (!/^-?\d+([.,]\d+)?$/.test(s)) throw new Error(`_settings.${k}: "${v}" is not a number (use 0.5 or 0,5)`);
      if (/^-?\d{1,3}[.,]\d{3}$/.test(s)) throw new Error(`_settings.${k}: "${v}" is ambiguous; do not use thousands separators (write 1000 or 0.5 / 0,5)`);
      out[k] = parseFloat(s.replace(',', '.'));   // decimal comma/dot accepted; ambiguous 1.000 / 1,000 is rejected
    }
    else if (type === 'bool') out[k] = /^(true|yes|1|on|evet|ja)$/i.test(String(v).trim());
    else if (type === 'list') out[k] = String(v).split(',').map(s => s.trim()).filter(Boolean);
    else out[k] = String(v).trim();
  });
  return out;
}
function writeSetting_(ss, key, value) {
  const sh = ss.getSheetByName(TECH.settings); if (!sh) return;
  const last = sh.getLastRow();
  const keys = last > 1 ? sh.getRange(2, 1, last - 1, 1).getValues().map(r => String(r[0])) : [];
  const i = keys.indexOf(key);
  const cell = i >= 0 ? sh.getRange(i + 2, 2) : sh.getRange(last + 1, 2);
  if (i < 0) sh.getRange(last + 1, 1).setNumberFormat('@').setValue(key);
  cell.setNumberFormat('@').setValue(value);
}

/* ===================== common/seal ===================== */
/* Attribution cell. Warning-only protection (no editor management → no Session dependency, no lock-out of the
   running user). Never throws: a report must not fail because a Sheet cell was edited.
   Called twice: from openSpreadsheet_ (first tab, before anything else) and at the end of main() on the report tab.
   Returns the spreadsheet so the open path depends on it. This is attribution, not DRM: anyone who edits the
   source can remove it. */
function seal_(ss, tabName) {
  try {
    const sheet = (tabName && ss.getSheetByName(tabName)) || ss.getSheets().find(s => !s.getName().startsWith('_')) || ss.getSheets()[0];
    const want = ATTRIBUTION;
    const range = sheet.getRange('H1:K1');
    let cur = ''; try { cur = String(range.getCell(1, 1).getValue()); } catch (e) {}
    if (cur !== want) {
      try { range.breakApart(); range.merge(); } catch (e) {}
      range.getCell(1, 1).setValue(want).setFontWeight('bold').setFontColor('#0F766E').setHorizontalAlignment('center').setNote(want);
    }
    const has = sheet.getProtections(SpreadsheetApp.ProtectionType.RANGE).some(p => p.getDescription() === want);
    if (!has) range.protect().setDescription(want).setWarningOnly(true);
  } catch (e) { Logger.log('Attribution skipped: ' + e); }
  return ss;
}

/* ===================== common/notify ===================== */
function htmlShell_(title, subtitle, body, url) {
  return '<div style="font:13px Arial,sans-serif;color:#1E293B;max-width:760px">' +
    '<div style="background:#1E293B;color:#fff;padding:12px 16px;font-size:15px;font-weight:bold">' + h_(title) + '</div>' +
    '<div style="padding:10px 16px;color:#64748B">' + h_(subtitle) + '</div>' + body +
    '<div style="padding:12px 16px;color:#94A3B8;font-size:11px">' + (url ? h_(t_('mail.full_report')) + ': <a href="' + h_(url) + '">' + h_(url) + '</a><br>' : '') + h_(ATTRIBUTION) + '</div></div>';
}
function kpiTable_(rows) {
  /* rows: [[label, now, prev, delta_html]] */
  const th = (s, r) => '<th style="padding:5px 10px;text-align:' + (r ? 'right' : 'left') + '">' + h_(s) + '</th>';
  return '<table style="border-collapse:collapse;width:100%"><tr style="background:#F1F5F9;color:#64748B;font-size:11px">' +
    th(t_('kpi'), false) + th(t_('kpi.now'), true) + th(t_('kpi.prev'), true) + th('Δ', true) + '</tr>' +
    rows.map(r => '<tr><td style="padding:5px 10px">' + h_(r[0]) + '</td><td style="padding:5px 10px;text-align:right;font-weight:bold">' + h_(r[1]) +
      '</td><td style="padding:5px 10px;text-align:right;color:#64748B">' + h_(r[2]) + '</td><td style="padding:5px 10px;text-align:right">' + r[3] + '</td></tr>').join('') + '</table>';
}
function delta_(a, b, invert) {
  const v = pct_(a, b); if (!isFinite(v)) return '';
  const good = invert ? v < 0 : v > 0;
  return '<span style="color:' + (good ? '#15803D' : '#B91C1C') + '">' + (v >= 0 ? '▲' : '▼') + ' ' + fmtPct_(Math.abs(v), 0) + '</span>';
}
function sendMail_(to, subject, html, text) {
  if (!to) return false;
  try { MailApp.sendEmail({ to: to, subject: subject, htmlBody: html, body: text || subject }); return true; }
  catch (e) { Logger.log('E-mail failed: ' + e); return false; }
}
function slackText_(s) { return String(s === undefined || s === null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
function sendSlack_(webhook, text) {
  if (!webhook) return false;
  try {
    const res = UrlFetchApp.fetch(webhook, { method: 'post', contentType: 'application/json', muteHttpExceptions: true, payload: JSON.stringify({ text: text + '\n_' + slackText_(ATTRIBUTION) + '_' }) });
    const ok = res.getResponseCode() === 200;
    if (!ok) Logger.log('Slack returned ' + res.getResponseCode() + ': ' + res.getContentText().slice(0, 200));
    return ok;
  } catch (e) { Logger.log('Slack failed: ' + e); return false; }
}

/* ===================== common/negatives ===================== */
/* Negative keywords by ID: ad group, campaign, shared lists attached to campaigns, and account level. */
function fetchNegatives_(campaignIds, language, opts) {
  opts = opts || {};
  const neg = { adGroup: {}, campaign: {}, account: [] };
  const item = (text, mt) => ({ words: tokens_(text, language), mt: String(mt || '').toUpperCase() });
  const push = (bucket, key, text, mt) => { (bucket[key] = bucket[key] || []).push(item(text, mt)); };
  chunks_(campaignIds, 200).forEach(chunk => {
    if (opts.adGroupLevel !== false) {
      query_(`SELECT ad_group.id, ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type
        FROM ad_group_criterion WHERE ad_group_criterion.negative = TRUE AND ad_group_criterion.type = 'KEYWORD'
        AND ad_group_criterion.status != 'REMOVED' AND campaign.id IN (${chunk.join(',')})`)
        .forEach(r => push(neg.adGroup, String(r.adGroup.id), r.adGroupCriterion.keyword.text, r.adGroupCriterion.keyword.matchType));
    }
    query_(`SELECT campaign.id, campaign_criterion.keyword.text, campaign_criterion.keyword.match_type
      FROM campaign_criterion WHERE campaign_criterion.negative = TRUE AND campaign_criterion.type = 'KEYWORD'
      AND campaign_criterion.status != 'REMOVED' AND campaign.id IN (${chunk.join(',')})`)
      .forEach(r => push(neg.campaign, String(r.campaign.id), r.campaignCriterion.keyword.text, r.campaignCriterion.keyword.matchType));
  });
  const setToCampaigns = {};
  chunks_(campaignIds, 200).forEach(chunk => {
    query_(`SELECT campaign.id, shared_set.id FROM campaign_shared_set
      WHERE shared_set.type = 'NEGATIVE_KEYWORDS' AND campaign_shared_set.status = 'ENABLED' AND shared_set.status = 'ENABLED'
      AND campaign.id IN (${chunk.join(',')})`)
      .forEach(r => (setToCampaigns[String(r.sharedSet.id)] = setToCampaigns[String(r.sharedSet.id)] || []).push(String(r.campaign.id)));
  });
  const setIds = Object.keys(setToCampaigns);
  chunks_(setIds, 200).forEach(chunk => {
    query_(`SELECT shared_set.id, shared_criterion.keyword.text, shared_criterion.keyword.match_type
      FROM shared_criterion WHERE shared_criterion.type = 'KEYWORD' AND shared_set.id IN (${chunk.join(',')})`)
      .forEach(r => setToCampaigns[String(r.sharedSet.id)].forEach(cid => push(neg.campaign, cid, r.sharedCriterion.keyword.text, r.sharedCriterion.keyword.matchType)));
  });
  /* Account-level negatives are a shared set of type ACCOUNT_LEVEL_NEGATIVE_KEYWORDS (customer_negative_criterion has no
     keyword field; it only points at that list). */
  try {
    query_(`SELECT shared_criterion.keyword.text, shared_criterion.keyword.match_type
      FROM shared_criterion WHERE shared_set.type = 'ACCOUNT_LEVEL_NEGATIVE_KEYWORDS' AND shared_criterion.type = 'KEYWORD' AND shared_set.status = 'ENABLED'`)
      .forEach(r => neg.account.push(item(r.sharedCriterion.keyword.text, r.sharedCriterion.keyword.matchType)));
  } catch (e) { Logger.log('Account-level negatives not readable: ' + e); neg.accountError = String(e); }
  return neg;
}
function isBlocked_(tok, neg, campaignId, adGroupId) {
  return blocked_(tok, neg.account) || blocked_(tok, neg.campaign[campaignId]) || (adGroupId ? blocked_(tok, neg.adGroup[adGroupId]) : false);
}

/* ===================== common/metrics ===================== */
/* Standard metric block for tables: header / row / number formats, all driven by T and one metric bag. */
const M_KEYS = ['impressions', 'clicks', 'ctr', 'cost', 'cpc', 'conversions', 'value', 'cr', 'cpa', 'roas'];
const M_FMT = ['#,##0', '#,##0', '0.00%', '#,##0.00', '#,##0.00', '#,##0.0', '#,##0.00', '0.00%', '#,##0.00', '0.00'];
function mHead_(keys) { return (keys || M_KEYS).map(k => t_('m.' + k)); }
function mRow_(m, keys) { return (keys || M_KEYS).map(k => nz_(m[k])); }
function mFmt_(keys) { return (keys || M_KEYS).map(k => M_FMT[M_KEYS.indexOf(k)] || null); }

/* ===================== common/ngram ===================== */
/* Shared n-gram engine for N-Gram Lens and PMax N-Gram Lens. terms: [{campaignId, campaign, adGroupId, term, source, categories?, m}] */
function ngramAnalyse_(terms, neg, opts) {
  const lang = opts.language, stop = new Set((opts.stopwords || []).map(w => normalize_(w, lang)));
  const brand = (opts.brand_terms || []).map(b => tokens_(b, lang)).filter(t => t.length);
  const acc = {}, camp = {}, cat = {}, wc = {}, totals = zeroM_(), split = { brand: zeroM_(), generic: zeroM_() };
  const blockedKeys = new Set(); let excluded = 0, queries = 0;
  const bump = (map, key, seed, m) => { const a = map[key] = map[key] || Object.assign(zeroM_(), { queries: 0 }, seed); Object.assign(a, addM_(a, m)); a.queries++; };
  terms.forEach(t => {
    const tok = tokens_(t.term, lang); if (!tok.length) return;
    if (neg && isBlocked_(tok, neg, t.campaignId, t.adGroupId)) { excluded++; blockedKeys.add(t.campaignId + '|' + t.term.toLowerCase()); return; }
    queries++; Object.assign(totals, addM_(totals, t.m));
    const isBrand = brand.length && brand.some(b => hasSubseq_(tok, b));
    const side = isBrand ? split.brand : split.generic; Object.assign(side, addM_(side, t.m)); side.queries = (side.queries || 0) + 1;
    bump(wc, tok.length > 6 ? '7+' : String(tok.length), { words: tok.length > 6 ? '7+' : String(tok.length) }, t.m);
    const seen = new Set();
    for (let n = opts.min_ngram; n <= opts.max_ngram && n <= tok.length; n++) for (let i = 0; i + n <= tok.length; i++) {
      const words = tok.slice(i, i + n); if (n === 1 && stop.has(words[0])) continue;
      const g = words.join(' '), k = n + '|' + g; if (seen.has(k)) continue; seen.add(k);
      bump(acc, k, { n: n, gram: g }, t.m);
      bump(camp, t.campaignId + '|' + k, { n: n, gram: g, campaign: t.campaign, source: t.source }, t.m);
      (t.categories || []).forEach(c => bump(cat, t.campaignId + '|' + c + '|' + k, { n: n, gram: g, campaign: t.campaign, category: c }, t.m));
    }
  });
  derive_(totals); derive_(split.brand); derive_(split.generic);
  const th = opts.thresholds, keep = a => a.queries >= th.query_count && a.impressions >= th.impressions && a.clicks >= th.clicks && (a.cost || 0) >= (th.cost || 0);
  const rank = opts.rankBy === 'clicks' ? ((x, y) => y.clicks - x.clicks || y.impressions - x.impressions || x.gram.localeCompare(y.gram))
                                        : ((x, y) => y.cost - x.cost || y.impressions - x.impressions || x.gram.localeCompare(y.gram));
  return { queries, excluded, totals, split, blockedKeys,
    account: Object.values(acc).map(derive_).filter(keep).sort(rank),
    campaign: Object.values(camp).map(derive_).filter(keep).sort((x, y) => x.campaign.localeCompare(y.campaign) || rank(x, y)),
    category: Object.values(cat).map(derive_).filter(keep).sort((x, y) => x.campaign.localeCompare(y.campaign) || x.category.localeCompare(y.category) || rank(x, y)),
    wordCount: ['1', '2', '3', '4', '5', '6', '7+'].map(k => derive_(Object.assign({ words: k, queries: 0 }, zeroM_(), wc[k] || {}))) };
}
const NG_KEYS = ['queries'].concat(M_KEYS);
function ngHead_(keys) { return (keys || NG_KEYS).map(k => k === 'queries' ? t_('col.queries') : t_('m.' + k)); }
function ngFmt_(keys) { return (keys || NG_KEYS).map(k => k === 'queries' ? '#,##0' : M_FMT[M_KEYS.indexOf(k)] || null); }
function ngRow_(a, keys) { return (keys || NG_KEYS).map(k => nz_(a[k])); }
function writeNgrams_(ss, tabKey, rows, segKeys, keys, cap) {
  const head = segKeys.map(k => t_('col.' + k)).concat([t_('col.n'), t_('col.phrase')]).concat(ngHead_(keys));
  const fmt = segKeys.map(() => '@').concat(['0', '@']).concat(ngFmt_(keys));
  table_(ss.getSheetByName(t_('tab.' + tabKey)), 1, head, rows.map(r => segKeys.map(k => r[k]).concat([r.n, r.gram]).concat(ngRow_(r, keys))), fmt, cap);
}


/**
 * @name Creative Lens
 * @overview Weekly creative report: ad-level metrics for two periods (previous-period totals include paused ads and
 *   ads that stopped serving; REMOVED ads are excluded by the query), RSA asset performance labels, pinning, ad strength, approval, landing-page economics,
 *   a prioritised fix list, a persistent history row per period and an e-mail / Slack digest.
 * Schedule: Weekly, Monday, 04:00 account time or later.
 */
const CONFIG = {
  spreadsheet_url: 'YOUR_SPREADSHEET_URL',
  email: '', slack_webhook: '', account_id: '',
  days: 7,
  campaign_filter: '', channel_types: [],   // e.g. ['SEARCH']
  min_impressions_for_ranking: 200,
  min_clicks_no_conv: 30,
  cost_no_conv_threshold: 0,
  fix_list_size: 10,
  max_rows_written: 20000
};
const STRENGTH = ['EXCELLENT', 'GOOD', 'AVERAGE', 'POOR', 'PENDING', 'NO_ADS', 'UNSPECIFIED'];
const LABELS = ['BEST', 'GOOD', 'LOW', 'LEARNING', 'PENDING', 'UNRATED'];

function main() {
  selectAccount_(CONFIG.account_id);
  const ss = openSpreadsheet_(CONFIG.spreadsheet_url);
  const tz = tz_(), cid = AdsApp.currentAccount().getCustomerId(), currency = AdsApp.currentAccount().getCurrencyCode();
  ss.setSpreadsheetTimeZone(tz);
  const tabs = ['summary', 'ads', 'assets', 'headlines', 'urls'].map(k => ({ name: t_('tab.' + k), aliases: T['alias.' + k] }));
  ensureTabs_(ss, tabs.concat([{ name: TECH.history, aliases: T['alias.history'] }]));
  const cur = window_(CONFIG.days, 0, tz), prev = window_(CONFIG.days, CONFIG.days, tz), where = filter_();

  const adsCur = fetchAds_(cur, where), adsPrev = fetchAds_(prev, where);
  const prevByKey = {}; adsPrev.forEach(a => prevByKey[a.key] = a.m);
  adsCur.forEach(a => a.p = prevByKey[a.key] || derive_(zeroM_()));
  const assets = fetchAssets_(cur, where);
  const s = summarize_(adsCur, adsPrev, assets);
  const fixes = fixList_(adsCur, assets);

  writeAds_(ss, adsCur); writeAssets_(ss, assets); writeHeadlines_(ss, assets); writeUrls_(ss, adsCur);
  writeSummary_(ss, cid, currency, cur, prev, s, fixes);
  upsertRow_(ss.getSheetByName(TECH.history), ['period_start', 'period_end', 'account', 'ads', 'assets', 'impressions', 'clicks', 'ctr', 'cost', 'conversions', 'value', 'cpa', 'roas',
    'excellent', 'good', 'average', 'poor', 'best_assets', 'good_assets', 'low_assets', 'disapproved'],
    [cur.start, cur.end, cid, s.ads, s.assets, s.tot.impressions, s.tot.clicks, nz_(s.tot.ctr), s.tot.cost, nz_(s.tot.conversions), nz_(s.tot.value), nz_(s.tot.cpa), nz_(s.tot.roas),
     s.strength.EXCELLENT || 0, s.strength.GOOD || 0, s.strength.AVERAGE || 0, s.strength.POOR || 0, s.labels.BEST || 0, s.labels.GOOD || 0, s.labels.LOW || 0, s.disapproved], 3, ['@', '@', '@'], tz);
  notify_(cid, currency, cur, s, fixes, ss.getUrl());
  seal_(ss, t_('tab.summary'));
  Logger.log(`${cur.label}: ${adsCur.length} ads, ${assets.length} assets, ${fixes.length} fixes.`);
}
function filter_() {
  let w = '';
  if (CONFIG.campaign_filter) w += ` AND campaign.name LIKE '%${escLike_(CONFIG.campaign_filter)}%'`;
  if (CONFIG.channel_types.length) w += ` AND campaign.advertising_channel_type IN (${CONFIG.channel_types.map(t => `'${esc_(t)}'`).join(',')})`;
  return w;
}
function fetchAds_(win, where) {
  return query_(`SELECT campaign.id, campaign.name, campaign.advertising_channel_type, ad_group.id, ad_group.name,
      ad_group_ad.ad.id, ad_group_ad.ad.name, ad_group_ad.ad.type, ad_group_ad.status, ad_group_ad.ad_strength,
      ad_group_ad.policy_summary.approval_status, ad_group_ad.ad.final_urls,
      metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions, metrics.conversions_value
    FROM ad_group_ad WHERE segments.date BETWEEN '${win.start}' AND '${win.end}' AND metrics.impressions > 0 AND ad_group_ad.status != 'REMOVED'${where}`)
    .map(r => ({
      key: `${r.adGroup.id}:${r.adGroupAd.ad.id}`, campaign: r.campaign.name, channel: r.campaign.advertisingChannelType,
      adGroup: r.adGroup.name, adId: String(r.adGroupAd.ad.id), adName: r.adGroupAd.ad.name || '', type: r.adGroupAd.ad.type, status: r.adGroupAd.status,
      strength: r.adGroupAd.adStrength || 'UNSPECIFIED', approval: (r.adGroupAd.policySummary && r.adGroupAd.policySummary.approvalStatus) || '',
      urls: (r.adGroupAd.ad.finalUrls || []).slice(), m: derive_(rawOf_(r))
    }));
}
/* Asset-level rows. Conversions fall back only when the API says the field is not selectable — any other error propagates. */
function fetchAssets_(win, where) {
  const base = `SELECT campaign.name, ad_group.name, ad_group_ad.ad.id, ad_group_ad_asset_view.field_type, ad_group_ad_asset_view.performance_label,
      ad_group_ad_asset_view.pinned_field, ad_group_ad_asset_view.enabled, asset.id, asset.text_asset.text, metrics.impressions, metrics.clicks, metrics.cost_micros`;
  const tail = ` FROM ad_group_ad_asset_view WHERE segments.date BETWEEN '${win.start}' AND '${win.end}'
      AND ad_group_ad_asset_view.field_type IN ('HEADLINE','DESCRIPTION') AND metrics.impressions > 0${where}`;
  let rows, hasConv = true;
  try { rows = query_(base + ', metrics.conversions, metrics.conversions_value' + tail); }
  catch (e) { if (!/not selectable|cannot be selected|cannot select|PROHIBITED|not supported|invalid.*metrics\.conversions/i.test(String(e))) throw e; hasConv = false; rows = query_(base + tail); Logger.log('Asset-level conversions not available: ' + e); }
  return rows.map(r => {
    const v = r.adGroupAdAssetView || {}, pin = String(v.pinnedField || '');
    return { campaign: r.campaign.name, adGroup: r.adGroup.name, adId: String(r.adGroupAd.ad.id), assetId: String(r.asset.id),
      text: (r.asset.textAsset && r.asset.textAsset.text) || '', field: v.fieldType, label: v.performanceLabel || 'UNRATED',
      pinned: /^(HEADLINE|DESCRIPTION)_\d$/.test(pin) ? pin : '', enabled: v.enabled !== false, m: derive_(rawOf_(r, { noConv: !hasConv })) };
  });
}
function summarize_(ads, adsPrev, assets) {
  const tot = derive_(ads.reduce((s, a) => addM_(s, a.m), zeroM_())), totPrev = derive_(adsPrev.reduce((s, a) => addM_(s, a.m), zeroM_()));
  const strength = {}, labels = {};
  ads.forEach(a => strength[a.strength] = (strength[a.strength] || 0) + 1);
  assets.forEach(a => labels[a.label] = (labels[a.label] || 0) + 1);
  const rated = assets.filter(a => a.enabled && a.m.impressions >= CONFIG.min_impressions_for_ranking && isFinite(a.m.ctr));   // enabled=false = no longer in the ad's current version
  return { ads: ads.length, assets: assets.length, tot, totPrev, strength, labels,
    disapproved: ads.filter(a => /DISAPPROVED/.test(a.approval)).length, limited: ads.filter(a => /LIMITED/.test(a.approval)).length,
    poor: (strength.POOR || 0) + (strength.AVERAGE || 0), lowAssets: assets.filter(a => a.label === 'LOW').length, pinnedAssets: assets.filter(a => a.pinned).length,
    topHeadlines: rated.filter(a => a.field === 'HEADLINE').sort((x, y) => y.m.ctr - x.m.ctr).slice(0, 5) };
}
function fixList_(ads, assets) {
  const out = [], where = a => `${a.campaign} › ${a.adGroup}`;
  ads.filter(a => /DISAPPROVED/.test(a.approval)).forEach(a => out.push({ p: 1, type: t_('fix.disapproved'), where: where(a), what: t_('fix.disapproved_what', { id: a.adId }), cost: a.m.cost }));
  ads.filter(a => a.m.cost > CONFIG.cost_no_conv_threshold && a.m.clicks >= CONFIG.min_clicks_no_conv && a.m.conversions === 0).forEach(a =>
    out.push({ p: 2, type: t_('fix.no_conv'), where: where(a), what: t_('fix.no_conv_what', { id: a.adId, clicks: a.m.clicks, cost: fmtNum_(a.m.cost, 2) }), cost: a.m.cost }));
  ads.filter(a => a.strength === 'POOR' && a.m.impressions >= CONFIG.min_impressions_for_ranking).forEach(a =>
    out.push({ p: 3, type: t_('fix.poor'), where: where(a), what: t_('fix.poor_what', { id: a.adId, impr: fmtNum_(a.m.impressions, 0) }), cost: a.m.cost }));
  const low = {};
  assets.filter(a => a.enabled && a.label === 'LOW').forEach(a => { const l = low[a.adId] = low[a.adId] || { n: 0, a: a, ex: [] }; l.n++; if (l.ex.length < 2) l.ex.push(a.text); });
  Object.keys(low).forEach(id => { const l = low[id]; if (l.n >= 2) out.push({ p: 4, type: t_('fix.low_assets'), where: where(l.a), what: t_('fix.low_assets_what', { id: id, n: l.n, ex: l.ex.join(' / ') }), cost: 0 }); });
  ads.filter(a => a.p.impressions > 0 && a.m.impressions >= CONFIG.min_impressions_for_ranking && a.p.ctr > 0 && a.m.ctr < a.p.ctr * 0.7).forEach(a =>
    out.push({ p: 5, type: t_('fix.ctr_drop'), where: where(a), what: t_('fix.ctr_drop_what', { id: a.adId, now: fmtPct_(a.m.ctr), prev: fmtPct_(a.p.ctr) }), cost: a.m.cost }));
  assets.filter(a => a.enabled && a.pinned && a.label === 'LOW').forEach(a => out.push({ p: 6, type: t_('fix.pinned_low'), where: where(a), what: t_('fix.pinned_low_what', { text: a.text, pos: a.pinned }), cost: a.m.cost }));
  return out.sort((x, y) => x.p - y.p || y.cost - x.cost).slice(0, CONFIG.fix_list_size);
}
/* ---- tabs ---- */
function writeAds_(ss, ads) {
  const head = ['campaign', 'channel', 'ad_group', 'ad_id', 'ad_name', 'type', 'status', 'strength', 'approval', 'final_url'].map(k => t_('col.' + k))
    .concat(mHead_()).concat(['prev_impressions', 'prev_clicks', 'prev_ctr', 'prev_cost', 'prev_conversions', 'wow_impressions', 'wow_ctr', 'wow_cost', 'wow_conversions'].map(k => t_('col.' + k)));
  const rows = ads.slice().sort((x, y) => y.m.cost - x.m.cost).map(a => [a.campaign, a.channel, a.adGroup, a.adId, a.adName, a.type, a.status, a.strength, a.approval, a.urls[0] || '']
    .concat(mRow_(a.m)).concat([a.p.impressions, a.p.clicks, nz_(a.p.ctr), a.p.cost, nz_(a.p.conversions), nz_(pct_(a.m.impressions, a.p.impressions)), nz_(pct_(a.m.ctr, a.p.ctr)), nz_(pct_(a.m.cost, a.p.cost)), nz_(pct_(a.m.conversions, a.p.conversions))]));
  const fm = ['@', '@', '@', '@', '@', '@', '@', '@', '@', '@'].concat(mFmt_()).concat(['#,##0', '#,##0', '0.00%', '#,##0.00', '#,##0.0', '+0%;-0%;0%', '+0%;-0%;0%', '+0%;-0%;0%', '+0%;-0%;0%']);
  table_(ss.getSheetByName(t_('tab.ads')), 1, head, rows, fm, CONFIG.max_rows_written);
}
function writeAssets_(ss, assets) {
  const head = ['campaign', 'ad_group', 'ad_id', 'field', 'asset_text', 'label', 'pinned', 'enabled'].map(k => t_('col.' + k)).concat(mHead_());
  const rows = assets.slice().sort((x, y) => y.m.impressions - x.m.impressions).map(a => [a.campaign, a.adGroup, a.adId, a.field, a.text, a.label, a.pinned, a.enabled ? t_('yes') : t_('no')].concat(mRow_(a.m)));
  table_(ss.getSheetByName(t_('tab.assets')), 1, head, rows, ['@', '@', '@', '@', '@', '@', '@', '@'].concat(mFmt_()), CONFIG.max_rows_written);
}
function writeHeadlines_(ss, assets) {
  const by = {};
  assets.forEach(a => { const k = a.field + '|' + a.text.trim().toLowerCase(); const t = by[k] = by[k] || { field: a.field, text: a.text, ads: new Set(), labels: {}, pinned: 0, m: zeroM_() };
    t.ads.add(a.adId); t.m = addM_(t.m, a.m); t.labels[a.label] = (t.labels[a.label] || 0) + 1; if (a.pinned) t.pinned++; });
  const rows = Object.values(by).map(t => { derive_(t.m); return t; }).sort((x, y) => y.m.impressions - x.m.impressions)
    .map(t => [t.field, t.text, t.ads.size, Object.keys(t.labels).sort((x, y) => t.labels[y] - t.labels[x])[0], t.pinned].concat(mRow_(t.m)));
  table_(ss.getSheetByName(t_('tab.headlines')), 1, ['field', 'asset_text', 'ads_using', 'dominant_label', 'times_pinned'].map(k => t_('col.' + k)).concat(mHead_()), rows, ['@', '@', '#,##0', '@', '#,##0'].concat(mFmt_()), CONFIG.max_rows_written);
}
/* Cost is attributed to the first final URL; additional URLs are listed but marked shared to avoid double counting. */
function writeUrls_(ss, ads) {
  const by = {};
  ads.forEach(a => { const urls = a.urls.length ? a.urls : [t_('note.no_final_url')]; urls.forEach((u, i) => { const c = u.split('?')[0]; const e = by[c] = by[c] || { url: c, ads: 0, shared: 0, m: zeroM_() }; e.ads++; if (i === 0) e.m = addM_(e.m, a.m); else e.shared++; }); });
  const rows = Object.values(by).map(e => { derive_(e.m); return e; }).sort((x, y) => y.m.cost - x.m.cost).map(e => [e.url, e.ads, e.shared].concat(mRow_(e.m)));
  table_(ss.getSheetByName(t_('tab.urls')), 1, ['final_url', 'ads', 'shared_urls'].map(k => t_('col.' + k)).concat(mHead_()), rows, ['@', '#,##0', '#,##0'].concat(mFmt_()), CONFIG.max_rows_written);
}
function writeSummary_(ss, cid, currency, cur, prev, s, fixes) {
  const sh = ss.getSheetByName(t_('tab.summary')), t = s.tot, p = s.totPrev;
  sh.getRange(2, 1, Math.max(1, sh.getMaxRows() - 1), Math.max(1, sh.getMaxColumns())).clear();
  sh.getRange('A1').setValue(t_('title')).setFontWeight('bold').setFontSize(13);
  const K = (k, a, b, inv, fmt) => ({ r: [t_(k), a, b, nz_(pct_(a, b))], fmt: fmt, inv: inv });
  const kpi = [K('m.impressions', t.impressions, p.impressions, false, '#,##0'), K('m.clicks', t.clicks, p.clicks, false, '#,##0'), K('m.ctr', nz_(t.ctr), nz_(p.ctr), false, '0.00%'),
    K('m.cost', t.cost, p.cost, false, '#,##0.00'), K('m.conversions', nz_(t.conversions), nz_(p.conversions), false, '#,##0.0'), K('m.value', nz_(t.value), nz_(p.value), false, '#,##0.00'),
    K('m.cpa', nz_(t.cpa), nz_(p.cpa), true, '#,##0.00'), K('m.roas', nz_(t.roas), nz_(p.roas), false, '0.00')];
  const rows = [[t_('col.account'), cid, '', ''], [t_('col.period'), cur.label, t_('kpi.prev'), prev.label], [t_('col.currency'), currency, '', ''], [],
    [t_('kpi'), t_('kpi.now'), t_('kpi.prev'), 'Δ'], [t_('sum.ads'), s.ads, '', ''], [t_('sum.assets'), s.assets, '', '']].concat(kpi.map(k => k.r))
    .concat([[], [t_('sum.strength'), t_('col.ads'), '', '']]).concat(STRENGTH.map(k => [k, s.strength[k] || 0, '', '']))
    .concat([[], [t_('sum.labels'), t_('col.assets'), '', '']]).concat(LABELS.map(k => [k, s.labels[k] || 0, '', '']))
    .concat([[], [t_('sum.health'), '', '', ''], [t_('sum.disapproved'), s.disapproved, '', ''], [t_('sum.limited'), s.limited, '', ''], [t_('sum.poor'), s.poor, '', ''], [t_('sum.low'), s.lowAssets, '', ''], [t_('sum.pinned'), s.pinnedAssets, '', '']]);
  sh.getRange(3, 1, rows.length, 4).setValues(rows.map(r => r.length ? r : ['', '', '', '']));
  const kpiTop = 3 + 7;
  kpi.forEach((k, i) => { sh.getRange(kpiTop + i, 2, 1, 2).setNumberFormat(k.fmt); sh.getRange(kpiTop + i, 4).setNumberFormat('+0.0%;-0.0%;0.0%'); });
  rows.forEach((r, i) => { if (r.length && [t_('kpi'), t_('sum.strength'), t_('sum.labels'), t_('sum.health')].includes(r[0])) sh.getRange(3 + i, 1, 1, 4).setFontWeight('bold').setBackground('#F1F5F9'); });
  let top = 3 + rows.length + 1;
  sh.getRange(top, 1).setValue(t_('fix.title')).setFontWeight('bold').setFontColor('#0F766E');
  styleHeader_(sh.getRange(top + 1, 1, 1, 5).setValues([['#', t_('col.type'), t_('col.where'), t_('col.what'), t_('col.cost_in_period')]]));
  if (fixes.length) { sh.getRange(top + 2, 1, fixes.length, 5).setValues(fixes.map((f, i) => [i + 1, f.type, f.where, f.what, f.cost])); sh.getRange(top + 2, 5, fixes.length, 1).setNumberFormat('#,##0.00'); }
  else sh.getRange(top + 2, 1).setValue(t_('fix.none'));
  top += 3 + Math.max(fixes.length, 1);
  sh.getRange(top, 1).setValue(t_('sum.top_headlines', { n: CONFIG.min_impressions_for_ranking })).setFontWeight('bold').setFontColor('#0F766E');
  styleHeader_(sh.getRange(top + 1, 1, 1, 5).setValues([[t_('col.asset_text'), t_('col.label'), t_('m.impressions'), t_('m.ctr'), t_('m.conversions')]]));
  if (s.topHeadlines.length) { sh.getRange(top + 2, 1, s.topHeadlines.length, 5).setValues(s.topHeadlines.map(a => [a.text, a.label, a.m.impressions, a.m.ctr, nz_(a.m.conversions)])); sh.getRange(top + 2, 4, s.topHeadlines.length, 1).setNumberFormat('0.00%'); }
  sh.setColumnWidths(1, 1, 240); sh.setColumnWidths(3, 2, 260);
}
function notify_(cid, currency, cur, s, fixes, url) {
  if (!CONFIG.email && !CONFIG.slack_webhook) return;
  const t = s.tot, p = s.totPrev, f = v => fmtNum_(v);
  const body = kpiTable_([[t_('m.impressions'), f(t.impressions), f(p.impressions), delta_(t.impressions, p.impressions)], [t_('m.clicks'), f(t.clicks), f(p.clicks), delta_(t.clicks, p.clicks)],
    [t_('m.ctr'), fmtPct_(t.ctr), fmtPct_(p.ctr), delta_(t.ctr, p.ctr)], [t_('m.cost'), f(t.cost), f(p.cost), delta_(t.cost, p.cost)], [t_('m.conversions'), f(t.conversions), f(p.conversions), delta_(t.conversions, p.conversions)],
    [t_('m.cpa'), f(t.cpa), f(p.cpa), delta_(t.cpa, p.cpa, true)], [t_('m.roas'), f(t.roas), f(p.roas), delta_(t.roas, p.roas)]]) +
    '<div style="padding:12px 16px 4px;font-weight:bold;color:#0F766E">' + h_(t_('sum.health')) + '</div><div style="padding:0 16px">' +
    h_(t_('mail.health', { dis: s.disapproved, poor: s.poor, low: s.lowAssets })) + '</div>' +
    '<div style="padding:12px 16px 4px;font-weight:bold;color:#0F766E">' + h_(t_('fix.title')) + '</div><ol style="margin:0 16px 8px 32px;padding:0">' +
    (fixes.map(x => '<li style="margin:3px 0"><b>' + h_(x.type) + '</b> — ' + h_(x.where) + '<br><span style="color:#64748B">' + h_(x.what) + '</span></li>').join('') || '<li>' + h_(t_('fix.none')) + '</li>') + '</ol>';
  const subject = t_('mail.subject', { cid: cid, period: cur.label, n: fixes.length });
  sendMail_(CONFIG.email, subject, htmlShell_(t_('title') + ' · ' + cid, t_('mail.sub', { period: cur.label, days: CONFIG.days, currency: currency }), body, url), subject + '\n' + url);
  const S = slackText_;
  sendSlack_(CONFIG.slack_webhook, `*${S(subject)}*\n${S(t_('m.ctr'))} ${fmtPct_(t.ctr)} · ${S(t_('m.cost'))} ${f(t.cost)} · ${S(t_('m.conversions'))} ${f(t.conversions)} · ${S(t_('m.cpa'))} ${f(t.cpa)}\n` +
    fixes.slice(0, 5).map((x, i) => `${i + 1}. ${S(x.type)} — ${S(x.where)}: ${S(x.what)}`).join('\n') + `\n<${url}|${S(t_('mail.open_report'))}>`);
}
3 langsSchedule: Weekly

Search-term n-gram analysis across Search, Shopping, DSA and Performance Max with negative-keyword awareness and ranked negative and expansion candidates.

// ngram-lens — EN build 2026-09-14
// Copyright 2026 Gökhan Güzel — gokhanguzel.com. All Rights Reserved. Generated file: edit src/ and i18n/, then run build.js.
// Do not share configured Slack webhook URLs.
'use strict';
const SCRIPT_ID = "ngram-lens";
const L = {"lang":"en","locale":"en-US","text_lang":"en","dec":".","thou":","};
const ATTRIBUTION = String.fromCharCode(169,32,103,111,107,104,97,110,103,117,122,101,108,46,99,111,109);
const T = {"kpi":"KPI","kpi.now":"This period","kpi.prev":"Previous","yes":"yes","no":"no","mail.full_report":"Full report","mail.open_report":"Open report","note.no_rows":"No rows.","note.truncated":"Showing {shown} of {total} rows (max_rows_written).","note.no_campaigns":"No campaigns with impressions match the current filters.","settings.key":"Key","settings.value":"Value","settings.notes":"Notes","settings.script":"script that owns this Sheet (do not edit)","settings.account":"Google Ads account that owns this Sheet (do not edit)","note.truncated_queries":"⚠ {n} quer(y/ies) hit the read limit — results incomplete; narrow the window","note.account_neg_failed":"⚠ account-level negatives could not be read:","note.skipped_time":"skipped — execution time budget","note.data_error":"data error:","note.failed":"FAILED —","note.no_final_url":"(no final URL)","alias.settings":["Settings","Ayarlar","Einstellungen"],"alias.history":["History","Kronoloji","Verlauf","Runs","Calismalar","Laeufe"],"alias.snapshot":["Snapshot","Snapshots","Kesit","Kesitler","Momentaufnahme","Momentaufnahmen"],"alias.state":["State","DurumBellegi","Zustand"],"alias.baseline":["Baseline","TemelCizgi","Basislinie"],"m.impressions":"Impr.","m.clicks":"Clicks","m.ctr":"CTR","m.cost":"Cost","m.cpc":"Avg. CPC","m.conversions":"Conv.","m.value":"Conv. value","m.cr":"Conv. rate","m.cpa":"CPA","m.roas":"ROAS","m.aov":"AOV","m.vpc":"Value / click","col.account":"Account","col.period":"Period","col.currency":"Currency","col.date":"Date","col.campaign":"Campaign","col.ad_group":"Ad group","col.n":"N","col.phrase":"Phrase","col.queries":"Queries","col.source":"Source","col.why":"Why","title":"N-Gram Lens — search term analysis","tab.summary":"Summary","tab.account_ngrams":"Account n-grams","tab.campaign_ngrams":"Campaign n-grams","tab.word_count":"Word count","tab.suggestions":"Suggestions","alias.summary":["Özet","Zusammenfassung"],"alias.account_ngrams":["Hesap n-gramları","Konto-N-Gramme"],"alias.campaign_ngrams":["Kampanya n-gramları","Kampagnen-N-Gramme"],"alias.word_count":["Kelime sayısı","Wortanzahl"],"alias.suggestions":["Tavsiyeler","Vorschläge"],"col.words_in_query":"Words in query","sum.campaigns":"Campaigns in scope","sum.sources":"Sources","sum.queries":"Queries analysed","sum.excluded":"Queries excluded by negatives","sum.sizes":"N-gram sizes","sum.account_ngrams":"Account n-grams (after thresholds)","sum.totals":"Account totals","sum.neg_candidates":"Negative candidates","sum.exp_candidates":"Expansion candidates","sum.cost_on_negatives":"Cost sitting on negative candidates","sum.notes":"Notes","sugg.negatives":"Negative candidates — review, then add as phrase negatives (never auto-applied)","sugg.expansions":"Expansion candidates — phrases that convert better than the account","why.no_conv":"{clicks} clicks, 0 conversions","why.expected":"account rate predicts ~{n} conv.","why.cpa":"CPA {cpa} vs account {acc}","why.roas":"ROAS {v} vs account {acc}","why.cr":"Conv. rate {v} vs account {acc}","note.ngram_counting":"Each n-gram is counted once per query; broad negatives match any word order, phrase contiguous, exact the whole query.","note.pmax_source":"Performance Max terms come from campaign_search_term_view (campaign level, with cost); Search/Shopping/DSA from search_term_view."};

/* ===================== common/core ===================== */
/* Pure helpers. No Sheets, no network. Everything here is unit-tested in test/run.js. */

/* AdsApp.search is report-backed and is not subject to selector entity limits. Keep a defensive count check only for
   runtimes/iterators that expose totalNumEntities(); normally this stays at zero. */
let TRUNCATED_ = 0;
function query_(gaql) {
  const out = [];
  const it = AdsApp.search(gaql);
  while (it.hasNext()) out.push(it.next());
  try { if (typeof it.totalNumEntities === 'function') { const n = it.totalNumEntities(); if (n > out.length) { TRUNCATED_++; Logger.log(`Query truncated: ${out.length} of ${n} rows read — narrow the window or filters. ${gaql.slice(0, 120)}`); } } } catch (e) {}
  return out;
}
/* Seconds left in the 30-minute execution window (Infinity when the runtime does not expose it, e.g. in the mock). */
function timeLeft_() { try { return AdsApp.getExecutionInfo().getRemainingTime(); } catch (e) { return Infinity; } }
function num_(v) { const x = parseFloat(v); return isFinite(x) ? x : 0; }
function nz_(v) { return isFinite(v) ? v : ''; }
function esc_(s) { return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'"); }
function escLike_(s) { const m = { '[': '[[]', ']': '[]]', '%': '[%]', '_': '[_]' }; return esc_(s).replace(/[\[\]%_]/g, c => m[c]); }
function chunks_(arr, n) { const o = []; for (let i = 0; i < arr.length; i += n) o.push(arr.slice(i, i + n)); return o; }
function pct_(a, b) { return (typeof a === 'number' && typeof b === 'number' && isFinite(a) && isFinite(b) && b) ? a / b - 1 : NaN; }

/* Number formatting for e-mail / Slack. Deterministic: separators come from the build (L.dec / L.thou), not from
   the runtime's ICU data. */
function fmtNum_(v, digits) {
  if (typeof v !== 'number' || !isFinite(v)) return '–';
  const d = digits === undefined ? (Math.abs(v) >= 1000 ? 0 : Math.abs(v) < 1 ? 3 : 2) : digits;
  const parts = Math.abs(v).toFixed(d).split('.');
  return (v < 0 ? '-' : '') + parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, L.thou) + (parts[1] ? L.dec + parts[1] : '');
}
function fmtPct_(v, digits) { return isFinite(v) ? fmtNum_(v * 100, digits === undefined ? 2 : digits) + '%' : '–'; }

/* Metric bag: raw sums + derived rates. NaN means "undefined", never 0. */
function zeroM_() { return { impressions: 0, clicks: 0, cost: 0, conversions: 0, value: 0 }; }
function rawOf_(r, opts) {
  const m = (r && r.metrics) || {}; opts = opts || {};
  return {
    impressions: num_(m.impressions), clicks: num_(m.clicks),
    cost: opts.noCost ? 0 : num_(m.costMicros) / 1e6,
    conversions: opts.noConv ? NaN : num_(m.conversions),
    value: opts.noConv ? NaN : num_(m.conversionsValue)
  };
}
function addM_(a, b) {
  const sumKnown = (x, y) => isFinite(x) && isFinite(y) ? x + y : NaN;
  return {
    impressions: a.impressions + b.impressions, clicks: a.clicks + b.clicks, cost: a.cost + b.cost,
    conversions: sumKnown(a.conversions, b.conversions),
    value: sumKnown(a.value, b.value)
  };
}
function derive_(o) {
  o.ctr = o.impressions ? o.clicks / o.impressions : NaN;
  o.cpc = o.clicks ? o.cost / o.clicks : NaN;
  o.cr = o.clicks && isFinite(o.conversions) ? o.conversions / o.clicks : NaN;
  o.cpa = o.conversions > 0 ? o.cost / o.conversions : NaN;
  o.roas = o.cost > 0 && isFinite(o.value) ? o.value / o.cost : NaN;
  o.aov = o.conversions > 0 ? o.value / o.conversions : NaN;
  o.vpc = o.clicks ? o.value / o.clicks : NaN;
  return o;
}

/* Text normalisation for search terms / negatives. language: 'tr' handles İ/ı. */
function normalize_(s, language) {
  s = String(s || '');
  s = language === 'tr' ? s.replace(/I/g, 'ı').replace(/İ/g, 'i').toLowerCase() : s.toLowerCase();
  return s.replace(/[+\[\]"“”'’`^*]/g, ' ')
          .replace(/[^\p{L}\p{N}\s\-\.]/gu, ' ')
          .split(/\s+/).map(w => w.replace(/^[.\-]+|[.\-]+$/g, '')).filter(Boolean).join(' ');
}
function tokens_(s, language) { const n = normalize_(s, language); return n ? n.split(' ') : []; }
function hasSubseq_(hay, needle) {
  outer: for (let i = 0; i + needle.length <= hay.length; i++) {
    for (let j = 0; j < needle.length; j++) if (hay[i + j] !== needle[j]) continue outer;
    return true;
  }
  return false;
}
/* exact = same tokens; phrase = contiguous; broad = every word present. */
function blocked_(q, negs) {
  if (!negs) return false;
  for (const n of negs) {
    if (!n.words.length) continue;
    if (n.mt === 'EXACT') { if (n.words.length === q.length && n.words.every((w, i) => w === q[i])) return true; }
    else if (n.mt === 'PHRASE') { if (hasSubseq_(q, n.words)) return true; }
    else if (n.words.every(w => q.includes(w))) return true;
  }
  return false;
}

/* Robust stats: median / MAD (σ-scaled), SD fallback. */
function median_(sorted) { const n = sorted.length; if (!n) return NaN; return n % 2 ? sorted[(n - 1) / 2] : (sorted[n / 2 - 1] + sorted[n / 2]) / 2; }
function robust_(arr) {
  if (!arr.length) return { median: NaN, mad: NaN, scale: 0 };
  const s = arr.slice().sort((a, b) => a - b), med = median_(s);
  const mad = median_(s.map(v => Math.abs(v - med)).sort((a, b) => a - b)) * 1.4826;
  let scale = mad;
  if (!(scale > 0)) { const mean = arr.reduce((x, v) => x + v, 0) / arr.length; scale = Math.sqrt(arr.reduce((x, v) => x + (v - mean) * (v - mean), 0) / Math.max(1, arr.length - 1)); }
  return { median: med, mad: mad, scale: scale };
}

/* Minimal HTML escape for anything user-generated that lands in e-mail. */
function h_(s) { return String(s === undefined || s === null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
/* Text lookup. T is injected by the build; keys are stable, values are per language. */
function t_(key, vars) {
  let s = T[key]; if (s === undefined) s = key;
  return String(s).replace(/\{(\w+)\}/g, (m, k) => (vars && vars[k] !== undefined) ? vars[k] : m);
}

/* ===================== common/dates ===================== */
/* Calendar-based date arithmetic in the account time zone. No 864e5 shifts,
   so DST transitions cannot skip or repeat a day. */
function tz_() { return AdsApp.currentAccount().getTimeZone(); }
function todayStr_(tz, base) { return Utilities.formatDate(base || new Date(), tz, 'yyyy-MM-dd'); }
function shiftDay_(dateStr, days) {
  const p = dateStr.split('-').map(Number);
  const d = new Date(Date.UTC(p[0], p[1] - 1, p[2] + days));
  return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + String(d.getUTCDate()).padStart(2, '0');
}
/* window_(days, offset): `days` complete days, ending `offset+1` days before today. offset 0 → ends yesterday. */
function window_(days, offset, tz, base) {
  const today = todayStr_(tz, base);
  const end = shiftDay_(today, -(offset + 1)), start = shiftDay_(today, -(offset + days));
  return { start: start, end: end, label: start + ' → ' + end };
}
const DOW_ = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY'];
function dowOf_(dateStr) { const p = dateStr.split('-').map(Number); return DOW_[new Date(Date.UTC(p[0], p[1] - 1, p[2])).getUTCDay()]; }
/* Sheets may hand back a Date where we wrote a 'yyyy-MM-dd' string. Always normalise on read. */
function dateStrOf_(v, tz) {
  if (v instanceof Date) return Utilities.formatDate(v, tz, 'yyyy-MM-dd');
  return String(v === null || v === undefined ? '' : v).trim();   // strings are never truncated: keys like 'yyyy-MM-dd HH:mm' or '123-456-7890' stay intact
}

/* ===================== common/manager ===================== */
/* Runs as a single account, or selects one client account when started from a manager account. */
function isManager_() { try { if (typeof AdsManagerApp === 'undefined') return false; AdsManagerApp.accounts().withLimit(1).get(); return true; } catch (e) { return false; } }
function selectAccount_(accountId) {
  if (!isManager_()) return;
  if (!accountId) throw new Error('Running from a manager account: set CONFIG.account_id (e.g. 123-456-7890).');
  const it = AdsManagerApp.accounts().withIds([String(accountId)]).get();
  if (!it.hasNext()) throw new Error('Account ' + accountId + ' is not a client of this manager account.');
  AdsManagerApp.select(it.next());
}

/* ===================== common/sheets ===================== */
/* Technical tabs are language-independent. User-facing tab names come from T. */
const TECH = { settings: '_settings', state: '_state', snapshot: '_snapshot', history: '_history', baseline: '_baseline' };

function openSpreadsheet_(url) {
  if (!url || url === 'YOUR_SPREADSHEET_URL') throw new Error('Set CONFIG.spreadsheet_url to a Google Sheet you own.');
  const ss = SpreadsheetApp.openByUrl(url);
  claimSheet_(ss);              // ownership first: nothing is written to a Sheet that belongs to another script or account
  return seal_(ss);             // every script opens its Sheet through the attribution layer
}
/* One Sheet per script. The first run records SCRIPT_ID in _settings; a different script pointed at the same Sheet stops
   before it can overwrite tabs (ngram-lens and pmax-ngram-lens share tab names). */
function claimSheet_(ss) {
  const sh = tab_(ss, TECH.settings, T['alias.settings']);
  const cid = AdsApp.currentAccount().getCustomerId();
  const kv = {}; if (sh.getLastRow() > 1) sh.getRange(2, 1, sh.getLastRow() - 1, 2).getValues().forEach(r => kv[String(r[0])] = String(r[1]));
  if (kv.script && kv.script !== SCRIPT_ID) throw new Error(`This Sheet belongs to ${kv.script}. Use a separate Sheet for ${SCRIPT_ID}.`);
  if (kv.account && kv.account !== cid) throw new Error(`This Sheet belongs to account ${kv.account}; running as ${cid}. Use a separate Sheet per account.`);
  if (!kv.script || !kv.account) ensureSettings_(ss, []);
}
/* Title in A1, header in row `top` (2). Writes only what is missing, so the attribution cell in H1 never masks an empty
   header row. Returns true when the header was (re)written. */
function ensureTitledHeader_(sheet, title, header) {
  ensureSize_(sheet, 3, header.length);
  if (String(sheet.getRange(1, 1).getValue()) === '') sheet.getRange(1, 1).setValue(title).setFontWeight('bold').setFontSize(13);
  const a2 = sheet.getRange(2, 1).getValue();
  if (String(a2) === header[0]) return false;
  if (String(a2) !== '') sheet.insertRowsBefore(2, 1);   // data written under a missing header (v2.2 layout): push it down, then add the header
  styleHeader_(sheet.getRange(2, 1, 1, header.length).setValues([header])); sheet.setFrozenRows(2);
  return true;
}
/* Find a tab by its current name or any legacy alias; rename legacy tabs once so state survives a language switch. */
function tab_(ss, name, aliases) {
  let sh = ss.getSheetByName(name);
  if (!sh && aliases) for (const a of aliases) { const old = ss.getSheetByName(a); if (old) { old.setName(name); sh = old; break; } }
  if (!sh) sh = ss.insertSheet(name);
  return sh;
}
/* specs: [{name, aliases:[...]}]. Removes the empty default tab whatever its locale name is. */
function ensureTabs_(ss, specs) {
  const wanted = new Set(specs.map(s => s.name));
  specs.forEach(s => tab_(ss, s.name, s.aliases));
  const first = ss.getSheets()[0];
  if (first && !wanted.has(first.getName()) && ss.getSheets().length > 1 && onlyAttribution_(first)) ss.deleteSheet(first);
}
/* A default tab that holds nothing but the attribution cell written by the early seal_ is still "empty". */
function onlyAttribution_(sheet) {
  if (sheet.getLastRow() === 0) return true;
  if (sheet.getLastRow() > 1) return false;
  const row = sheet.getRange(1, 1, 1, Math.max(1, sheet.getLastColumn())).getValues()[0];
  return row.every((v, i) => v === '' || (i === 7 && v === ATTRIBUTION));
}
/* Grow the grid BEFORE any getRange: Sheets throws on coordinates outside the current dimensions. */
function ensureSize_(sheet, rows, cols) {
  if (cols > sheet.getMaxColumns()) sheet.insertColumnsAfter(sheet.getMaxColumns(), cols - sheet.getMaxColumns());
  if (rows > sheet.getMaxRows()) sheet.insertRowsAfter(sheet.getMaxRows(), rows - sheet.getMaxRows());
}
function styleHeader_(rng) { return rng.setFontWeight('bold').setBackground('#1E293B').setFontColor('#ffffff'); }

/* Write a table. Rows above `top` are never touched (callers put titles there). formats[i] is a number format per column ('@' = text, applied BEFORE setValues so Sheets never auto-parses).
   Rows beyond `cap` are dropped and a note is written. Returns the number of rows written. */
function table_(sheet, top, header, rows, formats, cap) {
  let out = rows;
  if (cap && rows.length > cap) out = rows.slice(0, cap);
  ensureSize_(sheet, top + out.length + 1, header.length);
  const maxR = sheet.getMaxRows(), maxC = sheet.getMaxColumns();
  if (maxR >= top) sheet.getRange(top, 1, maxR - top + 1, maxC).clearContent();   // contents only: user notes/formats outside the table survive
  styleHeader_(sheet.getRange(top, 1, 1, header.length).setValues([header]));
  if (out.length) {
    (formats || []).forEach((f, i) => { if (f) sheet.getRange(top + 1, i + 1, out.length, 1).setNumberFormat(f); });
    sheet.getRange(top + 1, 1, out.length, header.length).setValues(out);
    if (out.length < rows.length) sheet.getRange(top + 1 + out.length, 1).setValue(t_('note.truncated', { shown: out.length, total: rows.length }));
  } else {
    sheet.getRange(top + 1, 1).setValue(t_('note.no_rows'));
  }
  sheet.setFrozenRows(top);
  return out.length;
}
/* Append rows with text formats applied first (appendRow cannot do that). */
function appendRows_(sheet, rows, formats) {
  if (!rows.length) return;
  const r0 = sheet.getLastRow() + 1, n = rows.length, w = rows[0].length;
  ensureSize_(sheet, r0 + n - 1, w);
  (formats || []).forEach((f, i) => { if (f) sheet.getRange(r0, i + 1, n, 1).setNumberFormat(f); });
  sheet.getRange(r0, 1, n, w).setValues(rows);
}
/* A technical tab whose header row differs from what this version writes (older layout) is renamed to an archive tab and a
   fresh one is created — rows of two schemas are never mixed. Returns the sheet to write to. */
function ensureSchema_(ss, name, header) {
  const sh = ss.getSheetByName(name); if (!sh || sh.getLastRow() === 0) return sh;
  const cur = sh.getRange(1, 1, 1, Math.max(1, sh.getLastColumn())).getValues()[0].map(String);
  while (cur.length && cur[cur.length - 1] === '') cur.pop();
  if (cur.join('|') === header.join('|')) return sh;
  let n = 1, archive = name + '_archive'; while (ss.getSheetByName(archive)) archive = name + '_archive' + (++n);
  sh.setName(archive); Logger.log(`${name}: old layout archived as ${archive}`);
  return ss.insertSheet(name);
}
/* Idempotent history: one row per key (first `keyCols` columns, joined). Existing row with the same key is replaced. */
function upsertRow_(sheet, header, row, keyCols, formats, tz) {
  ensureSize_(sheet, 2, Math.max(header.length, row.length));
  const cur = sheet.getLastRow() === 0 ? [] : sheet.getRange(1, 1, 1, Math.max(1, sheet.getLastColumn())).getValues()[0].map(String).filter(Boolean);
  if (cur.join('|') !== header.join('|')) {
    const w = Math.max(header.length, sheet.getLastColumn()); sheet.getRange(1, 1, 1, w).clearContent();
    styleHeader_(sheet.getRange(1, 1, 1, header.length).setValues([header])); sheet.setFrozenRows(1);
  }   // new/changed schema: clear stale cells, then rewrite header
  const key = row.slice(0, keyCols).map(v => dateStrOf_(v, tz)).join('|');
  const last = sheet.getLastRow();
  if (last > 1) {
    const keys = sheet.getRange(2, 1, last - 1, keyCols).getValues().map(r => r.map(v => dateStrOf_(v, tz)).join('|'));
    const i = keys.indexOf(key);
    if (i >= 0) {
      const w = Math.max(row.length, sheet.getLastColumn()); sheet.getRange(i + 2, 1, 1, w).clearContent();
      sheet.getRange(i + 2, 1, 1, row.length).setValues([row]); return;
    }
  }
  appendRows_(sheet, [row], formats);
}
/* Replace a whole table safely: write the new rows first, then clear whatever is left below. */
function replaceRows_(sheet, header, rows, formats) {
  const n = rows.length;
  ensureSize_(sheet, n + 1, header.length);
  if (sheet.getLastRow() === 0) styleHeader_(sheet.getRange(1, 1, 1, header.length).setValues([header]));
  if (n) {
    (formats || []).forEach((f, i) => { if (f) sheet.getRange(2, i + 1, n, 1).setNumberFormat(f); });
    sheet.getRange(2, 1, n, header.length).setValues(rows);
  }
  const last = sheet.getLastRow();
  if (last > n + 1) sheet.getRange(n + 2, 1, last - n - 1, Math.max(header.length, sheet.getLastColumn())).clearContent();
}

/* _settings: Key | Value | Notes. Keys are stable identifiers (never translated); notes are per language. */
function ensureSettings_(ss, defaults) {
  const sh = tab_(ss, TECH.settings, T['alias.settings']);
  ensureSize_(sh, 2 + defaults.length, 3);
  if (sh.getLastRow() === 0) {
    sh.getRange(1, 1, 1, 3).setNumberFormat('@').setValues([[t_('settings.key'), t_('settings.value'), t_('settings.notes')]]);
    styleHeader_(sh.getRange(1, 1, 1, 3)); sh.setFrozenRows(1); sh.setColumnWidths(1, 1, 200); sh.setColumnWidths(3, 1, 420);
  }
  const all = [['script', SCRIPT_ID, t_('settings.script')], ['account', AdsApp.currentAccount().getCustomerId(), t_('settings.account')]].concat(defaults);
  const last = sh.getLastRow();
  const keys = last > 1 ? sh.getRange(2, 1, last - 1, 1).getValues().map(r => String(r[0])) : [];
  const missing = all.filter(d => keys.indexOf(d[0]) < 0);
  if (missing.length) { const r0 = sh.getLastRow() + 1; ensureSize_(sh, r0 + missing.length, 3); sh.getRange(r0, 1, missing.length, 3).setNumberFormat('@').setValues(missing.map(d => [d[0], d[1], d[2]])); }
  if (keys.length) {   // notes follow the current language; one batched write, only when something differs; values are never touched
    const rng = sh.getRange(2, 3, keys.length, 1), cur = rng.getValues().map(r => String(r[0]));
    const want = keys.map((k, i) => { const d = all.find(x => x[0] === k); return d && d[2] ? d[2] : cur[i]; });
    if (want.join('\u0001') !== cur.join('\u0001')) rng.setValues(want.map(v => [v]));
  }
  return sh;
}
/* Reads _settings into cfg. Only keys present in `schema` are accepted; type comes from schema: 'num' | 'bool' | 'str' | 'list'. */
function readSettings_(ss, cfg, schema) {
  const out = JSON.parse(JSON.stringify(cfg));
  const sh = ss.getSheetByName(TECH.settings);
  if (!sh || sh.getLastRow() < 2) return out;
  sh.getRange(2, 1, sh.getLastRow() - 1, 2).getValues().forEach(([k, v]) => {
    const type = schema[k]; if (!type || v === '' || v === null) return;
    if (type === 'num') {
      if (typeof v === 'number') { out[k] = v; return; }
      const s = String(v).trim().replace(/\s/g, '');
      if (!/^-?\d+([.,]\d+)?$/.test(s)) throw new Error(`_settings.${k}: "${v}" is not a number (use 0.5 or 0,5)`);
      if (/^-?\d{1,3}[.,]\d{3}$/.test(s)) throw new Error(`_settings.${k}: "${v}" is ambiguous; do not use thousands separators (write 1000 or 0.5 / 0,5)`);
      out[k] = parseFloat(s.replace(',', '.'));   // decimal comma/dot accepted; ambiguous 1.000 / 1,000 is rejected
    }
    else if (type === 'bool') out[k] = /^(true|yes|1|on|evet|ja)$/i.test(String(v).trim());
    else if (type === 'list') out[k] = String(v).split(',').map(s => s.trim()).filter(Boolean);
    else out[k] = String(v).trim();
  });
  return out;
}
function writeSetting_(ss, key, value) {
  const sh = ss.getSheetByName(TECH.settings); if (!sh) return;
  const last = sh.getLastRow();
  const keys = last > 1 ? sh.getRange(2, 1, last - 1, 1).getValues().map(r => String(r[0])) : [];
  const i = keys.indexOf(key);
  const cell = i >= 0 ? sh.getRange(i + 2, 2) : sh.getRange(last + 1, 2);
  if (i < 0) sh.getRange(last + 1, 1).setNumberFormat('@').setValue(key);
  cell.setNumberFormat('@').setValue(value);
}

/* ===================== common/seal ===================== */
/* Attribution cell. Warning-only protection (no editor management → no Session dependency, no lock-out of the
   running user). Never throws: a report must not fail because a Sheet cell was edited.
   Called twice: from openSpreadsheet_ (first tab, before anything else) and at the end of main() on the report tab.
   Returns the spreadsheet so the open path depends on it. This is attribution, not DRM: anyone who edits the
   source can remove it. */
function seal_(ss, tabName) {
  try {
    const sheet = (tabName && ss.getSheetByName(tabName)) || ss.getSheets().find(s => !s.getName().startsWith('_')) || ss.getSheets()[0];
    const want = ATTRIBUTION;
    const range = sheet.getRange('H1:K1');
    let cur = ''; try { cur = String(range.getCell(1, 1).getValue()); } catch (e) {}
    if (cur !== want) {
      try { range.breakApart(); range.merge(); } catch (e) {}
      range.getCell(1, 1).setValue(want).setFontWeight('bold').setFontColor('#0F766E').setHorizontalAlignment('center').setNote(want);
    }
    const has = sheet.getProtections(SpreadsheetApp.ProtectionType.RANGE).some(p => p.getDescription() === want);
    if (!has) range.protect().setDescription(want).setWarningOnly(true);
  } catch (e) { Logger.log('Attribution skipped: ' + e); }
  return ss;
}

/* ===================== common/notify ===================== */
function htmlShell_(title, subtitle, body, url) {
  return '<div style="font:13px Arial,sans-serif;color:#1E293B;max-width:760px">' +
    '<div style="background:#1E293B;color:#fff;padding:12px 16px;font-size:15px;font-weight:bold">' + h_(title) + '</div>' +
    '<div style="padding:10px 16px;color:#64748B">' + h_(subtitle) + '</div>' + body +
    '<div style="padding:12px 16px;color:#94A3B8;font-size:11px">' + (url ? h_(t_('mail.full_report')) + ': <a href="' + h_(url) + '">' + h_(url) + '</a><br>' : '') + h_(ATTRIBUTION) + '</div></div>';
}
function kpiTable_(rows) {
  /* rows: [[label, now, prev, delta_html]] */
  const th = (s, r) => '<th style="padding:5px 10px;text-align:' + (r ? 'right' : 'left') + '">' + h_(s) + '</th>';
  return '<table style="border-collapse:collapse;width:100%"><tr style="background:#F1F5F9;color:#64748B;font-size:11px">' +
    th(t_('kpi'), false) + th(t_('kpi.now'), true) + th(t_('kpi.prev'), true) + th('Δ', true) + '</tr>' +
    rows.map(r => '<tr><td style="padding:5px 10px">' + h_(r[0]) + '</td><td style="padding:5px 10px;text-align:right;font-weight:bold">' + h_(r[1]) +
      '</td><td style="padding:5px 10px;text-align:right;color:#64748B">' + h_(r[2]) + '</td><td style="padding:5px 10px;text-align:right">' + r[3] + '</td></tr>').join('') + '</table>';
}
function delta_(a, b, invert) {
  const v = pct_(a, b); if (!isFinite(v)) return '';
  const good = invert ? v < 0 : v > 0;
  return '<span style="color:' + (good ? '#15803D' : '#B91C1C') + '">' + (v >= 0 ? '▲' : '▼') + ' ' + fmtPct_(Math.abs(v), 0) + '</span>';
}
function sendMail_(to, subject, html, text) {
  if (!to) return false;
  try { MailApp.sendEmail({ to: to, subject: subject, htmlBody: html, body: text || subject }); return true; }
  catch (e) { Logger.log('E-mail failed: ' + e); return false; }
}
function slackText_(s) { return String(s === undefined || s === null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
function sendSlack_(webhook, text) {
  if (!webhook) return false;
  try {
    const res = UrlFetchApp.fetch(webhook, { method: 'post', contentType: 'application/json', muteHttpExceptions: true, payload: JSON.stringify({ text: text + '\n_' + slackText_(ATTRIBUTION) + '_' }) });
    const ok = res.getResponseCode() === 200;
    if (!ok) Logger.log('Slack returned ' + res.getResponseCode() + ': ' + res.getContentText().slice(0, 200));
    return ok;
  } catch (e) { Logger.log('Slack failed: ' + e); return false; }
}

/* ===================== common/negatives ===================== */
/* Negative keywords by ID: ad group, campaign, shared lists attached to campaigns, and account level. */
function fetchNegatives_(campaignIds, language, opts) {
  opts = opts || {};
  const neg = { adGroup: {}, campaign: {}, account: [] };
  const item = (text, mt) => ({ words: tokens_(text, language), mt: String(mt || '').toUpperCase() });
  const push = (bucket, key, text, mt) => { (bucket[key] = bucket[key] || []).push(item(text, mt)); };
  chunks_(campaignIds, 200).forEach(chunk => {
    if (opts.adGroupLevel !== false) {
      query_(`SELECT ad_group.id, ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type
        FROM ad_group_criterion WHERE ad_group_criterion.negative = TRUE AND ad_group_criterion.type = 'KEYWORD'
        AND ad_group_criterion.status != 'REMOVED' AND campaign.id IN (${chunk.join(',')})`)
        .forEach(r => push(neg.adGroup, String(r.adGroup.id), r.adGroupCriterion.keyword.text, r.adGroupCriterion.keyword.matchType));
    }
    query_(`SELECT campaign.id, campaign_criterion.keyword.text, campaign_criterion.keyword.match_type
      FROM campaign_criterion WHERE campaign_criterion.negative = TRUE AND campaign_criterion.type = 'KEYWORD'
      AND campaign_criterion.status != 'REMOVED' AND campaign.id IN (${chunk.join(',')})`)
      .forEach(r => push(neg.campaign, String(r.campaign.id), r.campaignCriterion.keyword.text, r.campaignCriterion.keyword.matchType));
  });
  const setToCampaigns = {};
  chunks_(campaignIds, 200).forEach(chunk => {
    query_(`SELECT campaign.id, shared_set.id FROM campaign_shared_set
      WHERE shared_set.type = 'NEGATIVE_KEYWORDS' AND campaign_shared_set.status = 'ENABLED' AND shared_set.status = 'ENABLED'
      AND campaign.id IN (${chunk.join(',')})`)
      .forEach(r => (setToCampaigns[String(r.sharedSet.id)] = setToCampaigns[String(r.sharedSet.id)] || []).push(String(r.campaign.id)));
  });
  const setIds = Object.keys(setToCampaigns);
  chunks_(setIds, 200).forEach(chunk => {
    query_(`SELECT shared_set.id, shared_criterion.keyword.text, shared_criterion.keyword.match_type
      FROM shared_criterion WHERE shared_criterion.type = 'KEYWORD' AND shared_set.id IN (${chunk.join(',')})`)
      .forEach(r => setToCampaigns[String(r.sharedSet.id)].forEach(cid => push(neg.campaign, cid, r.sharedCriterion.keyword.text, r.sharedCriterion.keyword.matchType)));
  });
  /* Account-level negatives are a shared set of type ACCOUNT_LEVEL_NEGATIVE_KEYWORDS (customer_negative_criterion has no
     keyword field; it only points at that list). */
  try {
    query_(`SELECT shared_criterion.keyword.text, shared_criterion.keyword.match_type
      FROM shared_criterion WHERE shared_set.type = 'ACCOUNT_LEVEL_NEGATIVE_KEYWORDS' AND shared_criterion.type = 'KEYWORD' AND shared_set.status = 'ENABLED'`)
      .forEach(r => neg.account.push(item(r.sharedCriterion.keyword.text, r.sharedCriterion.keyword.matchType)));
  } catch (e) { Logger.log('Account-level negatives not readable: ' + e); neg.accountError = String(e); }
  return neg;
}
function isBlocked_(tok, neg, campaignId, adGroupId) {
  return blocked_(tok, neg.account) || blocked_(tok, neg.campaign[campaignId]) || (adGroupId ? blocked_(tok, neg.adGroup[adGroupId]) : false);
}

/* ===================== common/metrics ===================== */
/* Standard metric block for tables: header / row / number formats, all driven by T and one metric bag. */
const M_KEYS = ['impressions', 'clicks', 'ctr', 'cost', 'cpc', 'conversions', 'value', 'cr', 'cpa', 'roas'];
const M_FMT = ['#,##0', '#,##0', '0.00%', '#,##0.00', '#,##0.00', '#,##0.0', '#,##0.00', '0.00%', '#,##0.00', '0.00'];
function mHead_(keys) { return (keys || M_KEYS).map(k => t_('m.' + k)); }
function mRow_(m, keys) { return (keys || M_KEYS).map(k => nz_(m[k])); }
function mFmt_(keys) { return (keys || M_KEYS).map(k => M_FMT[M_KEYS.indexOf(k)] || null); }

/* ===================== common/ngram ===================== */
/* Shared n-gram engine for N-Gram Lens and PMax N-Gram Lens. terms: [{campaignId, campaign, adGroupId, term, source, categories?, m}] */
function ngramAnalyse_(terms, neg, opts) {
  const lang = opts.language, stop = new Set((opts.stopwords || []).map(w => normalize_(w, lang)));
  const brand = (opts.brand_terms || []).map(b => tokens_(b, lang)).filter(t => t.length);
  const acc = {}, camp = {}, cat = {}, wc = {}, totals = zeroM_(), split = { brand: zeroM_(), generic: zeroM_() };
  const blockedKeys = new Set(); let excluded = 0, queries = 0;
  const bump = (map, key, seed, m) => { const a = map[key] = map[key] || Object.assign(zeroM_(), { queries: 0 }, seed); Object.assign(a, addM_(a, m)); a.queries++; };
  terms.forEach(t => {
    const tok = tokens_(t.term, lang); if (!tok.length) return;
    if (neg && isBlocked_(tok, neg, t.campaignId, t.adGroupId)) { excluded++; blockedKeys.add(t.campaignId + '|' + t.term.toLowerCase()); return; }
    queries++; Object.assign(totals, addM_(totals, t.m));
    const isBrand = brand.length && brand.some(b => hasSubseq_(tok, b));
    const side = isBrand ? split.brand : split.generic; Object.assign(side, addM_(side, t.m)); side.queries = (side.queries || 0) + 1;
    bump(wc, tok.length > 6 ? '7+' : String(tok.length), { words: tok.length > 6 ? '7+' : String(tok.length) }, t.m);
    const seen = new Set();
    for (let n = opts.min_ngram; n <= opts.max_ngram && n <= tok.length; n++) for (let i = 0; i + n <= tok.length; i++) {
      const words = tok.slice(i, i + n); if (n === 1 && stop.has(words[0])) continue;
      const g = words.join(' '), k = n + '|' + g; if (seen.has(k)) continue; seen.add(k);
      bump(acc, k, { n: n, gram: g }, t.m);
      bump(camp, t.campaignId + '|' + k, { n: n, gram: g, campaign: t.campaign, source: t.source }, t.m);
      (t.categories || []).forEach(c => bump(cat, t.campaignId + '|' + c + '|' + k, { n: n, gram: g, campaign: t.campaign, category: c }, t.m));
    }
  });
  derive_(totals); derive_(split.brand); derive_(split.generic);
  const th = opts.thresholds, keep = a => a.queries >= th.query_count && a.impressions >= th.impressions && a.clicks >= th.clicks && (a.cost || 0) >= (th.cost || 0);
  const rank = opts.rankBy === 'clicks' ? ((x, y) => y.clicks - x.clicks || y.impressions - x.impressions || x.gram.localeCompare(y.gram))
                                        : ((x, y) => y.cost - x.cost || y.impressions - x.impressions || x.gram.localeCompare(y.gram));
  return { queries, excluded, totals, split, blockedKeys,
    account: Object.values(acc).map(derive_).filter(keep).sort(rank),
    campaign: Object.values(camp).map(derive_).filter(keep).sort((x, y) => x.campaign.localeCompare(y.campaign) || rank(x, y)),
    category: Object.values(cat).map(derive_).filter(keep).sort((x, y) => x.campaign.localeCompare(y.campaign) || x.category.localeCompare(y.category) || rank(x, y)),
    wordCount: ['1', '2', '3', '4', '5', '6', '7+'].map(k => derive_(Object.assign({ words: k, queries: 0 }, zeroM_(), wc[k] || {}))) };
}
const NG_KEYS = ['queries'].concat(M_KEYS);
function ngHead_(keys) { return (keys || NG_KEYS).map(k => k === 'queries' ? t_('col.queries') : t_('m.' + k)); }
function ngFmt_(keys) { return (keys || NG_KEYS).map(k => k === 'queries' ? '#,##0' : M_FMT[M_KEYS.indexOf(k)] || null); }
function ngRow_(a, keys) { return (keys || NG_KEYS).map(k => nz_(a[k])); }
function writeNgrams_(ss, tabKey, rows, segKeys, keys, cap) {
  const head = segKeys.map(k => t_('col.' + k)).concat([t_('col.n'), t_('col.phrase')]).concat(ngHead_(keys));
  const fmt = segKeys.map(() => '@').concat(['0', '@']).concat(ngFmt_(keys));
  table_(ss.getSheetByName(t_('tab.' + tabKey)), 1, head, rows.map(r => segKeys.map(k => r[k]).concat([r.n, r.gram]).concat(ngRow_(r, keys))), fmt, cap);
}


/**
 * @name N-Gram Lens
 * @overview Search-term n-gram analysis. Search / Shopping / DSA from search_term_view (ad-group level, so ad-group
 *   negatives apply); Performance Max from campaign_search_term_view (campaign level, includes cost). Negatives at
 *   ad group, campaign, shared-list and account level are matched by ID with exact / phrase / broad semantics.
 * Schedule: Weekly.
 */
const CONFIG = {
  spreadsheet_url: 'YOUR_SPREADSHEET_URL', account_id: '',
  days: 30, min_ngram: 1, max_ngram: 3,
  include_search: true, include_pmax: true,
  campaign_contains: '', campaign_not_contains: '', enabled_campaigns_only: true,
  min_query_impressions: 1, check_negatives: true,
  language: L.text_lang, stopwords: [],
  segment_by_campaign: true, max_rows_written: 20000,
  thresholds: { query_count: 1, impressions: 20, clicks: 0, cost: 0 },
  suggestions: { negative_min_clicks: 30, negative_max_cpa_multiple: 3, expansion_min_conversions: 3, list_size: 50 }
};
function main() {
  selectAccount_(CONFIG.account_id);
  const ss = openSpreadsheet_(CONFIG.spreadsheet_url);
  const tz = tz_(), cid = AdsApp.currentAccount().getCustomerId(), currency = AdsApp.currentAccount().getCurrencyCode();
  ss.setSpreadsheetTimeZone(tz);
  ensureTabs_(ss, ['summary', 'account_ngrams', 'campaign_ngrams', 'word_count', 'suggestions'].map(k => ({ name: t_('tab.' + k), aliases: T['alias.' + k] })));
  const win = window_(CONFIG.days, 0, tz);
  const campaigns = query_(`SELECT campaign.id, campaign.name, campaign.advertising_channel_type FROM campaign
    WHERE segments.date BETWEEN '${win.start}' AND '${win.end}' AND metrics.impressions > 0${campaignWhere_()}`)
    .map(r => ({ id: String(r.campaign.id), name: r.campaign.name, channel: r.campaign.advertisingChannelType }));
  if (!campaigns.length) { writeEmptyScope_(ss); return; }
  const search = campaigns.filter(c => c.channel !== 'PERFORMANCE_MAX'), pmax = campaigns.filter(c => c.channel === 'PERFORMANCE_MAX');
  const neg = CONFIG.check_negatives ? fetchNegatives_(campaigns.map(c => c.id), CONFIG.language) : null;
  let terms = [], errors = [];
  if (CONFIG.include_search && search.length) terms = terms.concat(fetchSearchTerms_(win, search.map(c => c.id)));
  if (CONFIG.include_pmax && pmax.length) { try { terms = terms.concat(fetchPmaxTerms_(win, pmax)); } catch (e) { errors.push('PMax terms: FAILED — ' + e); Logger.log(errors[0]); } }
  if (neg && neg.accountError) errors.push(t_('note.account_neg_failed') + ' ' + neg.accountError);
  if (TRUNCATED_) errors.push(t_('note.truncated_queries', { n: TRUNCATED_ }));
  const res = ngramAnalyse_(terms, neg, Object.assign({ rankBy: 'cost' }, CONFIG));
  const sugg = suggest_(res.account, res.totals);
  writeSummary_(ss, cid, currency, win, res, sugg, campaigns.length, errors);
  writeNgrams_(ss, 'account_ngrams', res.account, [], null, CONFIG.max_rows_written);
  if (CONFIG.segment_by_campaign) writeNgrams_(ss, 'campaign_ngrams', res.campaign, ['campaign', 'source'], null, CONFIG.max_rows_written);
  table_(ss.getSheetByName(t_('tab.word_count')), 1, [t_('col.words_in_query')].concat(ngHead_()), res.wordCount.map(w => [w.words].concat(ngRow_(w))), ['@'].concat(ngFmt_()));
  writeSuggestions_(ss, sugg);
  seal_(ss, t_('tab.summary'));
  Logger.log(`Done: ${res.queries} queries, ${res.excluded} excluded by negatives, ${res.account.length} account n-grams.`);
}
function writeEmptyScope_(ss) {
  ['summary','account_ngrams','campaign_ngrams','word_count','suggestions'].forEach(k => { const sh = ss.getSheetByName(t_('tab.' + k)); if (sh) sh.getRange(1, 1, sh.getMaxRows(), sh.getMaxColumns()).clearContent(); });
  const sh = ss.getSheetByName(t_('tab.summary')); sh.getRange('A1').setValue(t_('title')).setFontWeight('bold').setFontSize(13); sh.getRange('A3').setValue(t_('note.no_campaigns'));
  seal_(ss, t_('tab.summary')); Logger.log(t_('note.no_campaigns'));
}
function campaignWhere_() {
  let w = '';
  if (CONFIG.campaign_contains) w += ` AND campaign.name LIKE '%${escLike_(CONFIG.campaign_contains)}%'`;
  if (CONFIG.campaign_not_contains) w += ` AND campaign.name NOT LIKE '%${escLike_(CONFIG.campaign_not_contains)}%'`;
  w += ` AND campaign.advertising_channel_type IN ('SEARCH','SHOPPING','PERFORMANCE_MAX')`;   // DSA lives under SEARCH; Display/Video/Demand Gen have no search terms
  return w + (CONFIG.enabled_campaigns_only ? ` AND campaign.status = 'ENABLED'` : ` AND campaign.status IN ('ENABLED','PAUSED')`);
}
function fetchSearchTerms_(win, ids) {
  const out = [];
  chunks_(ids, 200).forEach(chunk => query_(`SELECT campaign.id, campaign.name, ad_group.id, search_term_view.search_term,
      metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions, metrics.conversions_value
    FROM search_term_view WHERE segments.date BETWEEN '${win.start}' AND '${win.end}' AND campaign.id IN (${chunk.join(',')})
      AND metrics.impressions >= ${CONFIG.min_query_impressions}`)
    .forEach(r => out.push({ source: 'SEARCH', campaignId: String(r.campaign.id), campaign: r.campaign.name, adGroupId: String(r.adGroup.id), term: r.searchTermView.searchTerm, m: rawOf_(r) })));
  return out;
}
/* campaign_search_term_view: campaign-level PMax search terms WITH cost. No keyword-related segments here — they would drop PMax rows. */
function fetchPmaxTerms_(win, campaigns) {
  const out = [], names = {}; campaigns.forEach(c => names[c.id] = c.name);
  chunks_(campaigns.map(c => c.id), 200).forEach(chunk => query_(`SELECT campaign.id, campaign_search_term_view.search_term,
      metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions, metrics.conversions_value
    FROM campaign_search_term_view WHERE segments.date BETWEEN '${win.start}' AND '${win.end}' AND campaign.id IN (${chunk.join(',')})
      AND metrics.impressions >= ${CONFIG.min_query_impressions}`)
    .forEach(r => { const t = r.campaignSearchTermView && r.campaignSearchTermView.searchTerm; if (!t) return;
      out.push({ source: 'PMAX', campaignId: String(r.campaign.id), campaign: names[String(r.campaign.id)] || String(r.campaign.id), adGroupId: '', term: t, m: rawOf_(r) }); }));
  return out;
}
function suggest_(rows, tot) {
  const s = CONFIG.suggestions;
  const expected = r => isFinite(tot.cr) ? r.clicks * tot.cr : NaN;   // conversions the account's rate would predict for this many clicks
  const negatives = rows.filter(r => (r.clicks >= s.negative_min_clicks && r.conversions === 0) ||
      (isFinite(tot.cpa) && isFinite(r.cpa) && r.clicks >= s.negative_min_clicks && r.cpa > tot.cpa * s.negative_max_cpa_multiple))
    .map(r => Object.assign({ expected: expected(r), reason: (r.conversions === 0 ? t_('why.no_conv', { clicks: r.clicks }) : t_('why.cpa', { cpa: fmtNum_(r.cpa, 2), acc: fmtNum_(tot.cpa, 2) })) +
      (isFinite(expected(r)) ? ' · ' + t_('why.expected', { n: fmtNum_(expected(r), 1) }) : '') }, r))
    .sort((x, y) => y.cost - x.cost).slice(0, s.list_size);
  const expansions = rows.filter(r => r.conversions >= s.expansion_min_conversions && ((isFinite(tot.roas) && r.roas > tot.roas * 1.25) || (isFinite(tot.cr) && r.cr > tot.cr * 1.25)))
    .map(r => Object.assign({ reason: (isFinite(tot.roas) && r.roas > tot.roas * 1.25) ? t_('why.roas', { v: fmtNum_(r.roas, 2), acc: fmtNum_(tot.roas, 2) }) : t_('why.cr', { v: fmtPct_(r.cr, 1), acc: fmtPct_(tot.cr, 1) }) }, r))
    .sort((x, y) => y.conversions - x.conversions).slice(0, s.list_size);
  return { negatives, expansions };
}
function writeSuggestions_(ss, sugg) {
  const sh = ss.getSheetByName(t_('tab.suggestions')); sh.clear();
  const head = [t_('col.n'), t_('col.phrase'), t_('col.why')].concat(ngHead_()), fmt = ['0', '@', '@'].concat(ngFmt_());
  sh.getRange('A1').setValue(t_('sugg.negatives')).setFontWeight('bold').setFontColor('#B91C1C');
  const n = table_(sh, 2, head, sugg.negatives.map(r => [r.n, r.gram, r.reason].concat(ngRow_(r))), fmt);
  const top = 4 + Math.max(n, 1);
  sh.getRange(top, 1).setValue(t_('sugg.expansions')).setFontWeight('bold').setFontColor('#15803D');
  styleHeader_(sh.getRange(top + 1, 1, 1, head.length).setValues([head]));
  const exp = sugg.expansions.map(r => [r.n, r.gram, r.reason].concat(ngRow_(r)));
  ensureSize_(sh, top + 2 + exp.length, head.length);
  if (exp.length) { fmt.forEach((f, i) => { if (f) sh.getRange(top + 2, i + 1, exp.length, 1).setNumberFormat(f); }); sh.getRange(top + 2, 1, exp.length, head.length).setValues(exp); }
  else sh.getRange(top + 2, 1).setValue(t_('note.no_rows'));
  sh.setFrozenRows(0);
}
function writeSummary_(ss, cid, currency, win, res, sugg, nCamp, errors) {
  const sh = ss.getSheetByName(t_('tab.summary')), t = res.totals;
  sh.getRange(2, 1, Math.max(1, sh.getMaxRows() - 1), Math.max(1, sh.getMaxColumns())).clear();
  sh.getRange('A1').setValue(t_('title')).setFontWeight('bold').setFontSize(13);
  const R = (label, v, fmt) => ({ r: [label, v], fmt: fmt }), H = label => ({ r: [label, ''], head: true });
  const items = [R(t_('col.account'), cid), R(t_('col.period'), win.label), R(t_('col.currency'), currency), R(t_('sum.campaigns'), nCamp),
    R(t_('sum.sources'), [CONFIG.include_search ? 'Search / Shopping / DSA' : null, CONFIG.include_pmax ? 'Performance Max' : null].filter(Boolean).join(' + ')),
    R(t_('sum.queries'), res.queries), R(t_('sum.excluded'), res.excluded), R(t_('sum.sizes'), `${CONFIG.min_ngram}–${CONFIG.max_ngram}`), R(t_('sum.account_ngrams'), res.account.length),
    null, H(t_('sum.totals')), R(t_('m.impressions'), t.impressions, '#,##0'), R(t_('m.clicks'), t.clicks, '#,##0'), R(t_('m.ctr'), nz_(t.ctr), '0.00%'), R(t_('m.cost'), t.cost, '#,##0.00'),
    R(t_('m.conversions'), t.conversions, '#,##0.0'), R(t_('m.value'), t.value, '#,##0.00'), R(t_('m.cr'), nz_(t.cr), '0.00%'), R(t_('m.cpa'), nz_(t.cpa), '#,##0.00'), R(t_('m.roas'), nz_(t.roas), '0.00'),
    null, H(t_('tab.suggestions')), R(t_('sum.neg_candidates'), sugg.negatives.length), R(t_('sum.exp_candidates'), sugg.expansions.length), R(t_('sum.cost_on_negatives'), sugg.negatives.reduce((s, r) => s + r.cost, 0), '#,##0.00'),
    null, H(t_('sum.notes')), R(t_('note.ngram_counting'), ''), R(t_('note.pmax_source'), '')].concat(errors.map(e => R('⚠ ' + e, '')));
  sh.getRange(3, 1, items.length, 1).setNumberFormat('@');
  items.forEach((it, i) => { if (!it) return; const f = it.fmt || (typeof it.r[1] === 'string' ? '@' : null); if (f) sh.getRange(3 + i, 2).setNumberFormat(f); });
  sh.getRange(3, 1, items.length, 2).setValues(items.map(i => i ? i.r : ['', '']));
  items.forEach((it, i) => { if (it && it.head) sh.getRange(3 + i, 1, 1, 2).setFontWeight('bold').setBackground('#F1F5F9'); });
  sh.setColumnWidths(1, 1, 340); sh.setColumnWidths(2, 1, 220);
}
3 langsSchedule: Weekly

Performance Max search-term n-gram analysis with real cost data, brand vs generic split, negative awareness and ranked negative candidates for campaign-level exclusion.

// pmax-ngram-lens — EN build 2026-09-14
// Copyright 2026 Gökhan Güzel — gokhanguzel.com. All Rights Reserved. Generated file: edit src/ and i18n/, then run build.js.
// Do not share configured Slack webhook URLs.
'use strict';
const SCRIPT_ID = "pmax-ngram-lens";
const L = {"lang":"en","locale":"en-US","text_lang":"en","dec":".","thou":","};
const ATTRIBUTION = String.fromCharCode(169,32,103,111,107,104,97,110,103,117,122,101,108,46,99,111,109);
const T = {"kpi":"KPI","kpi.now":"This period","kpi.prev":"Previous","yes":"yes","no":"no","mail.full_report":"Full report","mail.open_report":"Open report","note.no_rows":"No rows.","note.truncated":"Showing {shown} of {total} rows (max_rows_written).","note.no_campaigns":"No campaigns with impressions match the current filters.","settings.key":"Key","settings.value":"Value","settings.notes":"Notes","settings.script":"script that owns this Sheet (do not edit)","settings.account":"Google Ads account that owns this Sheet (do not edit)","note.truncated_queries":"⚠ {n} quer(y/ies) hit the read limit — results incomplete; narrow the window","note.account_neg_failed":"⚠ account-level negatives could not be read:","note.skipped_time":"skipped — execution time budget","note.data_error":"data error:","note.failed":"FAILED —","note.no_final_url":"(no final URL)","alias.settings":["Settings","Ayarlar","Einstellungen"],"alias.history":["History","Kronoloji","Verlauf","Runs","Calismalar","Laeufe"],"alias.snapshot":["Snapshot","Snapshots","Kesit","Kesitler","Momentaufnahme","Momentaufnahmen"],"alias.state":["State","DurumBellegi","Zustand"],"alias.baseline":["Baseline","TemelCizgi","Basislinie"],"m.impressions":"Impr.","m.clicks":"Clicks","m.ctr":"CTR","m.cost":"Cost","m.cpc":"Avg. CPC","m.conversions":"Conv.","m.value":"Conv. value","m.cr":"Conv. rate","m.cpa":"CPA","m.roas":"ROAS","m.aov":"AOV","m.vpc":"Value / click","col.account":"Account","col.period":"Period","col.currency":"Currency","col.date":"Date","col.campaign":"Campaign","col.ad_group":"Ad group","col.n":"N","col.phrase":"Phrase","col.queries":"Queries","col.source":"Source","col.why":"Why","title":"PMax N-Gram Lens — search term analysis","tab.summary":"Summary","tab.account_ngrams":"Account n-grams","tab.campaign_ngrams":"Campaign n-grams","tab.categories":"Categories","tab.brand":"Brand split","tab.suggestions":"Suggestions","tab.terms":"Terms","alias.summary":["Özet","Zusammenfassung"],"alias.account_ngrams":["Hesap n-gramları","Konto-N-Gramme"],"alias.campaign_ngrams":["Kampanya n-gramları","Kampagnen-N-Gramme"],"alias.categories":["Kategoriler","Kategorien","Category n-grams","Kategori n-gramları","Kategorie-N-Gramme"],"alias.brand":["Marka ayrımı","Marken-Split"],"alias.suggestions":["Tavsiyeler","Vorschläge"],"alias.terms":["Terimler","Suchbegriffe"],"col.category":"Category","col.segment":"Segment","col.paste_as":"Paste as","col.wasted_clicks":"Wasted clicks (est.)","col.search_term":"Search term","col.blocked":"Blocked by negative","brand.brand":"Brand","brand.generic":"Generic","uncategorised":"(uncategorised)","sum.pmax_campaigns":"PMax campaigns in scope","sum.queries":"Unique (campaign, term) rows analysed","sum.excluded":"Rows excluded by negatives","sum.sizes":"N-gram sizes","sum.account_ngrams":"Account n-grams (after thresholds)","sum.totals":"Totals","sum.brand_split":"Brand vs generic (share of clicks)","sum.neg_candidates":"Negative candidates","sum.cost_on_negatives":"Cost sitting on negative candidates","sum.notes":"Notes","sugg.negatives_pmax":"Negative candidates — review, then add as phrase negatives to the campaign (never auto-applied)","why.no_conv":"{clicks} clicks, 0 conversions","why.cpa":"CPA {cpa} vs account {acc}","why.cr_low":"Conv. rate {v} vs account {acc}","note.pmax_view":"Source: campaign_search_term_view — real search terms with cost, campaign level.","note.negatives_pmax":"Negatives checked: campaign-level keywords, attached shared lists (by ID), account-level negatives.","note.ngram_counting":"Each n-gram is counted once per query; broad negatives match any word order, phrase contiguous, exact the whole query.","note.categories_off":"Category roll-up is off (CONFIG.include_categories). It needs one extra query per campaign.","note.brand_off":"(set CONFIG.brand_terms to enable the split)"};

/* ===================== common/core ===================== */
/* Pure helpers. No Sheets, no network. Everything here is unit-tested in test/run.js. */

/* AdsApp.search is report-backed and is not subject to selector entity limits. Keep a defensive count check only for
   runtimes/iterators that expose totalNumEntities(); normally this stays at zero. */
let TRUNCATED_ = 0;
function query_(gaql) {
  const out = [];
  const it = AdsApp.search(gaql);
  while (it.hasNext()) out.push(it.next());
  try { if (typeof it.totalNumEntities === 'function') { const n = it.totalNumEntities(); if (n > out.length) { TRUNCATED_++; Logger.log(`Query truncated: ${out.length} of ${n} rows read — narrow the window or filters. ${gaql.slice(0, 120)}`); } } } catch (e) {}
  return out;
}
/* Seconds left in the 30-minute execution window (Infinity when the runtime does not expose it, e.g. in the mock). */
function timeLeft_() { try { return AdsApp.getExecutionInfo().getRemainingTime(); } catch (e) { return Infinity; } }
function num_(v) { const x = parseFloat(v); return isFinite(x) ? x : 0; }
function nz_(v) { return isFinite(v) ? v : ''; }
function esc_(s) { return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'"); }
function escLike_(s) { const m = { '[': '[[]', ']': '[]]', '%': '[%]', '_': '[_]' }; return esc_(s).replace(/[\[\]%_]/g, c => m[c]); }
function chunks_(arr, n) { const o = []; for (let i = 0; i < arr.length; i += n) o.push(arr.slice(i, i + n)); return o; }
function pct_(a, b) { return (typeof a === 'number' && typeof b === 'number' && isFinite(a) && isFinite(b) && b) ? a / b - 1 : NaN; }

/* Number formatting for e-mail / Slack. Deterministic: separators come from the build (L.dec / L.thou), not from
   the runtime's ICU data. */
function fmtNum_(v, digits) {
  if (typeof v !== 'number' || !isFinite(v)) return '–';
  const d = digits === undefined ? (Math.abs(v) >= 1000 ? 0 : Math.abs(v) < 1 ? 3 : 2) : digits;
  const parts = Math.abs(v).toFixed(d).split('.');
  return (v < 0 ? '-' : '') + parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, L.thou) + (parts[1] ? L.dec + parts[1] : '');
}
function fmtPct_(v, digits) { return isFinite(v) ? fmtNum_(v * 100, digits === undefined ? 2 : digits) + '%' : '–'; }

/* Metric bag: raw sums + derived rates. NaN means "undefined", never 0. */
function zeroM_() { return { impressions: 0, clicks: 0, cost: 0, conversions: 0, value: 0 }; }
function rawOf_(r, opts) {
  const m = (r && r.metrics) || {}; opts = opts || {};
  return {
    impressions: num_(m.impressions), clicks: num_(m.clicks),
    cost: opts.noCost ? 0 : num_(m.costMicros) / 1e6,
    conversions: opts.noConv ? NaN : num_(m.conversions),
    value: opts.noConv ? NaN : num_(m.conversionsValue)
  };
}
function addM_(a, b) {
  const sumKnown = (x, y) => isFinite(x) && isFinite(y) ? x + y : NaN;
  return {
    impressions: a.impressions + b.impressions, clicks: a.clicks + b.clicks, cost: a.cost + b.cost,
    conversions: sumKnown(a.conversions, b.conversions),
    value: sumKnown(a.value, b.value)
  };
}
function derive_(o) {
  o.ctr = o.impressions ? o.clicks / o.impressions : NaN;
  o.cpc = o.clicks ? o.cost / o.clicks : NaN;
  o.cr = o.clicks && isFinite(o.conversions) ? o.conversions / o.clicks : NaN;
  o.cpa = o.conversions > 0 ? o.cost / o.conversions : NaN;
  o.roas = o.cost > 0 && isFinite(o.value) ? o.value / o.cost : NaN;
  o.aov = o.conversions > 0 ? o.value / o.conversions : NaN;
  o.vpc = o.clicks ? o.value / o.clicks : NaN;
  return o;
}

/* Text normalisation for search terms / negatives. language: 'tr' handles İ/ı. */
function normalize_(s, language) {
  s = String(s || '');
  s = language === 'tr' ? s.replace(/I/g, 'ı').replace(/İ/g, 'i').toLowerCase() : s.toLowerCase();
  return s.replace(/[+\[\]"“”'’`^*]/g, ' ')
          .replace(/[^\p{L}\p{N}\s\-\.]/gu, ' ')
          .split(/\s+/).map(w => w.replace(/^[.\-]+|[.\-]+$/g, '')).filter(Boolean).join(' ');
}
function tokens_(s, language) { const n = normalize_(s, language); return n ? n.split(' ') : []; }
function hasSubseq_(hay, needle) {
  outer: for (let i = 0; i + needle.length <= hay.length; i++) {
    for (let j = 0; j < needle.length; j++) if (hay[i + j] !== needle[j]) continue outer;
    return true;
  }
  return false;
}
/* exact = same tokens; phrase = contiguous; broad = every word present. */
function blocked_(q, negs) {
  if (!negs) return false;
  for (const n of negs) {
    if (!n.words.length) continue;
    if (n.mt === 'EXACT') { if (n.words.length === q.length && n.words.every((w, i) => w === q[i])) return true; }
    else if (n.mt === 'PHRASE') { if (hasSubseq_(q, n.words)) return true; }
    else if (n.words.every(w => q.includes(w))) return true;
  }
  return false;
}

/* Robust stats: median / MAD (σ-scaled), SD fallback. */
function median_(sorted) { const n = sorted.length; if (!n) return NaN; return n % 2 ? sorted[(n - 1) / 2] : (sorted[n / 2 - 1] + sorted[n / 2]) / 2; }
function robust_(arr) {
  if (!arr.length) return { median: NaN, mad: NaN, scale: 0 };
  const s = arr.slice().sort((a, b) => a - b), med = median_(s);
  const mad = median_(s.map(v => Math.abs(v - med)).sort((a, b) => a - b)) * 1.4826;
  let scale = mad;
  if (!(scale > 0)) { const mean = arr.reduce((x, v) => x + v, 0) / arr.length; scale = Math.sqrt(arr.reduce((x, v) => x + (v - mean) * (v - mean), 0) / Math.max(1, arr.length - 1)); }
  return { median: med, mad: mad, scale: scale };
}

/* Minimal HTML escape for anything user-generated that lands in e-mail. */
function h_(s) { return String(s === undefined || s === null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
/* Text lookup. T is injected by the build; keys are stable, values are per language. */
function t_(key, vars) {
  let s = T[key]; if (s === undefined) s = key;
  return String(s).replace(/\{(\w+)\}/g, (m, k) => (vars && vars[k] !== undefined) ? vars[k] : m);
}

/* ===================== common/dates ===================== */
/* Calendar-based date arithmetic in the account time zone. No 864e5 shifts,
   so DST transitions cannot skip or repeat a day. */
function tz_() { return AdsApp.currentAccount().getTimeZone(); }
function todayStr_(tz, base) { return Utilities.formatDate(base || new Date(), tz, 'yyyy-MM-dd'); }
function shiftDay_(dateStr, days) {
  const p = dateStr.split('-').map(Number);
  const d = new Date(Date.UTC(p[0], p[1] - 1, p[2] + days));
  return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + String(d.getUTCDate()).padStart(2, '0');
}
/* window_(days, offset): `days` complete days, ending `offset+1` days before today. offset 0 → ends yesterday. */
function window_(days, offset, tz, base) {
  const today = todayStr_(tz, base);
  const end = shiftDay_(today, -(offset + 1)), start = shiftDay_(today, -(offset + days));
  return { start: start, end: end, label: start + ' → ' + end };
}
const DOW_ = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY'];
function dowOf_(dateStr) { const p = dateStr.split('-').map(Number); return DOW_[new Date(Date.UTC(p[0], p[1] - 1, p[2])).getUTCDay()]; }
/* Sheets may hand back a Date where we wrote a 'yyyy-MM-dd' string. Always normalise on read. */
function dateStrOf_(v, tz) {
  if (v instanceof Date) return Utilities.formatDate(v, tz, 'yyyy-MM-dd');
  return String(v === null || v === undefined ? '' : v).trim();   // strings are never truncated: keys like 'yyyy-MM-dd HH:mm' or '123-456-7890' stay intact
}

/* ===================== common/manager ===================== */
/* Runs as a single account, or selects one client account when started from a manager account. */
function isManager_() { try { if (typeof AdsManagerApp === 'undefined') return false; AdsManagerApp.accounts().withLimit(1).get(); return true; } catch (e) { return false; } }
function selectAccount_(accountId) {
  if (!isManager_()) return;
  if (!accountId) throw new Error('Running from a manager account: set CONFIG.account_id (e.g. 123-456-7890).');
  const it = AdsManagerApp.accounts().withIds([String(accountId)]).get();
  if (!it.hasNext()) throw new Error('Account ' + accountId + ' is not a client of this manager account.');
  AdsManagerApp.select(it.next());
}

/* ===================== common/sheets ===================== */
/* Technical tabs are language-independent. User-facing tab names come from T. */
const TECH = { settings: '_settings', state: '_state', snapshot: '_snapshot', history: '_history', baseline: '_baseline' };

function openSpreadsheet_(url) {
  if (!url || url === 'YOUR_SPREADSHEET_URL') throw new Error('Set CONFIG.spreadsheet_url to a Google Sheet you own.');
  const ss = SpreadsheetApp.openByUrl(url);
  claimSheet_(ss);              // ownership first: nothing is written to a Sheet that belongs to another script or account
  return seal_(ss);             // every script opens its Sheet through the attribution layer
}
/* One Sheet per script. The first run records SCRIPT_ID in _settings; a different script pointed at the same Sheet stops
   before it can overwrite tabs (ngram-lens and pmax-ngram-lens share tab names). */
function claimSheet_(ss) {
  const sh = tab_(ss, TECH.settings, T['alias.settings']);
  const cid = AdsApp.currentAccount().getCustomerId();
  const kv = {}; if (sh.getLastRow() > 1) sh.getRange(2, 1, sh.getLastRow() - 1, 2).getValues().forEach(r => kv[String(r[0])] = String(r[1]));
  if (kv.script && kv.script !== SCRIPT_ID) throw new Error(`This Sheet belongs to ${kv.script}. Use a separate Sheet for ${SCRIPT_ID}.`);
  if (kv.account && kv.account !== cid) throw new Error(`This Sheet belongs to account ${kv.account}; running as ${cid}. Use a separate Sheet per account.`);
  if (!kv.script || !kv.account) ensureSettings_(ss, []);
}
/* Title in A1, header in row `top` (2). Writes only what is missing, so the attribution cell in H1 never masks an empty
   header row. Returns true when the header was (re)written. */
function ensureTitledHeader_(sheet, title, header) {
  ensureSize_(sheet, 3, header.length);
  if (String(sheet.getRange(1, 1).getValue()) === '') sheet.getRange(1, 1).setValue(title).setFontWeight('bold').setFontSize(13);
  const a2 = sheet.getRange(2, 1).getValue();
  if (String(a2) === header[0]) return false;
  if (String(a2) !== '') sheet.insertRowsBefore(2, 1);   // data written under a missing header (v2.2 layout): push it down, then add the header
  styleHeader_(sheet.getRange(2, 1, 1, header.length).setValues([header])); sheet.setFrozenRows(2);
  return true;
}
/* Find a tab by its current name or any legacy alias; rename legacy tabs once so state survives a language switch. */
function tab_(ss, name, aliases) {
  let sh = ss.getSheetByName(name);
  if (!sh && aliases) for (const a of aliases) { const old = ss.getSheetByName(a); if (old) { old.setName(name); sh = old; break; } }
  if (!sh) sh = ss.insertSheet(name);
  return sh;
}
/* specs: [{name, aliases:[...]}]. Removes the empty default tab whatever its locale name is. */
function ensureTabs_(ss, specs) {
  const wanted = new Set(specs.map(s => s.name));
  specs.forEach(s => tab_(ss, s.name, s.aliases));
  const first = ss.getSheets()[0];
  if (first && !wanted.has(first.getName()) && ss.getSheets().length > 1 && onlyAttribution_(first)) ss.deleteSheet(first);
}
/* A default tab that holds nothing but the attribution cell written by the early seal_ is still "empty". */
function onlyAttribution_(sheet) {
  if (sheet.getLastRow() === 0) return true;
  if (sheet.getLastRow() > 1) return false;
  const row = sheet.getRange(1, 1, 1, Math.max(1, sheet.getLastColumn())).getValues()[0];
  return row.every((v, i) => v === '' || (i === 7 && v === ATTRIBUTION));
}
/* Grow the grid BEFORE any getRange: Sheets throws on coordinates outside the current dimensions. */
function ensureSize_(sheet, rows, cols) {
  if (cols > sheet.getMaxColumns()) sheet.insertColumnsAfter(sheet.getMaxColumns(), cols - sheet.getMaxColumns());
  if (rows > sheet.getMaxRows()) sheet.insertRowsAfter(sheet.getMaxRows(), rows - sheet.getMaxRows());
}
function styleHeader_(rng) { return rng.setFontWeight('bold').setBackground('#1E293B').setFontColor('#ffffff'); }

/* Write a table. Rows above `top` are never touched (callers put titles there). formats[i] is a number format per column ('@' = text, applied BEFORE setValues so Sheets never auto-parses).
   Rows beyond `cap` are dropped and a note is written. Returns the number of rows written. */
function table_(sheet, top, header, rows, formats, cap) {
  let out = rows;
  if (cap && rows.length > cap) out = rows.slice(0, cap);
  ensureSize_(sheet, top + out.length + 1, header.length);
  const maxR = sheet.getMaxRows(), maxC = sheet.getMaxColumns();
  if (maxR >= top) sheet.getRange(top, 1, maxR - top + 1, maxC).clearContent();   // contents only: user notes/formats outside the table survive
  styleHeader_(sheet.getRange(top, 1, 1, header.length).setValues([header]));
  if (out.length) {
    (formats || []).forEach((f, i) => { if (f) sheet.getRange(top + 1, i + 1, out.length, 1).setNumberFormat(f); });
    sheet.getRange(top + 1, 1, out.length, header.length).setValues(out);
    if (out.length < rows.length) sheet.getRange(top + 1 + out.length, 1).setValue(t_('note.truncated', { shown: out.length, total: rows.length }));
  } else {
    sheet.getRange(top + 1, 1).setValue(t_('note.no_rows'));
  }
  sheet.setFrozenRows(top);
  return out.length;
}
/* Append rows with text formats applied first (appendRow cannot do that). */
function appendRows_(sheet, rows, formats) {
  if (!rows.length) return;
  const r0 = sheet.getLastRow() + 1, n = rows.length, w = rows[0].length;
  ensureSize_(sheet, r0 + n - 1, w);
  (formats || []).forEach((f, i) => { if (f) sheet.getRange(r0, i + 1, n, 1).setNumberFormat(f); });
  sheet.getRange(r0, 1, n, w).setValues(rows);
}
/* A technical tab whose header row differs from what this version writes (older layout) is renamed to an archive tab and a
   fresh one is created — rows of two schemas are never mixed. Returns the sheet to write to. */
function ensureSchema_(ss, name, header) {
  const sh = ss.getSheetByName(name); if (!sh || sh.getLastRow() === 0) return sh;
  const cur = sh.getRange(1, 1, 1, Math.max(1, sh.getLastColumn())).getValues()[0].map(String);
  while (cur.length && cur[cur.length - 1] === '') cur.pop();
  if (cur.join('|') === header.join('|')) return sh;
  let n = 1, archive = name + '_archive'; while (ss.getSheetByName(archive)) archive = name + '_archive' + (++n);
  sh.setName(archive); Logger.log(`${name}: old layout archived as ${archive}`);
  return ss.insertSheet(name);
}
/* Idempotent history: one row per key (first `keyCols` columns, joined). Existing row with the same key is replaced. */
function upsertRow_(sheet, header, row, keyCols, formats, tz) {
  ensureSize_(sheet, 2, Math.max(header.length, row.length));
  const cur = sheet.getLastRow() === 0 ? [] : sheet.getRange(1, 1, 1, Math.max(1, sheet.getLastColumn())).getValues()[0].map(String).filter(Boolean);
  if (cur.join('|') !== header.join('|')) {
    const w = Math.max(header.length, sheet.getLastColumn()); sheet.getRange(1, 1, 1, w).clearContent();
    styleHeader_(sheet.getRange(1, 1, 1, header.length).setValues([header])); sheet.setFrozenRows(1);
  }   // new/changed schema: clear stale cells, then rewrite header
  const key = row.slice(0, keyCols).map(v => dateStrOf_(v, tz)).join('|');
  const last = sheet.getLastRow();
  if (last > 1) {
    const keys = sheet.getRange(2, 1, last - 1, keyCols).getValues().map(r => r.map(v => dateStrOf_(v, tz)).join('|'));
    const i = keys.indexOf(key);
    if (i >= 0) {
      const w = Math.max(row.length, sheet.getLastColumn()); sheet.getRange(i + 2, 1, 1, w).clearContent();
      sheet.getRange(i + 2, 1, 1, row.length).setValues([row]); return;
    }
  }
  appendRows_(sheet, [row], formats);
}
/* Replace a whole table safely: write the new rows first, then clear whatever is left below. */
function replaceRows_(sheet, header, rows, formats) {
  const n = rows.length;
  ensureSize_(sheet, n + 1, header.length);
  if (sheet.getLastRow() === 0) styleHeader_(sheet.getRange(1, 1, 1, header.length).setValues([header]));
  if (n) {
    (formats || []).forEach((f, i) => { if (f) sheet.getRange(2, i + 1, n, 1).setNumberFormat(f); });
    sheet.getRange(2, 1, n, header.length).setValues(rows);
  }
  const last = sheet.getLastRow();
  if (last > n + 1) sheet.getRange(n + 2, 1, last - n - 1, Math.max(header.length, sheet.getLastColumn())).clearContent();
}

/* _settings: Key | Value | Notes. Keys are stable identifiers (never translated); notes are per language. */
function ensureSettings_(ss, defaults) {
  const sh = tab_(ss, TECH.settings, T['alias.settings']);
  ensureSize_(sh, 2 + defaults.length, 3);
  if (sh.getLastRow() === 0) {
    sh.getRange(1, 1, 1, 3).setNumberFormat('@').setValues([[t_('settings.key'), t_('settings.value'), t_('settings.notes')]]);
    styleHeader_(sh.getRange(1, 1, 1, 3)); sh.setFrozenRows(1); sh.setColumnWidths(1, 1, 200); sh.setColumnWidths(3, 1, 420);
  }
  const all = [['script', SCRIPT_ID, t_('settings.script')], ['account', AdsApp.currentAccount().getCustomerId(), t_('settings.account')]].concat(defaults);
  const last = sh.getLastRow();
  const keys = last > 1 ? sh.getRange(2, 1, last - 1, 1).getValues().map(r => String(r[0])) : [];
  const missing = all.filter(d => keys.indexOf(d[0]) < 0);
  if (missing.length) { const r0 = sh.getLastRow() + 1; ensureSize_(sh, r0 + missing.length, 3); sh.getRange(r0, 1, missing.length, 3).setNumberFormat('@').setValues(missing.map(d => [d[0], d[1], d[2]])); }
  if (keys.length) {   // notes follow the current language; one batched write, only when something differs; values are never touched
    const rng = sh.getRange(2, 3, keys.length, 1), cur = rng.getValues().map(r => String(r[0]));
    const want = keys.map((k, i) => { const d = all.find(x => x[0] === k); return d && d[2] ? d[2] : cur[i]; });
    if (want.join('\u0001') !== cur.join('\u0001')) rng.setValues(want.map(v => [v]));
  }
  return sh;
}
/* Reads _settings into cfg. Only keys present in `schema` are accepted; type comes from schema: 'num' | 'bool' | 'str' | 'list'. */
function readSettings_(ss, cfg, schema) {
  const out = JSON.parse(JSON.stringify(cfg));
  const sh = ss.getSheetByName(TECH.settings);
  if (!sh || sh.getLastRow() < 2) return out;
  sh.getRange(2, 1, sh.getLastRow() - 1, 2).getValues().forEach(([k, v]) => {
    const type = schema[k]; if (!type || v === '' || v === null) return;
    if (type === 'num') {
      if (typeof v === 'number') { out[k] = v; return; }
      const s = String(v).trim().replace(/\s/g, '');
      if (!/^-?\d+([.,]\d+)?$/.test(s)) throw new Error(`_settings.${k}: "${v}" is not a number (use 0.5 or 0,5)`);
      if (/^-?\d{1,3}[.,]\d{3}$/.test(s)) throw new Error(`_settings.${k}: "${v}" is ambiguous; do not use thousands separators (write 1000 or 0.5 / 0,5)`);
      out[k] = parseFloat(s.replace(',', '.'));   // decimal comma/dot accepted; ambiguous 1.000 / 1,000 is rejected
    }
    else if (type === 'bool') out[k] = /^(true|yes|1|on|evet|ja)$/i.test(String(v).trim());
    else if (type === 'list') out[k] = String(v).split(',').map(s => s.trim()).filter(Boolean);
    else out[k] = String(v).trim();
  });
  return out;
}
function writeSetting_(ss, key, value) {
  const sh = ss.getSheetByName(TECH.settings); if (!sh) return;
  const last = sh.getLastRow();
  const keys = last > 1 ? sh.getRange(2, 1, last - 1, 1).getValues().map(r => String(r[0])) : [];
  const i = keys.indexOf(key);
  const cell = i >= 0 ? sh.getRange(i + 2, 2) : sh.getRange(last + 1, 2);
  if (i < 0) sh.getRange(last + 1, 1).setNumberFormat('@').setValue(key);
  cell.setNumberFormat('@').setValue(value);
}

/* ===================== common/seal ===================== */
/* Attribution cell. Warning-only protection (no editor management → no Session dependency, no lock-out of the
   running user). Never throws: a report must not fail because a Sheet cell was edited.
   Called twice: from openSpreadsheet_ (first tab, before anything else) and at the end of main() on the report tab.
   Returns the spreadsheet so the open path depends on it. This is attribution, not DRM: anyone who edits the
   source can remove it. */
function seal_(ss, tabName) {
  try {
    const sheet = (tabName && ss.getSheetByName(tabName)) || ss.getSheets().find(s => !s.getName().startsWith('_')) || ss.getSheets()[0];
    const want = ATTRIBUTION;
    const range = sheet.getRange('H1:K1');
    let cur = ''; try { cur = String(range.getCell(1, 1).getValue()); } catch (e) {}
    if (cur !== want) {
      try { range.breakApart(); range.merge(); } catch (e) {}
      range.getCell(1, 1).setValue(want).setFontWeight('bold').setFontColor('#0F766E').setHorizontalAlignment('center').setNote(want);
    }
    const has = sheet.getProtections(SpreadsheetApp.ProtectionType.RANGE).some(p => p.getDescription() === want);
    if (!has) range.protect().setDescription(want).setWarningOnly(true);
  } catch (e) { Logger.log('Attribution skipped: ' + e); }
  return ss;
}

/* ===================== common/notify ===================== */
function htmlShell_(title, subtitle, body, url) {
  return '<div style="font:13px Arial,sans-serif;color:#1E293B;max-width:760px">' +
    '<div style="background:#1E293B;color:#fff;padding:12px 16px;font-size:15px;font-weight:bold">' + h_(title) + '</div>' +
    '<div style="padding:10px 16px;color:#64748B">' + h_(subtitle) + '</div>' + body +
    '<div style="padding:12px 16px;color:#94A3B8;font-size:11px">' + (url ? h_(t_('mail.full_report')) + ': <a href="' + h_(url) + '">' + h_(url) + '</a><br>' : '') + h_(ATTRIBUTION) + '</div></div>';
}
function kpiTable_(rows) {
  /* rows: [[label, now, prev, delta_html]] */
  const th = (s, r) => '<th style="padding:5px 10px;text-align:' + (r ? 'right' : 'left') + '">' + h_(s) + '</th>';
  return '<table style="border-collapse:collapse;width:100%"><tr style="background:#F1F5F9;color:#64748B;font-size:11px">' +
    th(t_('kpi'), false) + th(t_('kpi.now'), true) + th(t_('kpi.prev'), true) + th('Δ', true) + '</tr>' +
    rows.map(r => '<tr><td style="padding:5px 10px">' + h_(r[0]) + '</td><td style="padding:5px 10px;text-align:right;font-weight:bold">' + h_(r[1]) +
      '</td><td style="padding:5px 10px;text-align:right;color:#64748B">' + h_(r[2]) + '</td><td style="padding:5px 10px;text-align:right">' + r[3] + '</td></tr>').join('') + '</table>';
}
function delta_(a, b, invert) {
  const v = pct_(a, b); if (!isFinite(v)) return '';
  const good = invert ? v < 0 : v > 0;
  return '<span style="color:' + (good ? '#15803D' : '#B91C1C') + '">' + (v >= 0 ? '▲' : '▼') + ' ' + fmtPct_(Math.abs(v), 0) + '</span>';
}
function sendMail_(to, subject, html, text) {
  if (!to) return false;
  try { MailApp.sendEmail({ to: to, subject: subject, htmlBody: html, body: text || subject }); return true; }
  catch (e) { Logger.log('E-mail failed: ' + e); return false; }
}
function slackText_(s) { return String(s === undefined || s === null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
function sendSlack_(webhook, text) {
  if (!webhook) return false;
  try {
    const res = UrlFetchApp.fetch(webhook, { method: 'post', contentType: 'application/json', muteHttpExceptions: true, payload: JSON.stringify({ text: text + '\n_' + slackText_(ATTRIBUTION) + '_' }) });
    const ok = res.getResponseCode() === 200;
    if (!ok) Logger.log('Slack returned ' + res.getResponseCode() + ': ' + res.getContentText().slice(0, 200));
    return ok;
  } catch (e) { Logger.log('Slack failed: ' + e); return false; }
}

/* ===================== common/negatives ===================== */
/* Negative keywords by ID: ad group, campaign, shared lists attached to campaigns, and account level. */
function fetchNegatives_(campaignIds, language, opts) {
  opts = opts || {};
  const neg = { adGroup: {}, campaign: {}, account: [] };
  const item = (text, mt) => ({ words: tokens_(text, language), mt: String(mt || '').toUpperCase() });
  const push = (bucket, key, text, mt) => { (bucket[key] = bucket[key] || []).push(item(text, mt)); };
  chunks_(campaignIds, 200).forEach(chunk => {
    if (opts.adGroupLevel !== false) {
      query_(`SELECT ad_group.id, ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type
        FROM ad_group_criterion WHERE ad_group_criterion.negative = TRUE AND ad_group_criterion.type = 'KEYWORD'
        AND ad_group_criterion.status != 'REMOVED' AND campaign.id IN (${chunk.join(',')})`)
        .forEach(r => push(neg.adGroup, String(r.adGroup.id), r.adGroupCriterion.keyword.text, r.adGroupCriterion.keyword.matchType));
    }
    query_(`SELECT campaign.id, campaign_criterion.keyword.text, campaign_criterion.keyword.match_type
      FROM campaign_criterion WHERE campaign_criterion.negative = TRUE AND campaign_criterion.type = 'KEYWORD'
      AND campaign_criterion.status != 'REMOVED' AND campaign.id IN (${chunk.join(',')})`)
      .forEach(r => push(neg.campaign, String(r.campaign.id), r.campaignCriterion.keyword.text, r.campaignCriterion.keyword.matchType));
  });
  const setToCampaigns = {};
  chunks_(campaignIds, 200).forEach(chunk => {
    query_(`SELECT campaign.id, shared_set.id FROM campaign_shared_set
      WHERE shared_set.type = 'NEGATIVE_KEYWORDS' AND campaign_shared_set.status = 'ENABLED' AND shared_set.status = 'ENABLED'
      AND campaign.id IN (${chunk.join(',')})`)
      .forEach(r => (setToCampaigns[String(r.sharedSet.id)] = setToCampaigns[String(r.sharedSet.id)] || []).push(String(r.campaign.id)));
  });
  const setIds = Object.keys(setToCampaigns);
  chunks_(setIds, 200).forEach(chunk => {
    query_(`SELECT shared_set.id, shared_criterion.keyword.text, shared_criterion.keyword.match_type
      FROM shared_criterion WHERE shared_criterion.type = 'KEYWORD' AND shared_set.id IN (${chunk.join(',')})`)
      .forEach(r => setToCampaigns[String(r.sharedSet.id)].forEach(cid => push(neg.campaign, cid, r.sharedCriterion.keyword.text, r.sharedCriterion.keyword.matchType)));
  });
  /* Account-level negatives are a shared set of type ACCOUNT_LEVEL_NEGATIVE_KEYWORDS (customer_negative_criterion has no
     keyword field; it only points at that list). */
  try {
    query_(`SELECT shared_criterion.keyword.text, shared_criterion.keyword.match_type
      FROM shared_criterion WHERE shared_set.type = 'ACCOUNT_LEVEL_NEGATIVE_KEYWORDS' AND shared_criterion.type = 'KEYWORD' AND shared_set.status = 'ENABLED'`)
      .forEach(r => neg.account.push(item(r.sharedCriterion.keyword.text, r.sharedCriterion.keyword.matchType)));
  } catch (e) { Logger.log('Account-level negatives not readable: ' + e); neg.accountError = String(e); }
  return neg;
}
function isBlocked_(tok, neg, campaignId, adGroupId) {
  return blocked_(tok, neg.account) || blocked_(tok, neg.campaign[campaignId]) || (adGroupId ? blocked_(tok, neg.adGroup[adGroupId]) : false);
}

/* ===================== common/metrics ===================== */
/* Standard metric block for tables: header / row / number formats, all driven by T and one metric bag. */
const M_KEYS = ['impressions', 'clicks', 'ctr', 'cost', 'cpc', 'conversions', 'value', 'cr', 'cpa', 'roas'];
const M_FMT = ['#,##0', '#,##0', '0.00%', '#,##0.00', '#,##0.00', '#,##0.0', '#,##0.00', '0.00%', '#,##0.00', '0.00'];
function mHead_(keys) { return (keys || M_KEYS).map(k => t_('m.' + k)); }
function mRow_(m, keys) { return (keys || M_KEYS).map(k => nz_(m[k])); }
function mFmt_(keys) { return (keys || M_KEYS).map(k => M_FMT[M_KEYS.indexOf(k)] || null); }

/* ===================== common/ngram ===================== */
/* Shared n-gram engine for N-Gram Lens and PMax N-Gram Lens. terms: [{campaignId, campaign, adGroupId, term, source, categories?, m}] */
function ngramAnalyse_(terms, neg, opts) {
  const lang = opts.language, stop = new Set((opts.stopwords || []).map(w => normalize_(w, lang)));
  const brand = (opts.brand_terms || []).map(b => tokens_(b, lang)).filter(t => t.length);
  const acc = {}, camp = {}, cat = {}, wc = {}, totals = zeroM_(), split = { brand: zeroM_(), generic: zeroM_() };
  const blockedKeys = new Set(); let excluded = 0, queries = 0;
  const bump = (map, key, seed, m) => { const a = map[key] = map[key] || Object.assign(zeroM_(), { queries: 0 }, seed); Object.assign(a, addM_(a, m)); a.queries++; };
  terms.forEach(t => {
    const tok = tokens_(t.term, lang); if (!tok.length) return;
    if (neg && isBlocked_(tok, neg, t.campaignId, t.adGroupId)) { excluded++; blockedKeys.add(t.campaignId + '|' + t.term.toLowerCase()); return; }
    queries++; Object.assign(totals, addM_(totals, t.m));
    const isBrand = brand.length && brand.some(b => hasSubseq_(tok, b));
    const side = isBrand ? split.brand : split.generic; Object.assign(side, addM_(side, t.m)); side.queries = (side.queries || 0) + 1;
    bump(wc, tok.length > 6 ? '7+' : String(tok.length), { words: tok.length > 6 ? '7+' : String(tok.length) }, t.m);
    const seen = new Set();
    for (let n = opts.min_ngram; n <= opts.max_ngram && n <= tok.length; n++) for (let i = 0; i + n <= tok.length; i++) {
      const words = tok.slice(i, i + n); if (n === 1 && stop.has(words[0])) continue;
      const g = words.join(' '), k = n + '|' + g; if (seen.has(k)) continue; seen.add(k);
      bump(acc, k, { n: n, gram: g }, t.m);
      bump(camp, t.campaignId + '|' + k, { n: n, gram: g, campaign: t.campaign, source: t.source }, t.m);
      (t.categories || []).forEach(c => bump(cat, t.campaignId + '|' + c + '|' + k, { n: n, gram: g, campaign: t.campaign, category: c }, t.m));
    }
  });
  derive_(totals); derive_(split.brand); derive_(split.generic);
  const th = opts.thresholds, keep = a => a.queries >= th.query_count && a.impressions >= th.impressions && a.clicks >= th.clicks && (a.cost || 0) >= (th.cost || 0);
  const rank = opts.rankBy === 'clicks' ? ((x, y) => y.clicks - x.clicks || y.impressions - x.impressions || x.gram.localeCompare(y.gram))
                                        : ((x, y) => y.cost - x.cost || y.impressions - x.impressions || x.gram.localeCompare(y.gram));
  return { queries, excluded, totals, split, blockedKeys,
    account: Object.values(acc).map(derive_).filter(keep).sort(rank),
    campaign: Object.values(camp).map(derive_).filter(keep).sort((x, y) => x.campaign.localeCompare(y.campaign) || rank(x, y)),
    category: Object.values(cat).map(derive_).filter(keep).sort((x, y) => x.campaign.localeCompare(y.campaign) || x.category.localeCompare(y.category) || rank(x, y)),
    wordCount: ['1', '2', '3', '4', '5', '6', '7+'].map(k => derive_(Object.assign({ words: k, queries: 0 }, zeroM_(), wc[k] || {}))) };
}
const NG_KEYS = ['queries'].concat(M_KEYS);
function ngHead_(keys) { return (keys || NG_KEYS).map(k => k === 'queries' ? t_('col.queries') : t_('m.' + k)); }
function ngFmt_(keys) { return (keys || NG_KEYS).map(k => k === 'queries' ? '#,##0' : M_FMT[M_KEYS.indexOf(k)] || null); }
function ngRow_(a, keys) { return (keys || NG_KEYS).map(k => nz_(a[k])); }
function writeNgrams_(ss, tabKey, rows, segKeys, keys, cap) {
  const head = segKeys.map(k => t_('col.' + k)).concat([t_('col.n'), t_('col.phrase')]).concat(ngHead_(keys));
  const fmt = segKeys.map(() => '@').concat(['0', '@']).concat(ngFmt_(keys));
  table_(ss.getSheetByName(t_('tab.' + tabKey)), 1, head, rows.map(r => segKeys.map(k => r[k]).concat([r.n, r.gram]).concat(ngRow_(r, keys))), fmt, cap);
}


/**
 * @name PMax N-Gram Lens
 * @overview Performance Max search-term n-gram analysis on campaign_search_term_view (real search terms, with cost).
 *   Optional category roll-up from campaign_search_term_insight (category totals only, never merged with term rows).
 *   Brand vs generic split, campaign-level / shared-list / account-level negative awareness, ranked negative candidates.
 * Schedule: Weekly.
 */
const CONFIG = {
  spreadsheet_url: 'YOUR_SPREADSHEET_URL', account_id: '',
  days: 30, min_ngram: 1, max_ngram: 3,
  campaign_contains: '', campaign_not_contains: '', enabled_campaigns_only: true,
  min_query_impressions: 1, check_negatives: true,
  brand_terms: [], language: L.text_lang, stopwords: [],
  include_categories: false,          // extra query per campaign; can hit RESOURCE_EXHAUSTED on big accounts
  write_raw_terms: true, max_rows_written: 20000,
  thresholds: { query_count: 1, impressions: 20, clicks: 0, cost: 0 },
  suggestions: { negative_min_clicks: 20, negative_max_cr_ratio: 0.25, negative_max_cpa_multiple: 3, list_size: 100 }
};
function main() {
  selectAccount_(CONFIG.account_id);
  const ss = openSpreadsheet_(CONFIG.spreadsheet_url);
  const tz = tz_(), cid = AdsApp.currentAccount().getCustomerId(), currency = AdsApp.currentAccount().getCurrencyCode();
  ss.setSpreadsheetTimeZone(tz);
  ensureTabs_(ss, ['summary', 'account_ngrams', 'campaign_ngrams', 'categories', 'brand', 'suggestions', 'terms'].map(k => ({ name: t_('tab.' + k), aliases: T['alias.' + k] })));
  const win = window_(CONFIG.days, 0, tz);
  let w = `campaign.advertising_channel_type = 'PERFORMANCE_MAX'` + (CONFIG.enabled_campaigns_only ? ` AND campaign.status = 'ENABLED'` : ` AND campaign.status IN ('ENABLED','PAUSED')`);
  if (CONFIG.campaign_contains) w += ` AND campaign.name LIKE '%${escLike_(CONFIG.campaign_contains)}%'`;
  if (CONFIG.campaign_not_contains) w += ` AND campaign.name NOT LIKE '%${escLike_(CONFIG.campaign_not_contains)}%'`;
  const campaigns = query_(`SELECT campaign.id, campaign.name FROM campaign WHERE ${w} AND segments.date BETWEEN '${win.start}' AND '${win.end}' AND metrics.impressions > 0`)
    .map(r => ({ id: String(r.campaign.id), name: r.campaign.name }));
  if (!campaigns.length) { writeEmptyScope_(ss); return; }
  const names = {}; campaigns.forEach(c => names[c.id] = c.name);
  const neg = CONFIG.check_negatives ? fetchNegatives_(campaigns.map(c => c.id), CONFIG.language, { adGroupLevel: false }) : null;
  const terms = [], errors = [];
  try { chunks_(campaigns.map(c => c.id), 200).forEach(chunk => query_(`SELECT campaign.id, campaign_search_term_view.search_term,
      metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions, metrics.conversions_value
    FROM campaign_search_term_view WHERE segments.date BETWEEN '${win.start}' AND '${win.end}' AND campaign.id IN (${chunk.join(',')})
      AND metrics.impressions >= ${CONFIG.min_query_impressions}`)
    .forEach(r => { const t = r.campaignSearchTermView && r.campaignSearchTermView.searchTerm; if (t) terms.push({ source: 'PMAX', campaignId: String(r.campaign.id), campaign: names[String(r.campaign.id)], adGroupId: '', term: t, m: rawOf_(r) }); })); }
  catch (e) { errors.push('campaign_search_term_view: FAILED — ' + e); Logger.log(errors[0]); }
  if (neg && neg.accountError) errors.push(t_('note.account_neg_failed') + ' ' + neg.accountError);
  if (TRUNCATED_) errors.push(t_('note.truncated_queries', { n: TRUNCATED_ }));
  const res = ngramAnalyse_(terms, neg, Object.assign({ rankBy: 'cost' }, CONFIG));
  const sugg = suggest_(res.account, res.totals);
  const cats = CONFIG.include_categories ? fetchCategories_(win, campaigns) : null;
  writeSummary_(ss, cid, currency, win, res, sugg, campaigns.length, errors);
  writeNgrams_(ss, 'account_ngrams', res.account, [], null, CONFIG.max_rows_written);
  writeNgrams_(ss, 'campaign_ngrams', res.campaign, ['campaign'], null, CONFIG.max_rows_written);
  writeCategories_(ss, cats);
  writeBrand_(ss, res.split);
  writeSuggestions_(ss, sugg);
  if (CONFIG.write_raw_terms) writeTerms_(ss, terms, res.blockedKeys);
  seal_(ss, t_('tab.summary'));
  Logger.log(`Done: ${res.queries} terms, ${res.excluded} excluded by negatives, ${res.account.length} account n-grams, ${sugg.length} negative candidates.`);
}
function writeEmptyScope_(ss) {
  ['summary','account_ngrams','campaign_ngrams','categories','brand','suggestions','terms'].forEach(k => { const sh = ss.getSheetByName(t_('tab.' + k)); if (sh) sh.getRange(1, 1, sh.getMaxRows(), sh.getMaxColumns()).clearContent(); });
  const sh = ss.getSheetByName(t_('tab.summary')); sh.getRange('A1').setValue(t_('title')).setFontWeight('bold').setFontSize(13); sh.getRange('A3').setValue(t_('note.no_campaigns'));
  seal_(ss, t_('tab.summary')); Logger.log(t_('note.no_campaigns'));
}
/* Category totals (search term insights). Kept separate: they aggregate differently from term rows. */
function fetchCategories_(win, campaigns) {
  const out = [];
  campaigns.forEach(c => { if (timeLeft_() < 120) { out.push({ campaign: c.name, category: '⚠ ' + t_('note.skipped_time'), m: derive_(zeroM_()) }); return; } try {
    query_(`SELECT campaign_search_term_insight.category_label, metrics.impressions, metrics.clicks, metrics.conversions, metrics.conversions_value
      FROM campaign_search_term_insight WHERE segments.date BETWEEN '${win.start}' AND '${win.end}' AND campaign_search_term_insight.campaign_id = ${c.id}`)
      .forEach(r => out.push({ campaign: c.name, category: (r.campaignSearchTermInsight && r.campaignSearchTermInsight.categoryLabel) || t_('uncategorised'), m: derive_(rawOf_(r, { noCost: true })) }));
  } catch (e) { out.push({ campaign: c.name, category: '⚠ ' + e, m: derive_(zeroM_()) }); } });
  return out;
}
function suggest_(rows, tot) {
  const s = CONFIG.suggestions;
  return rows.filter(r => r.clicks >= s.negative_min_clicks && (r.conversions === 0 || (isFinite(tot.cr) && r.cr < tot.cr * s.negative_max_cr_ratio) || (isFinite(tot.cpa) && isFinite(r.cpa) && r.cpa > tot.cpa * s.negative_max_cpa_multiple)))
    .map(r => Object.assign({ reason: r.conversions === 0 ? t_('why.no_conv', { clicks: r.clicks }) : (isFinite(r.cpa) && isFinite(tot.cpa) && r.cpa > tot.cpa * s.negative_max_cpa_multiple) ? t_('why.cpa', { cpa: fmtNum_(r.cpa, 2), acc: fmtNum_(tot.cpa, 2) }) : t_('why.cr_low', { v: fmtPct_(r.cr), acc: fmtPct_(tot.cr) }),
      wasted: isFinite(tot.cr) && tot.cr > 0 ? Math.max(0, r.clicks - r.conversions / tot.cr) : r.clicks }, r))
    .sort((x, y) => y.cost - x.cost || y.wasted - x.wasted).slice(0, s.list_size);
}
function writeCategories_(ss, cats) {
  const sh = ss.getSheetByName(t_('tab.categories'));
  const keys = ['impressions', 'clicks', 'ctr', 'conversions', 'value', 'cr'];
  if (!cats) { sh.clear(); sh.getRange('A1').setValue(t_('note.categories_off')); return; }
  table_(sh, 1, [t_('col.campaign'), t_('col.category')].concat(mHead_(keys)), cats.sort((x, y) => x.campaign.localeCompare(y.campaign) || y.m.clicks - x.m.clicks).map(c => [c.campaign, c.category].concat(mRow_(c.m, keys))), ['@', '@'].concat(mFmt_(keys)), CONFIG.max_rows_written);
}
function writeBrand_(ss, split) {
  const rows = [[t_('brand.brand')].concat(ngRow_(split.brand)), [t_('brand.generic')].concat(ngRow_(split.generic))];
  table_(ss.getSheetByName(t_('tab.brand')), 1, [t_('col.segment')].concat(ngHead_()), rows, ['@'].concat(ngFmt_()));
  if (!CONFIG.brand_terms.length) ss.getSheetByName(t_('tab.brand')).getRange(5, 1).setValue(t_('note.brand_off'));
}
function writeSuggestions_(ss, sugg) {
  const sh = ss.getSheetByName(t_('tab.suggestions')); sh.clear();
  sh.getRange('A1').setValue(t_('sugg.negatives_pmax')).setFontWeight('bold').setFontColor('#B91C1C');
  table_(sh, 2, [t_('col.n'), t_('col.phrase'), t_('col.paste_as'), t_('col.why'), t_('col.wasted_clicks')].concat(ngHead_()),
    sugg.map(r => [r.n, r.gram, `"${r.gram}"`, r.reason, Math.round(r.wasted)].concat(ngRow_(r))), ['0', '@', '@', '@', '#,##0'].concat(ngFmt_()));
}
function writeTerms_(ss, terms, blocked) {
  const keys = M_KEYS;
  table_(ss.getSheetByName(t_('tab.terms')), 1, [t_('col.campaign'), t_('col.search_term'), t_('col.blocked')].concat(mHead_(keys)),
    terms.slice().sort((x, y) => y.m.cost - x.m.cost || y.m.clicks - x.m.clicks).map(t => [t.campaign, t.term, blocked.has(t.campaignId + '|' + t.term.toLowerCase()) ? t_('yes') : ''].concat(mRow_(derive_(Object.assign({}, t.m)), keys))),
    ['@', '@', '@'].concat(mFmt_(keys)), CONFIG.max_rows_written);
}
function writeSummary_(ss, cid, currency, win, res, sugg, nCamp, errors) {
  const sh = ss.getSheetByName(t_('tab.summary')), t = res.totals, b = res.split;
  sh.getRange(2, 1, Math.max(1, sh.getMaxRows() - 1), Math.max(1, sh.getMaxColumns())).clear();
  sh.getRange('A1').setValue(t_('title')).setFontWeight('bold').setFontSize(13);
  const R = (label, v, fmt) => ({ r: [label, v], fmt: fmt }), H = label => ({ r: [label, ''], head: true });
  const items = [R(t_('col.account'), cid), R(t_('col.period'), win.label), R(t_('col.currency'), currency), R(t_('sum.pmax_campaigns'), nCamp),
    R(t_('sum.queries'), res.queries), R(t_('sum.excluded'), res.excluded), R(t_('sum.sizes'), `${CONFIG.min_ngram}–${CONFIG.max_ngram}`), R(t_('sum.account_ngrams'), res.account.length),
    null, H(t_('sum.totals')), R(t_('m.impressions'), t.impressions, '#,##0'), R(t_('m.clicks'), t.clicks, '#,##0'), R(t_('m.ctr'), nz_(t.ctr), '0.00%'), R(t_('m.cost'), t.cost, '#,##0.00'),
    R(t_('m.conversions'), t.conversions, '#,##0.0'), R(t_('m.value'), t.value, '#,##0.00'), R(t_('m.cr'), nz_(t.cr), '0.00%'), R(t_('m.cpa'), nz_(t.cpa), '#,##0.00'), R(t_('m.roas'), nz_(t.roas), '0.00'),
    null, H(t_('sum.brand_split')), R(t_('brand.brand'), t.clicks ? b.brand.clicks / t.clicks : 0, '0.0%'), R(t_('brand.generic'), t.clicks ? b.generic.clicks / t.clicks : 0, '0.0%'),
    null, H(t_('tab.suggestions')), R(t_('sum.neg_candidates'), sugg.length), R(t_('sum.cost_on_negatives'), sugg.reduce((s, r) => s + r.cost, 0), '#,##0.00'),
    null, H(t_('sum.notes')), R(t_('note.pmax_view'), ''), R(t_('note.negatives_pmax'), ''), R(t_('note.ngram_counting'), '')].concat(errors.map(e => R('⚠ ' + e, '')));
  sh.getRange(3, 1, items.length, 1).setNumberFormat('@');
  items.forEach((it, i) => { if (!it) return; const f = it.fmt || (typeof it.r[1] === 'string' ? '@' : null); if (f) sh.getRange(3 + i, 2).setNumberFormat(f); });
  sh.getRange(3, 1, items.length, 2).setValues(items.map(i => i ? i.r : ['', '']));
  items.forEach((it, i) => { if (it && it.head) sh.getRange(3 + i, 1, 1, 2).setFontWeight('bold').setBackground('#F1F5F9'); });
  sh.setColumnWidths(1, 1, 360); sh.setColumnWidths(2, 1, 220);
}
3 langsSchedule: Weekly

Daily keyword Quality Score tracker with components, a change log against the previous snapshot, an idempotent history row and a summary of where low quality costs the most.

// quality-lens — EN build 2026-09-14
// Copyright 2026 Gökhan Güzel — gokhanguzel.com. All Rights Reserved. Generated file: edit src/ and i18n/, then run build.js.
// Do not share configured Slack webhook URLs.
'use strict';
const SCRIPT_ID = "quality-lens";
const L = {"lang":"en","locale":"en-US","text_lang":"en","dec":".","thou":","};
const ATTRIBUTION = String.fromCharCode(169,32,103,111,107,104,97,110,103,117,122,101,108,46,99,111,109);
const T = {"kpi":"KPI","kpi.now":"This period","kpi.prev":"Previous","yes":"yes","no":"no","mail.full_report":"Full report","mail.open_report":"Open report","note.no_rows":"No rows.","note.truncated":"Showing {shown} of {total} rows (max_rows_written).","note.no_campaigns":"No campaigns with impressions match the current filters.","settings.key":"Key","settings.value":"Value","settings.notes":"Notes","settings.script":"script that owns this Sheet (do not edit)","settings.account":"Google Ads account that owns this Sheet (do not edit)","note.truncated_queries":"⚠ {n} quer(y/ies) hit the read limit — results incomplete; narrow the window","note.account_neg_failed":"⚠ account-level negatives could not be read:","note.skipped_time":"skipped — execution time budget","note.data_error":"data error:","note.failed":"FAILED —","note.no_final_url":"(no final URL)","alias.settings":["Settings","Ayarlar","Einstellungen"],"alias.history":["History","Kronoloji","Verlauf","Runs","Calismalar","Laeufe"],"alias.snapshot":["Snapshot","Snapshots","Kesit","Kesitler","Momentaufnahme","Momentaufnahmen"],"alias.state":["State","DurumBellegi","Zustand"],"alias.baseline":["Baseline","TemelCizgi","Basislinie"],"m.impressions":"Impr.","m.clicks":"Clicks","m.ctr":"CTR","m.cost":"Cost","m.cpc":"Avg. CPC","m.conversions":"Conv.","m.value":"Conv. value","m.cr":"Conv. rate","m.cpa":"CPA","m.roas":"ROAS","m.aov":"AOV","m.vpc":"Value / click","col.account":"Account","col.period":"Period","col.currency":"Currency","col.date":"Date","col.campaign":"Campaign","col.ad_group":"Ad group","col.n":"N","col.phrase":"Phrase","col.queries":"Queries","col.source":"Source","col.why":"Why","title":"Quality Lens — keyword quality score","tab.summary":"Summary","tab.keywords":"Keywords","tab.changes":"Changes","alias.summary":["Özet","Zusammenfassung"],"alias.keywords":["Anahtar kelimeler","AnahtarKelimeler"],"alias.changes":["Değişimler","Änderungen","Degisimler","Aenderungen"],"col.keyword":"Keyword","col.keywords":"Keywords","col.match_type":"Match type","col.qs":"QS","col.prev_qs":"Previous QS","col.delta":"Δ","col.exp_ctr":"Expected CTR","col.ad_relevance":"Ad relevance","col.lp_exp":"Landing page exp.","comp.ctr":"Expected CTR","comp.rel":"Ad relevance","comp.lpe":"Landing page experience","sum.window":"Metrics window","sum.scored":"Keywords with a QS","sum.weighted_qs":"Impression-weighted QS","sum.simple_qs":"Simple average QS","sum.low_cost":"Cost on QS ≤ 4","sum.low_share":"Share of cost on QS ≤ 4","sum.changes":"QS changes ≥ {n} since last run","sum.by_qs":"By QS","sum.no_qs":"no QS","sum.worst":"Most expensive low-quality keywords (QS ≤ 4)","sum.weakest":"Weakest component","mail.subject":"Quality Lens · {cid} · QS {qs} · {n} moves","mail.drops":"Drops","mail.gains":"Gains"};

/* ===================== common/core ===================== */
/* Pure helpers. No Sheets, no network. Everything here is unit-tested in test/run.js. */

/* AdsApp.search is report-backed and is not subject to selector entity limits. Keep a defensive count check only for
   runtimes/iterators that expose totalNumEntities(); normally this stays at zero. */
let TRUNCATED_ = 0;
function query_(gaql) {
  const out = [];
  const it = AdsApp.search(gaql);
  while (it.hasNext()) out.push(it.next());
  try { if (typeof it.totalNumEntities === 'function') { const n = it.totalNumEntities(); if (n > out.length) { TRUNCATED_++; Logger.log(`Query truncated: ${out.length} of ${n} rows read — narrow the window or filters. ${gaql.slice(0, 120)}`); } } } catch (e) {}
  return out;
}
/* Seconds left in the 30-minute execution window (Infinity when the runtime does not expose it, e.g. in the mock). */
function timeLeft_() { try { return AdsApp.getExecutionInfo().getRemainingTime(); } catch (e) { return Infinity; } }
function num_(v) { const x = parseFloat(v); return isFinite(x) ? x : 0; }
function nz_(v) { return isFinite(v) ? v : ''; }
function esc_(s) { return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'"); }
function escLike_(s) { const m = { '[': '[[]', ']': '[]]', '%': '[%]', '_': '[_]' }; return esc_(s).replace(/[\[\]%_]/g, c => m[c]); }
function chunks_(arr, n) { const o = []; for (let i = 0; i < arr.length; i += n) o.push(arr.slice(i, i + n)); return o; }
function pct_(a, b) { return (typeof a === 'number' && typeof b === 'number' && isFinite(a) && isFinite(b) && b) ? a / b - 1 : NaN; }

/* Number formatting for e-mail / Slack. Deterministic: separators come from the build (L.dec / L.thou), not from
   the runtime's ICU data. */
function fmtNum_(v, digits) {
  if (typeof v !== 'number' || !isFinite(v)) return '–';
  const d = digits === undefined ? (Math.abs(v) >= 1000 ? 0 : Math.abs(v) < 1 ? 3 : 2) : digits;
  const parts = Math.abs(v).toFixed(d).split('.');
  return (v < 0 ? '-' : '') + parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, L.thou) + (parts[1] ? L.dec + parts[1] : '');
}
function fmtPct_(v, digits) { return isFinite(v) ? fmtNum_(v * 100, digits === undefined ? 2 : digits) + '%' : '–'; }

/* Metric bag: raw sums + derived rates. NaN means "undefined", never 0. */
function zeroM_() { return { impressions: 0, clicks: 0, cost: 0, conversions: 0, value: 0 }; }
function rawOf_(r, opts) {
  const m = (r && r.metrics) || {}; opts = opts || {};
  return {
    impressions: num_(m.impressions), clicks: num_(m.clicks),
    cost: opts.noCost ? 0 : num_(m.costMicros) / 1e6,
    conversions: opts.noConv ? NaN : num_(m.conversions),
    value: opts.noConv ? NaN : num_(m.conversionsValue)
  };
}
function addM_(a, b) {
  const sumKnown = (x, y) => isFinite(x) && isFinite(y) ? x + y : NaN;
  return {
    impressions: a.impressions + b.impressions, clicks: a.clicks + b.clicks, cost: a.cost + b.cost,
    conversions: sumKnown(a.conversions, b.conversions),
    value: sumKnown(a.value, b.value)
  };
}
function derive_(o) {
  o.ctr = o.impressions ? o.clicks / o.impressions : NaN;
  o.cpc = o.clicks ? o.cost / o.clicks : NaN;
  o.cr = o.clicks && isFinite(o.conversions) ? o.conversions / o.clicks : NaN;
  o.cpa = o.conversions > 0 ? o.cost / o.conversions : NaN;
  o.roas = o.cost > 0 && isFinite(o.value) ? o.value / o.cost : NaN;
  o.aov = o.conversions > 0 ? o.value / o.conversions : NaN;
  o.vpc = o.clicks ? o.value / o.clicks : NaN;
  return o;
}

/* Text normalisation for search terms / negatives. language: 'tr' handles İ/ı. */
function normalize_(s, language) {
  s = String(s || '');
  s = language === 'tr' ? s.replace(/I/g, 'ı').replace(/İ/g, 'i').toLowerCase() : s.toLowerCase();
  return s.replace(/[+\[\]"“”'’`^*]/g, ' ')
          .replace(/[^\p{L}\p{N}\s\-\.]/gu, ' ')
          .split(/\s+/).map(w => w.replace(/^[.\-]+|[.\-]+$/g, '')).filter(Boolean).join(' ');
}
function tokens_(s, language) { const n = normalize_(s, language); return n ? n.split(' ') : []; }
function hasSubseq_(hay, needle) {
  outer: for (let i = 0; i + needle.length <= hay.length; i++) {
    for (let j = 0; j < needle.length; j++) if (hay[i + j] !== needle[j]) continue outer;
    return true;
  }
  return false;
}
/* exact = same tokens; phrase = contiguous; broad = every word present. */
function blocked_(q, negs) {
  if (!negs) return false;
  for (const n of negs) {
    if (!n.words.length) continue;
    if (n.mt === 'EXACT') { if (n.words.length === q.length && n.words.every((w, i) => w === q[i])) return true; }
    else if (n.mt === 'PHRASE') { if (hasSubseq_(q, n.words)) return true; }
    else if (n.words.every(w => q.includes(w))) return true;
  }
  return false;
}

/* Robust stats: median / MAD (σ-scaled), SD fallback. */
function median_(sorted) { const n = sorted.length; if (!n) return NaN; return n % 2 ? sorted[(n - 1) / 2] : (sorted[n / 2 - 1] + sorted[n / 2]) / 2; }
function robust_(arr) {
  if (!arr.length) return { median: NaN, mad: NaN, scale: 0 };
  const s = arr.slice().sort((a, b) => a - b), med = median_(s);
  const mad = median_(s.map(v => Math.abs(v - med)).sort((a, b) => a - b)) * 1.4826;
  let scale = mad;
  if (!(scale > 0)) { const mean = arr.reduce((x, v) => x + v, 0) / arr.length; scale = Math.sqrt(arr.reduce((x, v) => x + (v - mean) * (v - mean), 0) / Math.max(1, arr.length - 1)); }
  return { median: med, mad: mad, scale: scale };
}

/* Minimal HTML escape for anything user-generated that lands in e-mail. */
function h_(s) { return String(s === undefined || s === null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
/* Text lookup. T is injected by the build; keys are stable, values are per language. */
function t_(key, vars) {
  let s = T[key]; if (s === undefined) s = key;
  return String(s).replace(/\{(\w+)\}/g, (m, k) => (vars && vars[k] !== undefined) ? vars[k] : m);
}

/* ===================== common/dates ===================== */
/* Calendar-based date arithmetic in the account time zone. No 864e5 shifts,
   so DST transitions cannot skip or repeat a day. */
function tz_() { return AdsApp.currentAccount().getTimeZone(); }
function todayStr_(tz, base) { return Utilities.formatDate(base || new Date(), tz, 'yyyy-MM-dd'); }
function shiftDay_(dateStr, days) {
  const p = dateStr.split('-').map(Number);
  const d = new Date(Date.UTC(p[0], p[1] - 1, p[2] + days));
  return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + String(d.getUTCDate()).padStart(2, '0');
}
/* window_(days, offset): `days` complete days, ending `offset+1` days before today. offset 0 → ends yesterday. */
function window_(days, offset, tz, base) {
  const today = todayStr_(tz, base);
  const end = shiftDay_(today, -(offset + 1)), start = shiftDay_(today, -(offset + days));
  return { start: start, end: end, label: start + ' → ' + end };
}
const DOW_ = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY'];
function dowOf_(dateStr) { const p = dateStr.split('-').map(Number); return DOW_[new Date(Date.UTC(p[0], p[1] - 1, p[2])).getUTCDay()]; }
/* Sheets may hand back a Date where we wrote a 'yyyy-MM-dd' string. Always normalise on read. */
function dateStrOf_(v, tz) {
  if (v instanceof Date) return Utilities.formatDate(v, tz, 'yyyy-MM-dd');
  return String(v === null || v === undefined ? '' : v).trim();   // strings are never truncated: keys like 'yyyy-MM-dd HH:mm' or '123-456-7890' stay intact
}

/* ===================== common/manager ===================== */
/* Runs as a single account, or selects one client account when started from a manager account. */
function isManager_() { try { if (typeof AdsManagerApp === 'undefined') return false; AdsManagerApp.accounts().withLimit(1).get(); return true; } catch (e) { return false; } }
function selectAccount_(accountId) {
  if (!isManager_()) return;
  if (!accountId) throw new Error('Running from a manager account: set CONFIG.account_id (e.g. 123-456-7890).');
  const it = AdsManagerApp.accounts().withIds([String(accountId)]).get();
  if (!it.hasNext()) throw new Error('Account ' + accountId + ' is not a client of this manager account.');
  AdsManagerApp.select(it.next());
}

/* ===================== common/sheets ===================== */
/* Technical tabs are language-independent. User-facing tab names come from T. */
const TECH = { settings: '_settings', state: '_state', snapshot: '_snapshot', history: '_history', baseline: '_baseline' };

function openSpreadsheet_(url) {
  if (!url || url === 'YOUR_SPREADSHEET_URL') throw new Error('Set CONFIG.spreadsheet_url to a Google Sheet you own.');
  const ss = SpreadsheetApp.openByUrl(url);
  claimSheet_(ss);              // ownership first: nothing is written to a Sheet that belongs to another script or account
  return seal_(ss);             // every script opens its Sheet through the attribution layer
}
/* One Sheet per script. The first run records SCRIPT_ID in _settings; a different script pointed at the same Sheet stops
   before it can overwrite tabs (ngram-lens and pmax-ngram-lens share tab names). */
function claimSheet_(ss) {
  const sh = tab_(ss, TECH.settings, T['alias.settings']);
  const cid = AdsApp.currentAccount().getCustomerId();
  const kv = {}; if (sh.getLastRow() > 1) sh.getRange(2, 1, sh.getLastRow() - 1, 2).getValues().forEach(r => kv[String(r[0])] = String(r[1]));
  if (kv.script && kv.script !== SCRIPT_ID) throw new Error(`This Sheet belongs to ${kv.script}. Use a separate Sheet for ${SCRIPT_ID}.`);
  if (kv.account && kv.account !== cid) throw new Error(`This Sheet belongs to account ${kv.account}; running as ${cid}. Use a separate Sheet per account.`);
  if (!kv.script || !kv.account) ensureSettings_(ss, []);
}
/* Title in A1, header in row `top` (2). Writes only what is missing, so the attribution cell in H1 never masks an empty
   header row. Returns true when the header was (re)written. */
function ensureTitledHeader_(sheet, title, header) {
  ensureSize_(sheet, 3, header.length);
  if (String(sheet.getRange(1, 1).getValue()) === '') sheet.getRange(1, 1).setValue(title).setFontWeight('bold').setFontSize(13);
  const a2 = sheet.getRange(2, 1).getValue();
  if (String(a2) === header[0]) return false;
  if (String(a2) !== '') sheet.insertRowsBefore(2, 1);   // data written under a missing header (v2.2 layout): push it down, then add the header
  styleHeader_(sheet.getRange(2, 1, 1, header.length).setValues([header])); sheet.setFrozenRows(2);
  return true;
}
/* Find a tab by its current name or any legacy alias; rename legacy tabs once so state survives a language switch. */
function tab_(ss, name, aliases) {
  let sh = ss.getSheetByName(name);
  if (!sh && aliases) for (const a of aliases) { const old = ss.getSheetByName(a); if (old) { old.setName(name); sh = old; break; } }
  if (!sh) sh = ss.insertSheet(name);
  return sh;
}
/* specs: [{name, aliases:[...]}]. Removes the empty default tab whatever its locale name is. */
function ensureTabs_(ss, specs) {
  const wanted = new Set(specs.map(s => s.name));
  specs.forEach(s => tab_(ss, s.name, s.aliases));
  const first = ss.getSheets()[0];
  if (first && !wanted.has(first.getName()) && ss.getSheets().length > 1 && onlyAttribution_(first)) ss.deleteSheet(first);
}
/* A default tab that holds nothing but the attribution cell written by the early seal_ is still "empty". */
function onlyAttribution_(sheet) {
  if (sheet.getLastRow() === 0) return true;
  if (sheet.getLastRow() > 1) return false;
  const row = sheet.getRange(1, 1, 1, Math.max(1, sheet.getLastColumn())).getValues()[0];
  return row.every((v, i) => v === '' || (i === 7 && v === ATTRIBUTION));
}
/* Grow the grid BEFORE any getRange: Sheets throws on coordinates outside the current dimensions. */
function ensureSize_(sheet, rows, cols) {
  if (cols > sheet.getMaxColumns()) sheet.insertColumnsAfter(sheet.getMaxColumns(), cols - sheet.getMaxColumns());
  if (rows > sheet.getMaxRows()) sheet.insertRowsAfter(sheet.getMaxRows(), rows - sheet.getMaxRows());
}
function styleHeader_(rng) { return rng.setFontWeight('bold').setBackground('#1E293B').setFontColor('#ffffff'); }

/* Write a table. Rows above `top` are never touched (callers put titles there). formats[i] is a number format per column ('@' = text, applied BEFORE setValues so Sheets never auto-parses).
   Rows beyond `cap` are dropped and a note is written. Returns the number of rows written. */
function table_(sheet, top, header, rows, formats, cap) {
  let out = rows;
  if (cap && rows.length > cap) out = rows.slice(0, cap);
  ensureSize_(sheet, top + out.length + 1, header.length);
  const maxR = sheet.getMaxRows(), maxC = sheet.getMaxColumns();
  if (maxR >= top) sheet.getRange(top, 1, maxR - top + 1, maxC).clearContent();   // contents only: user notes/formats outside the table survive
  styleHeader_(sheet.getRange(top, 1, 1, header.length).setValues([header]));
  if (out.length) {
    (formats || []).forEach((f, i) => { if (f) sheet.getRange(top + 1, i + 1, out.length, 1).setNumberFormat(f); });
    sheet.getRange(top + 1, 1, out.length, header.length).setValues(out);
    if (out.length < rows.length) sheet.getRange(top + 1 + out.length, 1).setValue(t_('note.truncated', { shown: out.length, total: rows.length }));
  } else {
    sheet.getRange(top + 1, 1).setValue(t_('note.no_rows'));
  }
  sheet.setFrozenRows(top);
  return out.length;
}
/* Append rows with text formats applied first (appendRow cannot do that). */
function appendRows_(sheet, rows, formats) {
  if (!rows.length) return;
  const r0 = sheet.getLastRow() + 1, n = rows.length, w = rows[0].length;
  ensureSize_(sheet, r0 + n - 1, w);
  (formats || []).forEach((f, i) => { if (f) sheet.getRange(r0, i + 1, n, 1).setNumberFormat(f); });
  sheet.getRange(r0, 1, n, w).setValues(rows);
}
/* A technical tab whose header row differs from what this version writes (older layout) is renamed to an archive tab and a
   fresh one is created — rows of two schemas are never mixed. Returns the sheet to write to. */
function ensureSchema_(ss, name, header) {
  const sh = ss.getSheetByName(name); if (!sh || sh.getLastRow() === 0) return sh;
  const cur = sh.getRange(1, 1, 1, Math.max(1, sh.getLastColumn())).getValues()[0].map(String);
  while (cur.length && cur[cur.length - 1] === '') cur.pop();
  if (cur.join('|') === header.join('|')) return sh;
  let n = 1, archive = name + '_archive'; while (ss.getSheetByName(archive)) archive = name + '_archive' + (++n);
  sh.setName(archive); Logger.log(`${name}: old layout archived as ${archive}`);
  return ss.insertSheet(name);
}
/* Idempotent history: one row per key (first `keyCols` columns, joined). Existing row with the same key is replaced. */
function upsertRow_(sheet, header, row, keyCols, formats, tz) {
  ensureSize_(sheet, 2, Math.max(header.length, row.length));
  const cur = sheet.getLastRow() === 0 ? [] : sheet.getRange(1, 1, 1, Math.max(1, sheet.getLastColumn())).getValues()[0].map(String).filter(Boolean);
  if (cur.join('|') !== header.join('|')) {
    const w = Math.max(header.length, sheet.getLastColumn()); sheet.getRange(1, 1, 1, w).clearContent();
    styleHeader_(sheet.getRange(1, 1, 1, header.length).setValues([header])); sheet.setFrozenRows(1);
  }   // new/changed schema: clear stale cells, then rewrite header
  const key = row.slice(0, keyCols).map(v => dateStrOf_(v, tz)).join('|');
  const last = sheet.getLastRow();
  if (last > 1) {
    const keys = sheet.getRange(2, 1, last - 1, keyCols).getValues().map(r => r.map(v => dateStrOf_(v, tz)).join('|'));
    const i = keys.indexOf(key);
    if (i >= 0) {
      const w = Math.max(row.length, sheet.getLastColumn()); sheet.getRange(i + 2, 1, 1, w).clearContent();
      sheet.getRange(i + 2, 1, 1, row.length).setValues([row]); return;
    }
  }
  appendRows_(sheet, [row], formats);
}
/* Replace a whole table safely: write the new rows first, then clear whatever is left below. */
function replaceRows_(sheet, header, rows, formats) {
  const n = rows.length;
  ensureSize_(sheet, n + 1, header.length);
  if (sheet.getLastRow() === 0) styleHeader_(sheet.getRange(1, 1, 1, header.length).setValues([header]));
  if (n) {
    (formats || []).forEach((f, i) => { if (f) sheet.getRange(2, i + 1, n, 1).setNumberFormat(f); });
    sheet.getRange(2, 1, n, header.length).setValues(rows);
  }
  const last = sheet.getLastRow();
  if (last > n + 1) sheet.getRange(n + 2, 1, last - n - 1, Math.max(header.length, sheet.getLastColumn())).clearContent();
}

/* _settings: Key | Value | Notes. Keys are stable identifiers (never translated); notes are per language. */
function ensureSettings_(ss, defaults) {
  const sh = tab_(ss, TECH.settings, T['alias.settings']);
  ensureSize_(sh, 2 + defaults.length, 3);
  if (sh.getLastRow() === 0) {
    sh.getRange(1, 1, 1, 3).setNumberFormat('@').setValues([[t_('settings.key'), t_('settings.value'), t_('settings.notes')]]);
    styleHeader_(sh.getRange(1, 1, 1, 3)); sh.setFrozenRows(1); sh.setColumnWidths(1, 1, 200); sh.setColumnWidths(3, 1, 420);
  }
  const all = [['script', SCRIPT_ID, t_('settings.script')], ['account', AdsApp.currentAccount().getCustomerId(), t_('settings.account')]].concat(defaults);
  const last = sh.getLastRow();
  const keys = last > 1 ? sh.getRange(2, 1, last - 1, 1).getValues().map(r => String(r[0])) : [];
  const missing = all.filter(d => keys.indexOf(d[0]) < 0);
  if (missing.length) { const r0 = sh.getLastRow() + 1; ensureSize_(sh, r0 + missing.length, 3); sh.getRange(r0, 1, missing.length, 3).setNumberFormat('@').setValues(missing.map(d => [d[0], d[1], d[2]])); }
  if (keys.length) {   // notes follow the current language; one batched write, only when something differs; values are never touched
    const rng = sh.getRange(2, 3, keys.length, 1), cur = rng.getValues().map(r => String(r[0]));
    const want = keys.map((k, i) => { const d = all.find(x => x[0] === k); return d && d[2] ? d[2] : cur[i]; });
    if (want.join('\u0001') !== cur.join('\u0001')) rng.setValues(want.map(v => [v]));
  }
  return sh;
}
/* Reads _settings into cfg. Only keys present in `schema` are accepted; type comes from schema: 'num' | 'bool' | 'str' | 'list'. */
function readSettings_(ss, cfg, schema) {
  const out = JSON.parse(JSON.stringify(cfg));
  const sh = ss.getSheetByName(TECH.settings);
  if (!sh || sh.getLastRow() < 2) return out;
  sh.getRange(2, 1, sh.getLastRow() - 1, 2).getValues().forEach(([k, v]) => {
    const type = schema[k]; if (!type || v === '' || v === null) return;
    if (type === 'num') {
      if (typeof v === 'number') { out[k] = v; return; }
      const s = String(v).trim().replace(/\s/g, '');
      if (!/^-?\d+([.,]\d+)?$/.test(s)) throw new Error(`_settings.${k}: "${v}" is not a number (use 0.5 or 0,5)`);
      if (/^-?\d{1,3}[.,]\d{3}$/.test(s)) throw new Error(`_settings.${k}: "${v}" is ambiguous; do not use thousands separators (write 1000 or 0.5 / 0,5)`);
      out[k] = parseFloat(s.replace(',', '.'));   // decimal comma/dot accepted; ambiguous 1.000 / 1,000 is rejected
    }
    else if (type === 'bool') out[k] = /^(true|yes|1|on|evet|ja)$/i.test(String(v).trim());
    else if (type === 'list') out[k] = String(v).split(',').map(s => s.trim()).filter(Boolean);
    else out[k] = String(v).trim();
  });
  return out;
}
function writeSetting_(ss, key, value) {
  const sh = ss.getSheetByName(TECH.settings); if (!sh) return;
  const last = sh.getLastRow();
  const keys = last > 1 ? sh.getRange(2, 1, last - 1, 1).getValues().map(r => String(r[0])) : [];
  const i = keys.indexOf(key);
  const cell = i >= 0 ? sh.getRange(i + 2, 2) : sh.getRange(last + 1, 2);
  if (i < 0) sh.getRange(last + 1, 1).setNumberFormat('@').setValue(key);
  cell.setNumberFormat('@').setValue(value);
}

/* ===================== common/seal ===================== */
/* Attribution cell. Warning-only protection (no editor management → no Session dependency, no lock-out of the
   running user). Never throws: a report must not fail because a Sheet cell was edited.
   Called twice: from openSpreadsheet_ (first tab, before anything else) and at the end of main() on the report tab.
   Returns the spreadsheet so the open path depends on it. This is attribution, not DRM: anyone who edits the
   source can remove it. */
function seal_(ss, tabName) {
  try {
    const sheet = (tabName && ss.getSheetByName(tabName)) || ss.getSheets().find(s => !s.getName().startsWith('_')) || ss.getSheets()[0];
    const want = ATTRIBUTION;
    const range = sheet.getRange('H1:K1');
    let cur = ''; try { cur = String(range.getCell(1, 1).getValue()); } catch (e) {}
    if (cur !== want) {
      try { range.breakApart(); range.merge(); } catch (e) {}
      range.getCell(1, 1).setValue(want).setFontWeight('bold').setFontColor('#0F766E').setHorizontalAlignment('center').setNote(want);
    }
    const has = sheet.getProtections(SpreadsheetApp.ProtectionType.RANGE).some(p => p.getDescription() === want);
    if (!has) range.protect().setDescription(want).setWarningOnly(true);
  } catch (e) { Logger.log('Attribution skipped: ' + e); }
  return ss;
}

/* ===================== common/notify ===================== */
function htmlShell_(title, subtitle, body, url) {
  return '<div style="font:13px Arial,sans-serif;color:#1E293B;max-width:760px">' +
    '<div style="background:#1E293B;color:#fff;padding:12px 16px;font-size:15px;font-weight:bold">' + h_(title) + '</div>' +
    '<div style="padding:10px 16px;color:#64748B">' + h_(subtitle) + '</div>' + body +
    '<div style="padding:12px 16px;color:#94A3B8;font-size:11px">' + (url ? h_(t_('mail.full_report')) + ': <a href="' + h_(url) + '">' + h_(url) + '</a><br>' : '') + h_(ATTRIBUTION) + '</div></div>';
}
function kpiTable_(rows) {
  /* rows: [[label, now, prev, delta_html]] */
  const th = (s, r) => '<th style="padding:5px 10px;text-align:' + (r ? 'right' : 'left') + '">' + h_(s) + '</th>';
  return '<table style="border-collapse:collapse;width:100%"><tr style="background:#F1F5F9;color:#64748B;font-size:11px">' +
    th(t_('kpi'), false) + th(t_('kpi.now'), true) + th(t_('kpi.prev'), true) + th('Δ', true) + '</tr>' +
    rows.map(r => '<tr><td style="padding:5px 10px">' + h_(r[0]) + '</td><td style="padding:5px 10px;text-align:right;font-weight:bold">' + h_(r[1]) +
      '</td><td style="padding:5px 10px;text-align:right;color:#64748B">' + h_(r[2]) + '</td><td style="padding:5px 10px;text-align:right">' + r[3] + '</td></tr>').join('') + '</table>';
}
function delta_(a, b, invert) {
  const v = pct_(a, b); if (!isFinite(v)) return '';
  const good = invert ? v < 0 : v > 0;
  return '<span style="color:' + (good ? '#15803D' : '#B91C1C') + '">' + (v >= 0 ? '▲' : '▼') + ' ' + fmtPct_(Math.abs(v), 0) + '</span>';
}
function sendMail_(to, subject, html, text) {
  if (!to) return false;
  try { MailApp.sendEmail({ to: to, subject: subject, htmlBody: html, body: text || subject }); return true; }
  catch (e) { Logger.log('E-mail failed: ' + e); return false; }
}
function slackText_(s) { return String(s === undefined || s === null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
function sendSlack_(webhook, text) {
  if (!webhook) return false;
  try {
    const res = UrlFetchApp.fetch(webhook, { method: 'post', contentType: 'application/json', muteHttpExceptions: true, payload: JSON.stringify({ text: text + '\n_' + slackText_(ATTRIBUTION) + '_' }) });
    const ok = res.getResponseCode() === 200;
    if (!ok) Logger.log('Slack returned ' + res.getResponseCode() + ': ' + res.getContentText().slice(0, 200));
    return ok;
  } catch (e) { Logger.log('Slack failed: ' + e); return false; }
}

/* ===================== common/negatives ===================== */
/* Negative keywords by ID: ad group, campaign, shared lists attached to campaigns, and account level. */
function fetchNegatives_(campaignIds, language, opts) {
  opts = opts || {};
  const neg = { adGroup: {}, campaign: {}, account: [] };
  const item = (text, mt) => ({ words: tokens_(text, language), mt: String(mt || '').toUpperCase() });
  const push = (bucket, key, text, mt) => { (bucket[key] = bucket[key] || []).push(item(text, mt)); };
  chunks_(campaignIds, 200).forEach(chunk => {
    if (opts.adGroupLevel !== false) {
      query_(`SELECT ad_group.id, ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type
        FROM ad_group_criterion WHERE ad_group_criterion.negative = TRUE AND ad_group_criterion.type = 'KEYWORD'
        AND ad_group_criterion.status != 'REMOVED' AND campaign.id IN (${chunk.join(',')})`)
        .forEach(r => push(neg.adGroup, String(r.adGroup.id), r.adGroupCriterion.keyword.text, r.adGroupCriterion.keyword.matchType));
    }
    query_(`SELECT campaign.id, campaign_criterion.keyword.text, campaign_criterion.keyword.match_type
      FROM campaign_criterion WHERE campaign_criterion.negative = TRUE AND campaign_criterion.type = 'KEYWORD'
      AND campaign_criterion.status != 'REMOVED' AND campaign.id IN (${chunk.join(',')})`)
      .forEach(r => push(neg.campaign, String(r.campaign.id), r.campaignCriterion.keyword.text, r.campaignCriterion.keyword.matchType));
  });
  const setToCampaigns = {};
  chunks_(campaignIds, 200).forEach(chunk => {
    query_(`SELECT campaign.id, shared_set.id FROM campaign_shared_set
      WHERE shared_set.type = 'NEGATIVE_KEYWORDS' AND campaign_shared_set.status = 'ENABLED' AND shared_set.status = 'ENABLED'
      AND campaign.id IN (${chunk.join(',')})`)
      .forEach(r => (setToCampaigns[String(r.sharedSet.id)] = setToCampaigns[String(r.sharedSet.id)] || []).push(String(r.campaign.id)));
  });
  const setIds = Object.keys(setToCampaigns);
  chunks_(setIds, 200).forEach(chunk => {
    query_(`SELECT shared_set.id, shared_criterion.keyword.text, shared_criterion.keyword.match_type
      FROM shared_criterion WHERE shared_criterion.type = 'KEYWORD' AND shared_set.id IN (${chunk.join(',')})`)
      .forEach(r => setToCampaigns[String(r.sharedSet.id)].forEach(cid => push(neg.campaign, cid, r.sharedCriterion.keyword.text, r.sharedCriterion.keyword.matchType)));
  });
  /* Account-level negatives are a shared set of type ACCOUNT_LEVEL_NEGATIVE_KEYWORDS (customer_negative_criterion has no
     keyword field; it only points at that list). */
  try {
    query_(`SELECT shared_criterion.keyword.text, shared_criterion.keyword.match_type
      FROM shared_criterion WHERE shared_set.type = 'ACCOUNT_LEVEL_NEGATIVE_KEYWORDS' AND shared_criterion.type = 'KEYWORD' AND shared_set.status = 'ENABLED'`)
      .forEach(r => neg.account.push(item(r.sharedCriterion.keyword.text, r.sharedCriterion.keyword.matchType)));
  } catch (e) { Logger.log('Account-level negatives not readable: ' + e); neg.accountError = String(e); }
  return neg;
}
function isBlocked_(tok, neg, campaignId, adGroupId) {
  return blocked_(tok, neg.account) || blocked_(tok, neg.campaign[campaignId]) || (adGroupId ? blocked_(tok, neg.adGroup[adGroupId]) : false);
}

/* ===================== common/metrics ===================== */
/* Standard metric block for tables: header / row / number formats, all driven by T and one metric bag. */
const M_KEYS = ['impressions', 'clicks', 'ctr', 'cost', 'cpc', 'conversions', 'value', 'cr', 'cpa', 'roas'];
const M_FMT = ['#,##0', '#,##0', '0.00%', '#,##0.00', '#,##0.00', '#,##0.0', '#,##0.00', '0.00%', '#,##0.00', '0.00'];
function mHead_(keys) { return (keys || M_KEYS).map(k => t_('m.' + k)); }
function mRow_(m, keys) { return (keys || M_KEYS).map(k => nz_(m[k])); }
function mFmt_(keys) { return (keys || M_KEYS).map(k => M_FMT[M_KEYS.indexOf(k)] || null); }

/* ===================== common/ngram ===================== */
/* Shared n-gram engine for N-Gram Lens and PMax N-Gram Lens. terms: [{campaignId, campaign, adGroupId, term, source, categories?, m}] */
function ngramAnalyse_(terms, neg, opts) {
  const lang = opts.language, stop = new Set((opts.stopwords || []).map(w => normalize_(w, lang)));
  const brand = (opts.brand_terms || []).map(b => tokens_(b, lang)).filter(t => t.length);
  const acc = {}, camp = {}, cat = {}, wc = {}, totals = zeroM_(), split = { brand: zeroM_(), generic: zeroM_() };
  const blockedKeys = new Set(); let excluded = 0, queries = 0;
  const bump = (map, key, seed, m) => { const a = map[key] = map[key] || Object.assign(zeroM_(), { queries: 0 }, seed); Object.assign(a, addM_(a, m)); a.queries++; };
  terms.forEach(t => {
    const tok = tokens_(t.term, lang); if (!tok.length) return;
    if (neg && isBlocked_(tok, neg, t.campaignId, t.adGroupId)) { excluded++; blockedKeys.add(t.campaignId + '|' + t.term.toLowerCase()); return; }
    queries++; Object.assign(totals, addM_(totals, t.m));
    const isBrand = brand.length && brand.some(b => hasSubseq_(tok, b));
    const side = isBrand ? split.brand : split.generic; Object.assign(side, addM_(side, t.m)); side.queries = (side.queries || 0) + 1;
    bump(wc, tok.length > 6 ? '7+' : String(tok.length), { words: tok.length > 6 ? '7+' : String(tok.length) }, t.m);
    const seen = new Set();
    for (let n = opts.min_ngram; n <= opts.max_ngram && n <= tok.length; n++) for (let i = 0; i + n <= tok.length; i++) {
      const words = tok.slice(i, i + n); if (n === 1 && stop.has(words[0])) continue;
      const g = words.join(' '), k = n + '|' + g; if (seen.has(k)) continue; seen.add(k);
      bump(acc, k, { n: n, gram: g }, t.m);
      bump(camp, t.campaignId + '|' + k, { n: n, gram: g, campaign: t.campaign, source: t.source }, t.m);
      (t.categories || []).forEach(c => bump(cat, t.campaignId + '|' + c + '|' + k, { n: n, gram: g, campaign: t.campaign, category: c }, t.m));
    }
  });
  derive_(totals); derive_(split.brand); derive_(split.generic);
  const th = opts.thresholds, keep = a => a.queries >= th.query_count && a.impressions >= th.impressions && a.clicks >= th.clicks && (a.cost || 0) >= (th.cost || 0);
  const rank = opts.rankBy === 'clicks' ? ((x, y) => y.clicks - x.clicks || y.impressions - x.impressions || x.gram.localeCompare(y.gram))
                                        : ((x, y) => y.cost - x.cost || y.impressions - x.impressions || x.gram.localeCompare(y.gram));
  return { queries, excluded, totals, split, blockedKeys,
    account: Object.values(acc).map(derive_).filter(keep).sort(rank),
    campaign: Object.values(camp).map(derive_).filter(keep).sort((x, y) => x.campaign.localeCompare(y.campaign) || rank(x, y)),
    category: Object.values(cat).map(derive_).filter(keep).sort((x, y) => x.campaign.localeCompare(y.campaign) || x.category.localeCompare(y.category) || rank(x, y)),
    wordCount: ['1', '2', '3', '4', '5', '6', '7+'].map(k => derive_(Object.assign({ words: k, queries: 0 }, zeroM_(), wc[k] || {}))) };
}
const NG_KEYS = ['queries'].concat(M_KEYS);
function ngHead_(keys) { return (keys || NG_KEYS).map(k => k === 'queries' ? t_('col.queries') : t_('m.' + k)); }
function ngFmt_(keys) { return (keys || NG_KEYS).map(k => k === 'queries' ? '#,##0' : M_FMT[M_KEYS.indexOf(k)] || null); }
function ngRow_(a, keys) { return (keys || NG_KEYS).map(k => nz_(a[k])); }
function writeNgrams_(ss, tabKey, rows, segKeys, keys, cap) {
  const head = segKeys.map(k => t_('col.' + k)).concat([t_('col.n'), t_('col.phrase')]).concat(ngHead_(keys));
  const fmt = segKeys.map(() => '@').concat(['0', '@']).concat(ngFmt_(keys));
  table_(ss.getSheetByName(t_('tab.' + tabKey)), 1, head, rows.map(r => segKeys.map(k => r[k]).concat([r.n, r.gram]).concat(ngRow_(r, keys))), fmt, cap);
}


/**
 * @name Quality Lens
 * @overview Daily keyword Quality Score tracker with components, a change log against the previous snapshot,
 *   an idempotent daily history row, and a summary of where low quality costs the most.
 * Schedule: Daily, 04:00 account time or later.
 */
const CONFIG = {
  spreadsheet_url: 'YOUR_SPREADSHEET_URL', email: '', account_id: '',
  days: 30, enabled_only: true, min_impressions: 1, campaign_contains: '',
  change_threshold: 2, keep_history_days: 400, max_rows_written: 5000
};
const RATINGS = ['ABOVE_AVERAGE', 'AVERAGE', 'BELOW_AVERAGE', 'UNKNOWN'];
const COMPONENTS = [{ key: 'ctr', field: 'searchPredictedCtr' }, { key: 'rel', field: 'creativeQualityScore' }, { key: 'lpe', field: 'postClickQualityScore' }];

function main() {
  selectAccount_(CONFIG.account_id);
  const ss = openSpreadsheet_(CONFIG.spreadsheet_url);
  const tz = tz_(), cid = AdsApp.currentAccount().getCustomerId(), currency = AdsApp.currentAccount().getCurrencyCode();
  ss.setSpreadsheetTimeZone(tz);
  ensureTabs_(ss, [{ name: t_('tab.summary'), aliases: T['alias.summary'] }, { name: t_('tab.keywords'), aliases: T['alias.keywords'] }, { name: t_('tab.changes'), aliases: T['alias.changes'] },
    { name: TECH.history, aliases: T['alias.history'] }, { name: TECH.snapshot, aliases: T['alias.snapshot'] }]);
  const today = todayStr_(tz), win = window_(CONFIG.days, 0, tz);
  const rows = fetchKeywords_(win);
  const prev = readSnapshot_(ss);
  const agg = aggregate_(rows), changes = detectChanges_(rows, prev, CONFIG.change_threshold);
  const worst = rows.filter(r => r.qs !== null && r.qs <= 4).sort((a, b) => b.m.cost - a.m.cost).slice(0, 25);
  writeKeywords_(ss, rows);
  writeSnapshot_(ss, rows, today);
  writeHistory_(ss, today, cid, rows.length, agg, tz);
  table_(ss.getSheetByName(t_('tab.changes')), 1, ['date', 'campaign', 'ad_group', 'keyword', 'match_type', 'prev_qs', 'qs', 'delta', 'exp_ctr', 'ad_relevance', 'lp_exp'].map(k => t_('col.' + k)).concat([t_('m.impressions'), t_('m.cost')]),
    changes.map(c => [today, c.campaign, c.adGroup, c.text, c.match, c.prevQs, c.qs, c.delta, c.ctr, c.rel, c.lpe, c.m.impressions, c.m.cost]), ['@', '@', '@', '@', '@', '0', '0', '+0;-0;0', '@', '@', '@', '#,##0', '#,##0.00']);
  writeSummary_(ss, cid, currency, today, win, rows, agg, changes, worst);
  notify_(cid, currency, today, rows.length, agg, changes, worst, ss.getUrl());
  seal_(ss, t_('tab.summary'));
  Logger.log(`Done: weighted QS ${agg.weightedQs.toFixed(2)}, ${changes.length} changes ≥ ${CONFIG.change_threshold}.`);
}
function fetchKeywords_(win) {
  const st = CONFIG.enabled_only ? "= 'ENABLED'" : "IN ('ENABLED','PAUSED')";
  let w = `campaign.status ${st} AND ad_group.status ${st} AND ad_group_criterion.status ${st}`;
  if (CONFIG.campaign_contains) w += ` AND campaign.name LIKE '%${escLike_(CONFIG.campaign_contains)}%'`;
  return query_(`SELECT campaign.name, ad_group.id, ad_group.name, ad_group_criterion.criterion_id, ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type,
      ad_group_criterion.quality_info.quality_score, ad_group_criterion.quality_info.search_predicted_ctr, ad_group_criterion.quality_info.creative_quality_score, ad_group_criterion.quality_info.post_click_quality_score,
      metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions, metrics.conversions_value
    FROM keyword_view WHERE segments.date BETWEEN '${win.start}' AND '${win.end}' AND ${w} AND metrics.impressions >= ${CONFIG.min_impressions}`)
    .map(r => { const c = r.adGroupCriterion || {}, q = c.qualityInfo || {}, qs = parseInt(q.qualityScore, 10);
      const rating = v => RATINGS.indexOf(String(v)) >= 0 ? String(v) : 'UNKNOWN';
      return { key: `ag${r.adGroup.id}_cr${c.criterionId}`, campaign: r.campaign.name, adGroup: r.adGroup.name, text: (c.keyword && c.keyword.text) || '', match: (c.keyword && c.keyword.matchType) || '',
        qs: isFinite(qs) ? qs : null, ctr: rating(q.searchPredictedCtr), rel: rating(q.creativeQualityScore), lpe: rating(q.postClickQualityScore), m: derive_(rawOf_(r)) }; });
}
function aggregate_(rows) {
  const z = () => Object.assign({ keywords: 0 }, zeroM_()), add = (a, r) => { a.keywords++; Object.assign(a, addM_(a, r.m)); return a; };
  const byQs = {}; for (let i = 1; i <= 10; i++) byQs[i] = z(); byQs['n/a'] = z();
  const byComp = {}; COMPONENTS.forEach(c => { byComp[c.key] = {}; RATINGS.forEach(r => byComp[c.key][r] = z()); });
  let wSum = 0, wImpr = 0, scored = 0, qsSum = 0;
  rows.forEach(r => { const b = byQs[r.qs === null ? 'n/a' : r.qs]; add(b, r); COMPONENTS.forEach(c => add(byComp[c.key][r[c.key]], r));
    if (r.qs !== null) { wSum += r.qs * r.m.impressions; wImpr += r.m.impressions; scored++; qsSum += r.qs; } });
  const total = rows.reduce((a, r) => add(a, r), z());
  return { byQs, byComp, total, scored, weightedQs: wImpr ? wSum / wImpr : 0, simpleQs: scored ? qsSum / scored : 0, lowCost: rows.filter(r => r.qs !== null && r.qs <= 4).reduce((s, r) => s + r.m.cost, 0) };
}
function detectChanges_(rows, prev, thr) {
  const out = [];
  rows.forEach(r => { if (r.qs === null) return; const p = prev[r.key]; if (p === undefined || p === null) return; const d = r.qs - p; if (Math.abs(d) >= thr) out.push(Object.assign({ prevQs: p, delta: d }, r)); });
  return out.sort((a, b) => a.delta - b.delta || b.m.cost - a.m.cost);
}
function writeKeywords_(ss, rows) {
  const head = ['campaign', 'ad_group', 'keyword', 'match_type', 'qs', 'exp_ctr', 'ad_relevance', 'lp_exp'].map(k => t_('col.' + k)).concat(mHead_());
  const data = rows.slice().sort((a, b) => b.m.cost - a.m.cost).map(r => [r.campaign, r.adGroup, r.text, r.match, r.qs === null ? '' : r.qs, r.ctr, r.rel, r.lpe].concat(mRow_(r.m)));
  const sh = ss.getSheetByName(t_('tab.keywords'));
  const n = table_(sh, 1, head, data, ['@', '@', '@', '@', '0', '@', '@', '@'].concat(mFmt_()), CONFIG.max_rows_written);
  sh.clearConditionalFormatRules();
  if (n) { const rng = sh.getRange(2, 6, n, 3), qsr = sh.getRange(2, 5, n, 1);
    sh.setConditionalFormatRules([
      SpreadsheetApp.newConditionalFormatRule().whenTextEqualTo('BELOW_AVERAGE').setBackground('#FDE2E1').setRanges([rng]).build(),
      SpreadsheetApp.newConditionalFormatRule().whenTextEqualTo('ABOVE_AVERAGE').setBackground('#DCF5E6').setRanges([rng]).build(),
      SpreadsheetApp.newConditionalFormatRule().whenNumberLessThanOrEqualTo(4).setBackground('#FDE2E1').setRanges([qsr]).build(),
      SpreadsheetApp.newConditionalFormatRule().whenNumberGreaterThanOrEqualTo(8).setBackground('#DCF5E6').setRanges([qsr]).build()]); }
}
function readSnapshot_(ss) {
  const sh = ss.getSheetByName(TECH.snapshot), out = {};
  if (sh.getLastRow() < 2) return out;
  sh.getRange(2, 1, sh.getLastRow() - 1, 2).getValues().forEach(([k, q]) => { if (!k) return; const key = String(k).replace(/^(\d+):(\d+)$/, 'ag$1_cr$2'); out[key] = q === '' ? null : parseInt(q, 10); });   // v1 keys 'ag:cr' still compare
  return out;
}
/* New rows first, then the leftover is cleared — a failed write never destroys the previous snapshot. */
function writeSnapshot_(ss, rows, today) {
  const sh = ss.getSheetByName(TECH.snapshot);
  replaceRows_(sh, ['key', 'qs', 'date'], rows.map(r => [r.key, r.qs === null ? '' : r.qs, today]), ['@', '0', '@']);
  sh.hideSheet();
}
function writeHistory_(ss, today, cid, n, agg, tz) {
  const sh = ss.getSheetByName(TECH.history);
  const head = ['date', 'account', 'keywords', 'scored', 'weighted_qs', 'simple_qs', 'cost_qs_le4', 'total_cost'].concat([1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => 'impr_qs_' + i))
    .concat(COMPONENTS.reduce((a, c) => a.concat(['ABOVE_AVERAGE', 'AVERAGE', 'BELOW_AVERAGE'].map(r => c.key + '_' + r.toLowerCase())), []));
  const row = [today, cid, n, agg.scored, agg.weightedQs, agg.simpleQs, agg.lowCost, agg.total.cost].concat([1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => agg.byQs[i].impressions))
    .concat(COMPONENTS.reduce((a, c) => a.concat(['ABOVE_AVERAGE', 'AVERAGE', 'BELOW_AVERAGE'].map(r => agg.byComp[c.key][r].impressions)), []));
  upsertRow_(sh, head, row, 2, ['@', '@'], tz);
  const extra = sh.getLastRow() - 1 - CONFIG.keep_history_days;
  if (extra > 0) sh.deleteRows(2, extra);
}
function writeSummary_(ss, cid, currency, today, win, rows, agg, changes, worst) {
  const sh = ss.getSheetByName(t_('tab.summary')), t = agg.total;
  sh.getRange(2, 1, Math.max(1, sh.getMaxRows() - 1), Math.max(1, sh.getMaxColumns())).clear();
  sh.getRange('A1').setValue(t_('title')).setFontWeight('bold').setFontSize(13);
  const out = [[t_('col.account'), cid], [t_('col.date'), today], [t_('sum.window'), win.label], [t_('col.currency'), currency], [t_('col.keywords'), rows.length], [t_('sum.scored'), agg.scored],
    [t_('sum.weighted_qs'), agg.weightedQs], [t_('sum.simple_qs'), agg.simpleQs], [t_('sum.low_cost'), agg.lowCost], [t_('sum.low_share'), t.cost ? agg.lowCost / t.cost : 0], [t_('sum.changes', { n: CONFIG.change_threshold }), changes.length],
    [], [t_('sum.by_qs'), t_('m.impressions'), t_('m.clicks'), t_('m.cost'), t_('m.conversions')]];
  for (let i = 1; i <= 10; i++) { const b = agg.byQs[i]; out.push([String(i), b.impressions, b.clicks, b.cost, b.conversions]); }
  const na = agg.byQs['n/a']; out.push([t_('sum.no_qs'), na.impressions, na.clicks, na.cost, na.conversions]);
  COMPONENTS.forEach(c => { out.push([], [t_('comp.' + c.key), t_('col.keywords'), t_('m.impressions'), t_('m.cost'), t_('m.conversions')]); RATINGS.forEach(r => { const b = agg.byComp[c.key][r]; out.push([r, b.keywords, b.impressions, b.cost, b.conversions]); }); });
  out.push([], [t_('sum.worst'), 'QS', t_('m.cost'), t_('m.conversions'), t_('sum.weakest')]);
  worst.forEach(w => out.push([`${w.text} (${w.campaign})`, w.qs, w.m.cost, w.m.conversions, COMPONENTS.filter(c => w[c.key] === 'BELOW_AVERAGE').map(c => t_('comp.' + c.key)).join(', ') || '—']));
  sh.getRange(3, 1, out.length, 1).setNumberFormat('@'); sh.getRange(4, 2).setNumberFormat('@'); sh.getRange(5, 2).setNumberFormat('@');
  sh.getRange(3, 1, out.length, 5).setValues(out.map(r => { while (r.length < 5) r.push(''); return r; }));
  sh.getRange(9, 2, 2, 1).setNumberFormat('0.00'); sh.getRange(11, 2).setNumberFormat('#,##0.00'); sh.getRange(12, 2).setNumberFormat('0.0%');
  out.forEach((r, i) => { if (r.length && [t_('sum.by_qs'), t_('sum.worst')].includes(r[0]) || COMPONENTS.some(c => r[0] === t_('comp.' + c.key))) sh.getRange(3 + i, 1, 1, 5).setFontWeight('bold').setBackground('#F1F5F9'); });
  sh.setColumnWidths(1, 1, 320); sh.setColumnWidths(2, 4, 110);
}
function notify_(cid, currency, today, n, agg, changes, worst, url) {
  if (!CONFIG.email) return;
  const drops = changes.filter(c => c.delta < 0).slice(0, 10), gains = changes.filter(c => c.delta > 0).slice(-10).reverse();
  const li = c => '<li>' + h_(c.text) + ' <span style="color:#64748B">(' + h_(c.campaign) + ')</span>: ' + c.prevQs + ' → <b>' + c.qs + '</b></li>';
  const body = kpiTable_([[t_('sum.weighted_qs'), agg.weightedQs.toFixed(2), '', ''], [t_('sum.low_cost'), fmtNum_(agg.lowCost) + ' (' + fmtPct_(agg.total.cost ? agg.lowCost / agg.total.cost : 0, 0) + ')', '', ''], [t_('sum.changes', { n: CONFIG.change_threshold }), String(changes.length), '', '']]) +
    '<div style="padding:12px 16px 4px;font-weight:bold;color:#B91C1C">' + h_(t_('mail.drops')) + '</div><ul style="margin:0 16px 8px 32px;padding:0">' + (drops.map(li).join('') || '<li>—</li>') + '</ul>' +
    '<div style="padding:12px 16px 4px;font-weight:bold;color:#15803D">' + h_(t_('mail.gains')) + '</div><ul style="margin:0 16px 8px 32px;padding:0">' + (gains.map(li).join('') || '<li>—</li>') + '</ul>' +
    '<div style="padding:12px 16px 4px;font-weight:bold;color:#0F766E">' + h_(t_('sum.worst')) + '</div><ul style="margin:0 16px 8px 32px;padding:0">' + (worst.slice(0, 5).map(w => '<li>' + h_(w.text) + ' — QS ' + w.qs + ', ' + fmtNum_(w.m.cost) + '</li>').join('') || '<li>—</li>') + '</ul>';
  const subject = t_('mail.subject', { cid: cid, qs: agg.weightedQs.toFixed(2), n: changes.length });
  sendMail_(CONFIG.email, subject, htmlShell_(t_('title') + ' · ' + cid, `${today} · ${n} ${t_('col.keywords')} · ${currency}`, body, url), subject + '\n' + url);
}
3 langsSchedule: Daily

Mines 90 days of search terms into 1- and 2-word n-grams and writes high-spend, zero-conversion negative-keyword candidates to a Google Sheet.

// Search Term N-Gram Negative Miner — Google Ads script
// Mines the search-term report into 1- and 2-word n-grams, aggregates
// cost / conversions per n-gram, and writes negative-keyword candidates
// (high spend, zero conversions) to a Google Sheet for review.
var SPREADSHEET_URL = 'PASTE_YOUR_SHEET_URL';
var LOOKBACK_DAYS   = 90;
var MIN_COST        = 50;   // flag n-grams that spent at least this (account currency)
var MAX_NGRAM       = 2;    // 1 = unigrams only, 2 = unigrams + bigrams

function main() {
  var tz    = AdsApp.currentAccount().getTimeZone();
  var to    = new Date();
  var from  = new Date(to.getTime() - LOOKBACK_DAYS * 864e5);
  var range = Utilities.formatDate(from, tz, 'yyyyMMdd') + ',' +
              Utilities.formatDate(to, tz, 'yyyyMMdd');

  var report = AdsApp.report(
    'SELECT Query, Cost, Conversions, Clicks ' +
    'FROM SEARCH_QUERY_PERFORMANCE_REPORT ' +
    'WHERE CampaignStatus = ENABLED ' +
    'DURING ' + range
  );

  var grams = {};
  var rows  = report.rows();
  while (rows.hasNext()) {
    var row   = rows.next();
    var cost  = parseFloat(row['Cost']) || 0;
    var conv  = parseFloat(row['Conversions']) || 0;
    var clk   = parseInt(row['Clicks'], 10) || 0;
    var words = String(row['Query']).toLowerCase().split(/s+/);

    for (var n = 1; n <= MAX_NGRAM; n++) {
      for (var i = 0; i + n <= words.length; i++) {
        var g = words.slice(i, i + n).join(' ');
        if (!grams[g]) { grams[g] = { cost: 0, conv: 0, clicks: 0 }; }
        grams[g].cost   += cost;
        grams[g].conv   += conv;
        grams[g].clicks += clk;
      }
    }
  }

  var out = [['N-gram', 'Cost', 'Conversions', 'Clicks']];
  Object.keys(grams).forEach(function (g) {
    var s = grams[g];
    if (s.cost >= MIN_COST && s.conv === 0) {
      out.push([g, s.cost.toFixed(2), s.conv, s.clicks]);
    }
  });
  out.sort(function (a, b) { return (b[1] - a[1]) || 0; });

  var sheet = SpreadsheetApp.openByUrl(SPREADSHEET_URL).getActiveSheet();
  sheet.clearContents();
  sheet.getRange(1, 1, out.length, 4).setValues(out);
  Logger.log((out.length - 1) + ' negative candidates written to the sheet.');
}
1 langSchedule: Weekly

A Google Ads script that pauses keywords below a target ROAS over a rolling window and logs each action to a Google Sheet.

// Pause Low-ROAS Keywords — Google Ads script
var TARGET_ROAS = 3.0;      // pause below this
var LOOKBACK_DAYS = 30;     // rolling window
var LABEL = 'auto-paused-low-roas';

function main() {
  var to = new Date();
  var from = new Date(to.getTime() - LOOKBACK_DAYS * 864e5);
  var range = Utilities.formatDate(from, AdsApp.currentAccount().getTimeZone(), 'yyyyMMdd') +
              ',' + Utilities.formatDate(to, AdsApp.currentAccount().getTimeZone(), 'yyyyMMdd');

  var kws = AdsApp.keywords()
    .withCondition('Status = ENABLED')
    .forDateRange(range)
    .withCondition('Conversions > 0')
    .get();

  while (kws.hasNext()) {
    var kw = kws.next();
    var stats = kw.getStatsFor(range);
    var roas = stats.getConversionValue() / Math.max(stats.getCost(), 0.01);
    if (roas < TARGET_ROAS) {
      kw.pause();
      kw.applyLabel(LABEL);
    }
  }
}
1 langSchedule: Daily