Hängt täglich eine Zeile mit 20 Kontometriken an den Report-Tab an und sendet per E-Mail einen Vergleich gestern / vorgestern / vor einer Woche.

// account-summary — DE 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":"de","locale":"de-DE","text_lang":"de","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":"Dieser Zeitraum","kpi.prev":"Vorher","yes":"ja","no":"nein","mail.full_report":"Vollständiger Bericht","mail.open_report":"Bericht öffnen","note.no_rows":"Keine Zeilen.","note.truncated":"{shown} von {total} Zeilen angezeigt (max_rows_written).","note.no_campaigns":"Keine Kampagnen mit Impressionen entsprechen den aktuellen Filtern.","settings.key":"Schlüssel","settings.value":"Wert","settings.notes":"Hinweise","settings.script":"Script, dem dieses Sheet gehört (nicht ändern)","settings.account":"Google Ads-Konto, dem dieses Sheet gehört (nicht ändern)","note.truncated_queries":"⚠ {n} Abfrage(n) am Leselimit — Ergebnis unvollständig; Zeitraum verkleinern","note.account_neg_failed":"⚠ Negativs auf Kontoebene nicht lesbar:","note.skipped_time":"übersprungen — Zeitbudget","note.data_error":"Datenfehler:","note.failed":"FEHLGESCHLAGEN —","note.no_final_url":"(keine finale URL)","m.impressions":"Impr.","m.clicks":"Klicks","m.ctr":"CTR","m.cost":"Kosten","m.cpc":"Ø CPC","m.conversions":"Conv.","m.value":"Conv.-Wert","m.cr":"Conv.-Rate","m.cpa":"Kosten/Conv.","m.roas":"ROAS","m.aov":"Ø Bestellwert","m.vpc":"Wert / Klick","col.account":"Konto","col.period":"Zeitraum","col.currency":"Währung","col.date":"Datum","col.campaign":"Kampagne","col.ad_group":"Anzeigengruppe","col.n":"N","col.phrase":"Phrase","col.queries":"Suchanfragen","col.source":"Quelle","col.why":"Warum","title":"Account Summary — Kontoübersicht","tab.report":"Bericht","settings.email":"Empfänger, kommagetrennt; leer = keine E-Mail","settings.last_check":"zuletzt geschriebener Tag (yyyy-MM-dd); für Nachfüllen ein früheres Datum eintragen (max. 90 Tage)","mail.subject":"Google Ads {cid} — Kontoübersicht {date}","mail.yesterday":"Gestern","mail.two_days_ago":"Vor zwei Tagen","mail.week_ago":"Vor einer Woche","f.cost_micros":"Kosten","f.average_cpc":"Ø CPC","f.ctr":"CTR","f.search_impression_share":"Such-Impr.-Anteil","f.impressions":"Impressionen","f.clicks":"Klicks","f.conversions":"Conversions","f.conversions_value":"Conv.-Wert","f.cost_per_conversion":"Kosten/Conv.","f.conversions_from_interactions_rate":"Conv.-Rate","f.all_conversions":"Alle Conv.","f.view_through_conversions":"View-Through-Conv.","f.search_budget_lost_impression_share":"Verl. IS (Budget)","f.search_rank_lost_impression_share":"Verl. IS (Rang)","f.search_top_impression_share":"Top-IS","f.search_absolute_top_impression_share":"Abs. Top-IS","f.average_cpm":"Ø CPM","f.interactions":"Interaktionen","f.interaction_rate":"Interaktionsrate","f.invalid_clicks":"Ungültige Klicks","alias.report":["Report","Rapor"],"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"]};

/* ===================== 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 SprachenZeitplan: Daily

Stündlicher Konto-Monitor: vergleicht den heutigen Tag per Median/MAD-Z-Score mit demselben Wochentag der Vorwochen und sendet deduplizierte Alerts mit Entwarnung.

// anomaly-sentinel — DE 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":"de","locale":"de-DE","text_lang":"de","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":"Dieser Zeitraum","kpi.prev":"Vorher","yes":"ja","no":"nein","mail.full_report":"Vollständiger Bericht","mail.open_report":"Bericht öffnen","note.no_rows":"Keine Zeilen.","note.truncated":"{shown} von {total} Zeilen angezeigt (max_rows_written).","note.no_campaigns":"Keine Kampagnen mit Impressionen entsprechen den aktuellen Filtern.","settings.key":"Schlüssel","settings.value":"Wert","settings.notes":"Hinweise","settings.script":"Script, dem dieses Sheet gehört (nicht ändern)","settings.account":"Google Ads-Konto, dem dieses Sheet gehört (nicht ändern)","note.truncated_queries":"⚠ {n} Abfrage(n) am Leselimit — Ergebnis unvollständig; Zeitraum verkleinern","note.account_neg_failed":"⚠ Negativs auf Kontoebene nicht lesbar:","note.skipped_time":"übersprungen — Zeitbudget","note.data_error":"Datenfehler:","note.failed":"FEHLGESCHLAGEN —","note.no_final_url":"(keine finale URL)","m.impressions":"Impr.","m.clicks":"Klicks","m.ctr":"CTR","m.cost":"Kosten","m.cpc":"Ø CPC","m.conversions":"Conv.","m.value":"Conv.-Wert","m.cr":"Conv.-Rate","m.cpa":"Kosten/Conv.","m.roas":"ROAS","m.aov":"Ø Bestellwert","m.vpc":"Wert / Klick","col.account":"Konto","col.period":"Zeitraum","col.currency":"Währung","col.date":"Datum","col.campaign":"Kampagne","col.ad_group":"Anzeigengruppe","col.n":"N","col.phrase":"Phrase","col.queries":"Suchanfragen","col.source":"Quelle","col.why":"Warum","title":"Anomaly Sentinel — Alarmprotokoll","tab.alerts":"Alarme","settings.email":"Empfänger, kommagetrennt; leer = keine E-Mail","settings.weeks":"Wochentags-Historie in Wochen (Standard 12)","settings.lag_hours":"Toleranz für Reporting-Verzögerung in Stunden (Standard 3)","settings.quiet_hours_end":"vor dieser Stunde keine ratenbasierten Alarme (Standard 6)","settings.min_impressions":"Volumenschwelle für Ratenmetriken (Standard 1000)","settings.min_clicks":"Volumenschwelle für Ratenmetriken (Standard 50)","settings.min_conversions_baseline":"keine CPA- / Conv.-Rate- / ROAS-Alarme, wenn die Basis-Conversions darunter liegen (Standard 3)","settings.z_warning":"Standard 2.0","settings.z_critical":"Standard 3.0","settings.min_pct_deviation":"Abweichungen unter diesem Anteil ignorieren, z. B. 0.20 (Standard)","settings.exception_dates":"yyyy-MM-dd, kommagetrennt (Feiertage, Launches)","settings.campaign_hints":"Top-N Kampagnen, die eine Abweichung erklären (Standard 3)","settings.conversion_metrics_max_severity":"WARNING (Standard) oder CRITICAL — Obergrenze für Conversion-Metriken, bis die Snapshot-Basis genug Historie hat","col.timestamp":"Zeitstempel","col.hour":"Stunde","col.metric":"Metrik","col.status":"Status","col.severity":"Schweregrad","col.observed":"Beobachtet","col.expected":"Erwartet","col.deviation":"Abweichung","col.samples":"Stichproben","col.hints":"Kampagnen-Hinweise","col.delivered":"Zugestellt über","sev.OK":"OK","sev.WARNING":"WARNUNG","sev.CRITICAL":"KRITISCH","status.NEW":"NEU","status.ESCALATED":"ESKALIERT","status.RECOVERED":"ERHOLT","mail.subject":"Google Ads {cid} — {sev} Anomalie ({date} {hour}:00)","mail.subject_recovered":"✅ Google Ads {cid} — Anomalien erholt ({date} {hour}:00)","mail.sub":"{date} · ausgewertet bis {hour}:00 · {currency}","alias.alerts":["Alerts","Uyarılar","Alerts Log","Uyarı Günlüğü","Alarmprotokoll"],"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"]};

/* ===================== 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 SprachenZeitplan: Hourly

Wöchentlicher Creative-Report: Anzeigenmetriken für zwei Zeiträume, RSA-Asset-Labels, Pinning, Anzeigeneffektivität, Freigabestatus, Landingpage-Ökonomie und eine priorisierte Fix-Liste.

// creative-lens — DE 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":"de","locale":"de-DE","text_lang":"de","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":"Dieser Zeitraum","kpi.prev":"Vorher","yes":"ja","no":"nein","mail.full_report":"Vollständiger Bericht","mail.open_report":"Bericht öffnen","note.no_rows":"Keine Zeilen.","note.truncated":"{shown} von {total} Zeilen angezeigt (max_rows_written).","note.no_campaigns":"Keine Kampagnen mit Impressionen entsprechen den aktuellen Filtern.","settings.key":"Schlüssel","settings.value":"Wert","settings.notes":"Hinweise","settings.script":"Script, dem dieses Sheet gehört (nicht ändern)","settings.account":"Google Ads-Konto, dem dieses Sheet gehört (nicht ändern)","note.truncated_queries":"⚠ {n} Abfrage(n) am Leselimit — Ergebnis unvollständig; Zeitraum verkleinern","note.account_neg_failed":"⚠ Negativs auf Kontoebene nicht lesbar:","note.skipped_time":"übersprungen — Zeitbudget","note.data_error":"Datenfehler:","note.failed":"FEHLGESCHLAGEN —","note.no_final_url":"(keine finale URL)","m.impressions":"Impr.","m.clicks":"Klicks","m.ctr":"CTR","m.cost":"Kosten","m.cpc":"Ø CPC","m.conversions":"Conv.","m.value":"Conv.-Wert","m.cr":"Conv.-Rate","m.cpa":"Kosten/Conv.","m.roas":"ROAS","m.aov":"Ø Bestellwert","m.vpc":"Wert / Klick","col.account":"Konto","col.period":"Zeitraum","col.currency":"Währung","col.date":"Datum","col.campaign":"Kampagne","col.ad_group":"Anzeigengruppe","col.n":"N","col.phrase":"Phrase","col.queries":"Suchanfragen","col.source":"Quelle","col.why":"Warum","title":"Creative Lens — wöchentlicher Creative-Report","tab.summary":"Zusammenfassung","tab.ads":"Anzeigen","tab.assets":"Assets","tab.headlines":"Überschriften","tab.urls":"Finale URLs","col.channel":"Kanal","col.ad_id":"Anzeigen-ID","col.ad_name":"Anzeigenname","col.type":"Typ","col.status":"Status","col.strength":"Anzeigeneffektivität","col.approval":"Freigabe","col.final_url":"Finale URL","col.prev_impressions":"Vorh. Impr.","col.prev_clicks":"Vorh. Klicks","col.prev_ctr":"Vorh. CTR","col.prev_cost":"Vorh. Kosten","col.prev_conversions":"Vorh. Conv.","col.wow_impressions":"Impr. WoW","col.wow_ctr":"CTR WoW","col.wow_cost":"Kosten WoW","col.wow_conversions":"Conv. WoW","col.field":"Feld","col.asset_text":"Asset-Text","col.label":"Leistungslabel","col.pinned":"Angepinnt","col.enabled":"Aktiv","col.ads_using":"Anzeigen damit","col.dominant_label":"Dominantes Label","col.times_pinned":"Anpinnungen","col.ads":"Anzeigen","col.assets":"Assets","col.shared_urls":"Geteilt (nicht gezählt)","col.where":"Wo","col.what":"Was","col.cost_in_period":"Kosten im Zeitraum","sum.ads":"Anzeigen mit Impressionen","sum.assets":"RSA-Assets mit Impressionen","sum.strength":"Anzeigeneffektivität","sum.labels":"Asset-Label","sum.health":"Gesundheit","sum.disapproved":"Abgelehnte Anzeigen","sum.limited":"Eingeschränkte Anzeigen","sum.poor":"Anzeigen POOR / AVERAGE","sum.low":"LOW-bewertete Assets","sum.pinned":"Angepinnte Assets","sum.top_headlines":"Top-Überschriften nach CTR (≥ {n} Impressionen)","fix.title":"Fix-Liste — diese Woche erledigen","fix.none":"Nichts zu fixen — gute Woche.","fix.disapproved":"Abgelehnte Anzeige","fix.disapproved_what":"Anzeige {id} ist abgelehnt","fix.no_conv":"Ausgaben, keine Conversions","fix.no_conv_what":"Anzeige {id}: {clicks} Klicks, {cost} ausgegeben, 0 Conv.","fix.poor":"Schwache Anzeigeneffektivität","fix.poor_what":"Anzeige {id} POOR bei {impr} Impressionen","fix.low_assets":"LOW-Assets ersetzen","fix.low_assets_what":"Anzeige {id}: {n} LOW-Assets, z. B. {ex}","fix.ctr_drop":"CTR-Einbruch WoW","fix.ctr_drop_what":"Anzeige {id}: CTR {now} vs. {prev} Vorperiode","fix.pinned_low":"Angepinnt, aber LOW","fix.pinned_low_what":"\"{text}\" angepinnt auf {pos}, aber LOW","mail.subject":"Creative Lens · {cid} · {period} · {n} Fixes","mail.sub":"{period} vs. vorherige {days} Tage · {currency}","mail.health":"{dis} abgelehnt · {poor} Anzeigen POOR/AVERAGE · {low} LOW-Assets","alias.summary":["Summary","Özet"],"alias.ads":["Ads","Reklamlar"],"alias.assets":["Öğeler","Metinler"],"alias.headlines":["Headlines","Başlıklar"],"alias.urls":["Final URLs","Son URL’ler","Son URL Listesi"],"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"]};

/* ===================== 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 SprachenZeitplan: Weekly

Suchbegriff-N-Gramm-Analyse über Search, Shopping, DSA und Performance Max mit Negativ-Keyword-Abgleich sowie gereihten Negativ- und Erweiterungskandidaten.

// ngram-lens — DE 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":"de","locale":"de-DE","text_lang":"de","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":"Dieser Zeitraum","kpi.prev":"Vorher","yes":"ja","no":"nein","mail.full_report":"Vollständiger Bericht","mail.open_report":"Bericht öffnen","note.no_rows":"Keine Zeilen.","note.truncated":"{shown} von {total} Zeilen angezeigt (max_rows_written).","note.no_campaigns":"Keine Kampagnen mit Impressionen entsprechen den aktuellen Filtern.","settings.key":"Schlüssel","settings.value":"Wert","settings.notes":"Hinweise","settings.script":"Script, dem dieses Sheet gehört (nicht ändern)","settings.account":"Google Ads-Konto, dem dieses Sheet gehört (nicht ändern)","note.truncated_queries":"⚠ {n} Abfrage(n) am Leselimit — Ergebnis unvollständig; Zeitraum verkleinern","note.account_neg_failed":"⚠ Negativs auf Kontoebene nicht lesbar:","note.skipped_time":"übersprungen — Zeitbudget","note.data_error":"Datenfehler:","note.failed":"FEHLGESCHLAGEN —","note.no_final_url":"(keine finale URL)","m.impressions":"Impr.","m.clicks":"Klicks","m.ctr":"CTR","m.cost":"Kosten","m.cpc":"Ø CPC","m.conversions":"Conv.","m.value":"Conv.-Wert","m.cr":"Conv.-Rate","m.cpa":"Kosten/Conv.","m.roas":"ROAS","m.aov":"Ø Bestellwert","m.vpc":"Wert / Klick","col.account":"Konto","col.period":"Zeitraum","col.currency":"Währung","col.date":"Datum","col.campaign":"Kampagne","col.ad_group":"Anzeigengruppe","col.n":"N","col.phrase":"Phrase","col.queries":"Suchanfragen","col.source":"Quelle","col.why":"Warum","title":"N-Gram Lens — Suchbegriff-Analyse","tab.summary":"Zusammenfassung","tab.account_ngrams":"Konto-N-Gramme","tab.campaign_ngrams":"Kampagnen-N-Gramme","tab.word_count":"Wortanzahl","tab.suggestions":"Vorschläge","col.words_in_query":"Wörter je Anfrage","sum.campaigns":"Kampagnen im Umfang","sum.sources":"Quellen","sum.queries":"Analysierte Anfragen","sum.excluded":"Durch Negativs ausgeschlossene Anfragen","sum.sizes":"N-Gramm-Größen","sum.account_ngrams":"Konto-N-Gramme (nach Schwellen)","sum.totals":"Kontosummen","sum.neg_candidates":"Negativ-Kandidaten","sum.exp_candidates":"Erweiterungs-Kandidaten","sum.cost_on_negatives":"Kosten auf Negativ-Kandidaten","sum.notes":"Hinweise","sugg.negatives":"Negativ-Kandidaten — prüfen, dann als Phrase-Negativ hinzufügen (nie automatisch)","sugg.expansions":"Erweiterungs-Kandidaten — Phrasen mit besserer Conversion als das Konto","why.no_conv":"{clicks} Klicks, 0 Conversions","why.expected":"Kontorate erwartet ~{n} Conv.","why.cpa":"Kosten/Conv. {cpa} vs. Konto {acc}","why.roas":"ROAS {v} vs. Konto {acc}","why.cr":"Conv.-Rate {v} vs. Konto {acc}","note.ngram_counting":"Jedes N-Gramm zählt je Anfrage einmal; Broad-Negativs in beliebiger Reihenfolge, Phrase zusammenhängend, Exact ganze Anfrage.","note.pmax_source":"Performance-Max-Begriffe stammen aus campaign_search_term_view (Kampagnenebene, mit Kosten); Suche/Shopping/DSA aus search_term_view.","alias.summary":["Summary","Özet"],"alias.account_ngrams":["Account n-grams","Hesap n-gramları"],"alias.campaign_ngrams":["Campaign n-grams","Kampanya n-gramları"],"alias.word_count":["Word count","Kelime sayısı"],"alias.suggestions":["Suggestions","Tavsiyeler"],"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"]};

/* ===================== 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 SprachenZeitplan: Weekly

Performance-Max-Suchbegriff-N-Gramm-Analyse mit echten Kostendaten, Brand/Generic-Split, Negativ-Abgleich und gereihten Negativkandidaten für den Ausschluss auf Kampagnenebene.

// pmax-ngram-lens — DE 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":"de","locale":"de-DE","text_lang":"de","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":"Dieser Zeitraum","kpi.prev":"Vorher","yes":"ja","no":"nein","mail.full_report":"Vollständiger Bericht","mail.open_report":"Bericht öffnen","note.no_rows":"Keine Zeilen.","note.truncated":"{shown} von {total} Zeilen angezeigt (max_rows_written).","note.no_campaigns":"Keine Kampagnen mit Impressionen entsprechen den aktuellen Filtern.","settings.key":"Schlüssel","settings.value":"Wert","settings.notes":"Hinweise","settings.script":"Script, dem dieses Sheet gehört (nicht ändern)","settings.account":"Google Ads-Konto, dem dieses Sheet gehört (nicht ändern)","note.truncated_queries":"⚠ {n} Abfrage(n) am Leselimit — Ergebnis unvollständig; Zeitraum verkleinern","note.account_neg_failed":"⚠ Negativs auf Kontoebene nicht lesbar:","note.skipped_time":"übersprungen — Zeitbudget","note.data_error":"Datenfehler:","note.failed":"FEHLGESCHLAGEN —","note.no_final_url":"(keine finale URL)","m.impressions":"Impr.","m.clicks":"Klicks","m.ctr":"CTR","m.cost":"Kosten","m.cpc":"Ø CPC","m.conversions":"Conv.","m.value":"Conv.-Wert","m.cr":"Conv.-Rate","m.cpa":"Kosten/Conv.","m.roas":"ROAS","m.aov":"Ø Bestellwert","m.vpc":"Wert / Klick","col.account":"Konto","col.period":"Zeitraum","col.currency":"Währung","col.date":"Datum","col.campaign":"Kampagne","col.ad_group":"Anzeigengruppe","col.n":"N","col.phrase":"Phrase","col.queries":"Suchanfragen","col.source":"Quelle","col.why":"Warum","title":"PMax N-Gram Lens — Suchbegriff-Analyse","tab.summary":"Zusammenfassung","tab.account_ngrams":"Konto-N-Gramme","tab.campaign_ngrams":"Kampagnen-N-Gramme","tab.categories":"Kategorien","tab.brand":"Marken-Split","tab.suggestions":"Vorschläge","tab.terms":"Suchbegriffe","col.category":"Kategorie","col.segment":"Segment","col.paste_as":"Einfügen als","col.wasted_clicks":"Verschw. Klicks (gesch.)","col.search_term":"Suchbegriff","col.blocked":"Durch Negativ blockiert","brand.brand":"Marke","brand.generic":"Generisch","uncategorised":"(ohne Kategorie)","sum.pmax_campaigns":"PMax-Kampagnen im Umfang","sum.queries":"Analysierte eindeutige (Kampagne, Begriff)-Zeilen","sum.excluded":"Durch Negativs ausgeschlossene Zeilen","sum.sizes":"N-Gramm-Größen","sum.account_ngrams":"Konto-N-Gramme (nach Schwellen)","sum.totals":"Summen","sum.brand_split":"Marke vs. generisch (Klickanteil)","sum.neg_candidates":"Negativ-Kandidaten","sum.cost_on_negatives":"Kosten auf Negativ-Kandidaten","sum.notes":"Hinweise","sugg.negatives_pmax":"Negativ-Kandidaten — prüfen, dann als Phrase-Negativ zur Kampagne hinzufügen (nie automatisch)","why.no_conv":"{clicks} Klicks, 0 Conversions","why.cpa":"Kosten/Conv. {cpa} vs. Konto {acc}","why.cr_low":"Conv.-Rate {v} vs. Konto {acc}","note.pmax_view":"Quelle: campaign_search_term_view — echte Suchbegriffe mit Kosten, Kampagnenebene.","note.negatives_pmax":"Geprüfte Negativs: Kampagnenebene, angehängte Listen (per ID), Kontoebene.","note.ngram_counting":"Jedes N-Gramm zählt je Anfrage einmal; Broad-Negativs in beliebiger Reihenfolge, Phrase zusammenhängend, Exact ganze Anfrage.","note.categories_off":"Kategorie-Rollup ist aus (CONFIG.include_categories). Benötigt eine zusätzliche Abfrage je Kampagne.","note.brand_off":"(CONFIG.brand_terms setzen, um den Split zu aktivieren)","alias.summary":["Summary","Özet"],"alias.account_ngrams":["Account n-grams","Hesap n-gramları"],"alias.campaign_ngrams":["Campaign n-grams","Kampanya n-gramları"],"alias.categories":["Categories","Kategoriler","Category n-grams","Kategori n-gramları","Kategorie-N-Gramme"],"alias.brand":["Brand split","Marka ayrımı"],"alias.suggestions":["Suggestions","Tavsiyeler"],"alias.terms":["Terms","Terimler"],"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"]};

/* ===================== 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 SprachenZeitplan: Weekly

Täglicher Keyword-Qualitätsfaktor-Tracker mit Komponenten, Änderungsprotokoll gegenüber dem vorherigen Snapshot, idempotenter Verlaufszeile und Übersicht, wo niedrige Qualität am meisten kostet.

// quality-lens — DE 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":"de","locale":"de-DE","text_lang":"de","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":"Dieser Zeitraum","kpi.prev":"Vorher","yes":"ja","no":"nein","mail.full_report":"Vollständiger Bericht","mail.open_report":"Bericht öffnen","note.no_rows":"Keine Zeilen.","note.truncated":"{shown} von {total} Zeilen angezeigt (max_rows_written).","note.no_campaigns":"Keine Kampagnen mit Impressionen entsprechen den aktuellen Filtern.","settings.key":"Schlüssel","settings.value":"Wert","settings.notes":"Hinweise","settings.script":"Script, dem dieses Sheet gehört (nicht ändern)","settings.account":"Google Ads-Konto, dem dieses Sheet gehört (nicht ändern)","note.truncated_queries":"⚠ {n} Abfrage(n) am Leselimit — Ergebnis unvollständig; Zeitraum verkleinern","note.account_neg_failed":"⚠ Negativs auf Kontoebene nicht lesbar:","note.skipped_time":"übersprungen — Zeitbudget","note.data_error":"Datenfehler:","note.failed":"FEHLGESCHLAGEN —","note.no_final_url":"(keine finale URL)","m.impressions":"Impr.","m.clicks":"Klicks","m.ctr":"CTR","m.cost":"Kosten","m.cpc":"Ø CPC","m.conversions":"Conv.","m.value":"Conv.-Wert","m.cr":"Conv.-Rate","m.cpa":"Kosten/Conv.","m.roas":"ROAS","m.aov":"Ø Bestellwert","m.vpc":"Wert / Klick","col.account":"Konto","col.period":"Zeitraum","col.currency":"Währung","col.date":"Datum","col.campaign":"Kampagne","col.ad_group":"Anzeigengruppe","col.n":"N","col.phrase":"Phrase","col.queries":"Suchanfragen","col.source":"Quelle","col.why":"Warum","title":"Quality Lens — Keyword-Qualitätsfaktor","tab.summary":"Zusammenfassung","tab.keywords":"Keywords","tab.changes":"Änderungen","col.keyword":"Keyword","col.keywords":"Keywords","col.match_type":"Übereinstimmungstyp","col.qs":"QS","col.prev_qs":"Vorheriger QS","col.delta":"Δ","col.exp_ctr":"Erwartete CTR","col.ad_relevance":"Anzeigenrelevanz","col.lp_exp":"Landingpage-Erlebnis","comp.ctr":"Erwartete CTR","comp.rel":"Anzeigenrelevanz","comp.lpe":"Landingpage-Erlebnis","sum.window":"Metrik-Zeitfenster","sum.scored":"Keywords mit QS","sum.weighted_qs":"Impressionsgewichteter QS","sum.simple_qs":"Einfacher Durchschnitts-QS","sum.low_cost":"Kosten bei QS ≤ 4","sum.low_share":"Kostenanteil bei QS ≤ 4","sum.changes":"QS-Änderungen ≥ {n} seit letztem Lauf","sum.by_qs":"Nach QS","sum.no_qs":"kein QS","sum.worst":"Teuerste Keywords mit niedriger Qualität (QS ≤ 4)","sum.weakest":"Schwächste Komponente","mail.subject":"Quality Lens · {cid} · QS {qs} · {n} Bewegungen","mail.drops":"Rückgänge","mail.gains":"Anstiege","alias.summary":["Summary","Özet"],"alias.keywords":["Anahtar kelimeler","AnahtarKelimeler"],"alias.changes":["Changes","Değişimler","Degisimler","Aenderungen"],"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"]};

/* ===================== 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 SprachenZeitplan: Daily