/* ===== admin-report.jsx — Aruanne: the outputs page and the Word export ======

   Modelled on RP's own report, GL26016_01_Tekst.docx, which is the only honest
   specification there is for this. What that document contains splits cleanly in
   two:

     FACTS, which the database holds and this generates - the title page, how
     many holes were drilled and when, which rigs, how many lab samples, the
     per-soil paragraphs (which holes, what thickness, what depth, what colour
     and density), and the water levels.

     JUDGEMENT, which it does not and must not invent - Reljeef, Geoloogiline
     ehitus, the bearing-capacity and founding recommendations, and Tabel 1's
     normative parameters. Those come out as headings with a marked placeholder,
     so the engineer fills them in and nothing reads as if the software decided
     it.

   Writing a plausible-sounding geotechnical conclusion nobody checked would be
   the worst thing this feature could do, so it does not.
--------------------------------------------------------------------------- */

/* "PA 21, 22, 24…30, 44, 61, 67…68" - RP's own way of naming a set of holes,
   read off the report: a run of three or more consecutive numbers collapses to
   a range, anything shorter is listed. Non-numeric ids are listed as they are.

   `conj` puts "ja" before the last item, which is how the sentences read
   ("Puuraukudes nr 38, 54, 60 ja 82"). */
function bhRangeText(ids, opts) {
  opts = opts || {};
  const nums = [];
  const other = [];
  (ids || []).forEach((id) => {
    const m = String(id).match(/^(?:PA|KP|LP)?[\s-]*(\d+)$/i);
    if (m) nums.push(+m[1]); else other.push(String(id));
  });
  nums.sort((a, b) => a - b);

  const parts = [];
  let i = 0;
  while (i < nums.length) {
    let j = i;
    while (j + 1 < nums.length && nums[j + 1] === nums[j] + 1) j++;
    const run = j - i + 1;
    if (run >= 3) { parts.push(nums[i] + "…" + nums[j]); i = j + 1; }
    else { for (let k = i; k <= j; k++) parts.push(String(nums[k])); i = j + 1; }
  }
  const all = parts.concat(other);
  if (!all.length) return "";
  if (!opts.conj || all.length === 1) return all.join(", ");
  return all.slice(0, -1).join(", ") + " ja " + all[all.length - 1];
}

/* Estonian decimals, and a range written the way the reports write it. Under a
   metre the reports use centimetres ("paksusega 2…13 cm"), at a metre and over
   they switch to metres ("paksusega 0,8…0,9 m") - so the unit follows the
   thickest occurrence, not each one. */
function num2(v, dp) {
  return (+v).toFixed(dp === undefined ? 2 : dp).replace(".", ",");
}
function rangeText(minV, maxV, dp) {
  const a = num2(minV, dp), b = num2(maxV, dp);
  return a === b ? a : a + "…" + b;
}
function thicknessPhrase(minM, maxM) {
  if (maxM < 1) return rangeText(minM * 100, maxM * 100, 0) + " cm";
  return rangeText(minM, maxM, 2) + " m";
}

/* The soil key a layer belongs to. One place, so the lab attribution below and
   the summary above cannot drift apart. */
/* THE IDENTITY OF A SOIL, for grouping. Folded to lower case, because the ISO
   symbol is typed by hand and GL26016 holds both OrsiSa and orsiSa for the same
   soil. Folding cannot merge two real soils: FSa and FSaU differ by a letter,
   siSa and saSi by the order of theirs, and neither survives being lower-cased
   into the other. Only spellings collapse. */
function soilKeyOf(g) {
  const est = (window.layerSoilName ? layerSoilName(g) : "").trim();
  const iso = (window.layerAgsName ? layerAgsName(g) : "").trim();
  return (iso || est).toLowerCase();
}

/* Lab rows gathered per soil, as RAW numbers.

   LabParse.labByLayer() would be the obvious tool but it returns values already
   formatted for the screen; ranges across a whole soil need the numbers. Rows
   are attributed by DEPTH: the midpoint of the sampled interval decides which
   layer, and that layer decides the soil - the same join the profile plot uses.

   `k` keeps the censored text as well: the lab writes "<0,1" for anything below
   its resolution, and rounding that to 0,1 would overstate what was measured. */
function labBySoil(pid, labRows) {
  const byUuid = {};
  (BOREHOLES[pid] || []).forEach((b) => { if (b._uuid) byUuid[b._uuid] = b; });
  const out = {};
  const push = (key, field, v) => {
    if (v === null || v === undefined || v === "") return;
    const n = +v;
    if (!isFinite(n)) return;
    (out[key][field] = out[key][field] || []).push(n);
  };

  (labRows || []).forEach((r) => {
    const b = byUuid[r.borehole_id];
    if (!b) return;
    const log = SAMPLE_LOG[b.key];
    if (!log || !log.geos) return;
    const t = parseFloat(r.depth_top);
    const bs = parseFloat(r.depth_base);
    if (!isFinite(t)) return;
    const mid = isFinite(bs) && bs > t ? (t + bs) / 2 : t;
    const g = log.geos.find((x) => mid >= +x.top - 0.001 && mid <= +x.base + 0.001);
    if (!g) return;
    const key = soilKeyOf(g);
    if (!key) return;
    const o = out[key] || (out[key] = {
      n: 0, gravel: [], sand: [], fines: [], cu: [], curves: [], k: [], kText: {},
      wl: [], ip: [], atter: [], org: [], wn: [], groups: {}, isos: {},
    });
    o.n += 1;
    push(key, "gravel", r.gravel_pct);
    push(key, "sand", r.sand_pct);
    push(key, "fines", r.fines_pct);
    push(key, "cu", r.cu);
    /* Cu WITH its Cc. gradingOf needs both - above Cu 15 the word is lünklik or
       väga ebaühtlane depending on Cc alone, so a letter from Cu only would be
       a coin toss. */
    if (r.cu != null && r.cc != null) o.curves.push({ cu: +r.cu, cc: +r.cc });
    push(key, "wl", r.w_liquid);
    push(key, "ip", r.plasticity_ix);
    /* wL WITH ITS Ip. The plasticity diagram places a POINT, so the two limits
       have to be the ones that came off the same sample; the flat lists above
       would let a low wL pair with a high Ip and land somewhere neither
       sample ever was. */
    if (r.w_liquid != null && r.plasticity_ix != null) {
      o.atter.push({ wl: +r.w_liquid, ip: +r.plasticity_ix });
    }
    push(key, "org", r.organic_pct);
    push(key, "wn", r.w_natural);
    if (r.permeability_text) o.kText[r.permeability_text] = 1;
    else push(key, "k", r.permeability);
    if (r.soil_group) o.groups[r.soil_group] = (o.groups[r.soil_group] || 0) + 1;
    const iso = r.iso2018_class || r.iso_class;
    if (iso) o.isos[iso] = (o.isos[iso] || 0) + 1;
  });
  return out;
}

/* The distinct soils in a project, each with where and how it occurs. Keyed by
   the report name - the ISO name when there is one, else the Estonian name -
   because that is the heading each paragraph carries. */
function soilSummary(pid) {
  const holes = window.sortBoreholes(BOREHOLES[pid] || []);
  const by = {};
  holes.forEach((b) => {
    const log = SAMPLE_LOG[b.key];
    if (!log || !log.geos) return;
    log.geos.forEach((g) => {
      const est = (window.layerSoilName ? layerSoilName(g) : "").trim();
      const iso = (window.layerAgsName ? layerAgsName(g) : "").trim();
      /* GROUPED BY THE ISO NAME, not the Estonian one. Typed Estonian names
         differ in case and word order for the same soil - "Väga ühtlane
         PeenLIIV" and "Väga ühtlane peenLIIV", "PeenLIIV orgaanikaga" and
         "Orgaanikaga peenLIIV" - and grouping on those produced 25 paragraphs
         for 21 soils. The ISO symbol IS the identity, and case matters in it:
         FSa and FSaU are different soils under Tabel 2. The heading then uses
         the commonest Estonian spelling. */
      const key = soilKeyOf(g);          /* the same key labBySoil attributes to */
      if (!key) return;
      const top = parseFloat(g.top), base = parseFloat(g.base);
      if (!isFinite(top) || !isFinite(base)) return;
      const t = base - top;
      const s = by[key] || (by[key] = {
        key: key, name: est || iso, iso: iso, names: {}, isoNames: {},
        holes: [], thinnest: Infinity, thickest: 0,
        top: Infinity, base: 0, colours: {}, density: {}, moisture: {},
        organic: [], surface: 0, count: 0,
      });
      if (est) s.names[est] = (s.names[est] || 0) + 1;
      if (iso) s.isoNames[iso] = (s.isoNames[iso] || 0) + 1;
      if (s.holes.indexOf(b.id) === -1) s.holes.push(b.id);
      s.thinnest = Math.min(s.thinnest, t);
      s.thickest = Math.max(s.thickest, t);
      s.top = Math.min(s.top, top);
      s.base = Math.max(s.base, base);
      s.count += 1;
      if (top === 0) s.surface += 1;
      /* TRIMMED AND FOLDED. Trimmed because a stored "pruun " printed as
         "pruun , niiske"; folded because GL26016 holds both "Pruun" and
         "pruun" for the same colour - twelve layers and two - and tallying
         them apart would both print "pruun ja pruun" as a range and push a
         real majority under dominant()'s threshold. The sentence lower-cases
         these anyway, so folding the key loses nothing. */
      const col = (g.colour || "").trim().toLowerCase();
      const den = (g.density || "").trim().toLowerCase();
      const moi = (g.moisture || "").trim().toLowerCase();
      if (col) s.colours[col] = (s.colours[col] || 0) + 1;
      if (den) s.density[den] = (s.density[den] || 0) + 1;
      if (moi) s.moisture[moi] = (s.moisture[moi] || 0) + 1;
      const op = parseFloat(g.organicPct);
      if (isFinite(op)) s.organic.push(op);
      if (!s.iso && iso) s.iso = iso;
    });
  });
  /* shallowest first, which is the order the reports describe them in:
     "kihi kaupa ülevalt alla" */
  Object.keys(by).forEach((k) => {
    const s = by[k];
    const spellings = Object.keys(s.names);
    if (spellings.length) {
      spellings.sort((a, b) => s.names[b] - s.names[a] || a.localeCompare(b, "et"));
      s.name = spellings[0];
    }
    /* and the commonest spelling of the SYMBOL, now that the grouping no longer
       cares which one was typed */
    const syms = Object.keys(s.isoNames);
    if (syms.length) {
      syms.sort((a, b) => s.isoNames[b] - s.isoNames[a] || a.localeCompare(b, "et"));
      s.iso = syms[0];
    }
  });
  return Object.keys(by).map((k) => by[k]).sort((a, b) => a.top - b.top || a.name.localeCompare(b.name, "et"));
}

/* THE FEW COMMONEST VALUES of a tally, for a property that genuinely varies.
   dominant() names one value and only when it holds six tenths of the layers,
   which is right for density and moisture - a soil is loose or it is not - and
   wrong for colour, where a bed grading from grey to brown said nothing at all
   because neither reached the threshold.

   Anything under a tenth of the layers is left out: it is one stray reading,
   not a description of the soil. */
function several(tally, max) {
  const keys = Object.keys(tally || {});
  if (!keys.length) return [];
  const total = keys.reduce((n, k) => n + tally[k], 0);
  keys.sort((a, b) => tally[b] - tally[a] || a.localeCompare(b, "et"));
  return keys.filter((k) => tally[k] / total >= 0.1).slice(0, max || 3);
}

/* the commonest value of a tally, when one clearly dominates */
function dominant(tally, share) {
  const keys = Object.keys(tally || {});
  if (!keys.length) return "";
  const total = keys.reduce((n, k) => n + tally[k], 0);
  keys.sort((a, b) => tally[b] - tally[a]);
  return tally[keys[0]] / total >= (share || 0.6) ? keys[0] : "";
}

function reportFacts(pid) {
  const proj = (PROJECTS || []).find((p) => p.id === pid) || {};
  const holes = window.sortBoreholes(BOREHOLES[pid] || []);
  const dates = holes.map((b) => b.date).filter(Boolean).sort();
  const rigs = {};
  const workers = {};
  let sampled = 0, sounded = 0, deepest = 0;
  const water = [];
  /* WHICH LAYER THE WATER WAS STRUCK IN, tallied over the holes. The strike
     depth falls inside one of that hole's layers, so this is a lookup. */
  const waterIn = {};
  /* and which holes carry no photograph at all. `anyPhoto` separates "none
     were taken" from "none were loaded" - see the guard at the caller. */
  const noPhotos = [];
  let anyPhoto = false;

  holes.forEach((b) => {
    if (b.rig) rigs[b.rig] = (rigs[b.rig] || 0) + 1;
    const log = SAMPLE_LOG[b.key];
    if (!log) return;
    (log.loca && log.loca.fieldWorkers ? log.loca.fieldWorkers : []).forEach((w) => {
      workers[w] = (workers[w] || 0) + 1;
    });
    const samp = (log.samp || []).filter((s) => (s.type || "").trim().toUpperCase() === "K");
    if (samp.length) sampled += samp.length;
    if (window.lpSoundings && lpSoundings(log.sounding).some((x) => x.readings && x.readings.length)) sounded += 1;
    const d = parseFloat(b.depth);
    if (isFinite(d)) deepest = Math.max(deepest, d);
    if ((log.photos || []).length) anyPhoto = true;
    else noPhotos.push(b.id);
    /* EITHER WATER FIELD. The form asked for two - „vesi ilmus puurimisel“ and
       „stabiliseerunud“ - until 29.09, when it became one because that is what
       gets written in practice; the one it keeps is `veetase`. This read only
       veeStrike, so every hole logged since then counted as dry and the water
       paragraph was never written: GL26050 reports „Vett esines välitöö käigus
       (26.04.2013)“, the template's own example, in a 2026 report. Across the
       database 23 holes carry a level and 37 a strike, so both have to be read
       (05.10). */
    const loc = log.loca || {};
    const vS = parseFloat(loc.veeStrike);
    const v = isFinite(vS) ? vS : parseFloat(loc.veetase);
    if (isFinite(v) && v >= 0) {
      water.push({ id: b.id, v: v });
      const g = (log.geos || []).find((x) => v >= +x.top - 0.001 && v <= +x.base + 0.001);
      const nm = g && window.layerSoilName ? layerSoilName(g).trim() : "";
      if (nm) waterIn[nm] = (waterIn[nm] || 0) + 1;
    }
  });

  return {
    proj: proj, holes: holes, dates: dates,
    rigs: Object.keys(rigs).sort((a, b) => rigs[b] - rigs[a]),
    workers: Object.keys(workers).sort((a, b) => workers[b] - workers[a]),
    sampled: sampled, sounded: sounded, deepest: deepest, water: water,
    waterIn: waterIn, noPhotos: noPhotos, anyPhoto: anyPhoto,
    soils: soilSummary(pid),
  };
}

const REPORT_TODO = "‹ täiendab geoloogiainsener ›";

/* THE ISO SENTENCE, PHRASED FOUR WAYS.

   Christopher gave four forms he uses, and rotating them is what stops eight
   paragraphs in a row ending identically. Chosen by the soil's INDEX, not at
   random: the same project must produce the same document twice.

   A form that mentions the soil group is only used when the lab assigned one. */
const ISO_FORMS = [
  (iso, grp) => grp
    ? "Kihi ISO järgne nimetus on " + iso + ", mis kuulub " + grp + " pinnasegruppi."
    : "Kihi ISO järgne nimetus on " + iso + ".",
  (iso) => "Kihi ISO klassifikatsiooni järgne nimetus on " + iso + ".",
  (iso, grp) => grp
    ? "ISO järgseks nimetuseks on " + iso + " ja see kuulub " + grp + " pinnasegruppi."
    : "ISO järgseks nimetuseks on " + iso + ".",
  (iso) => "Materjal on ISO nimetuse järgi " + iso + ".",
];

function isoSentence(isoText, group, idx) {
  if (!isoText) return "";
  const forms = group
    ? [ISO_FORMS[0], ISO_FORMS[2], ISO_FORMS[1]]
    : [ISO_FORMS[1], ISO_FORMS[3], ISO_FORMS[0]];
  return " " + forms[idx % forms.length](isoText, group);
}

/* "kruusa 15,6…66,1%" - a measured range, or a single value when one sample */
function pctRange(list, dp) {
  if (!list || !list.length) return "";
  const lo = Math.min.apply(null, list), hi = Math.max.apply(null, list);
  return rangeText(lo, hi, dp === undefined ? 1 : dp);
}

/* several ISO names, as the reports write them: "MSaP ja MSaU" */
function joinEt(list) {
  if (!list.length) return "";
  if (list.length === 1) return list[0];
  return list.slice(0, -1).join(", ") + " ja " + list[list.length - 1];
}

/* One soil's paragraph: the bold name, then the factual sentence. Shared by the
   Word export and the on-screen check list so they cannot drift. */
function soilSentence(s, lab, idx) {
  let txt = " – ";
  txt += s.surface === s.count ? "esineb pindmise kihina "
    : s.surface > 0 ? "esineb nii pindmise kui sügavama kihina "
      : "esineb ";
  /* "puuraugus nr 41", not "puuraukudes nr 41" - one hole takes the singular */
  txt += (s.holes.length === 1 ? "puuraugus nr " : "puuraukudes nr ")
    + bhRangeText(s.holes, { conj: true });
  txt += ", paksusega " + thicknessPhrase(s.thinnest, s.thickest);
  if (s.top > 0) txt += ", sügavusel " + rangeText(s.top, s.base) + " m";
  txt += ".";
  const den = dominant(s.density), moi = dominant(s.moisture);
  const bits = [];
  /* colour varies down a bed in a way density does not, so name up to three
     rather than insisting one of them dominates (Christopher, 21.09) */
  const cols = several(s.colours, 3);
  /* THE SENTENCE IS ALWAYS WRITTEN, with a gap where the field left one
     (Christopher, 08.10: „if no color is given write the sentence still, sth
     like pinnas on värvuselt (värv)“).

     Colour is recorded on 17% of layers in the database and density on 12%, so
     leaving the sentence out whenever they are missing is how a report ends up
     with no description of the soil at all — which is what happened on GL26050,
     where not one layer carries either. A visible „(värvus)“ is a question the
     engineer can answer in one pass; a sentence that was never written is one
     nobody notices is missing.

     Moisture stays optional: it is a genuine extra, not part of the sentence
     the engineer would otherwise write by hand. */
  bits.push(cols.length
    ? "värvuselt " + joinEt(cols.map((c) => c.toLowerCase()))
    : "värvuselt (värvus)");
  /* DENSITY ONLY WHERE IT EXISTS - no placeholder (Christopher, 09.10). The
     field app stopped asking for tihedus and konsistents that day: both are a
     judgement a hand auger cannot support, and what the crew do know goes into
     Kirjeldus in their own words. A „(tihedus puurimisel)“ gap would therefore
     print under every soil in every future report and never be fillable. The 46
     layers that already carry a density still print it. */
  if (den) bits.push(den.toLowerCase());
  if (moi) bits.push(moi.toLowerCase());
  txt += " Pinnas on valdavalt " + bits.join(", ") + ".";
  /* organics come from the lab block below when the lab measured them; this
     is the fallback for a layer carrying a stored figure with no lab row */
  if (s.organic.length && !(lab && lab.org && lab.org.length)) {
    const lo = Math.min.apply(null, s.organic), hi = Math.max.apply(null, s.organic);
    txt += " Laboris määratud orgaanika sisaldus " + rangeText(lo, hi, 1) + "%.";
  }
  /* ---- what the lab measured. Nothing here is asserted without a number. ---- */
  if (lab && lab.n) {
    const parts = [];
    if (lab.gravel.length) parts.push("kruusa " + pctRange(lab.gravel) + "%");
    if (lab.sand.length) parts.push("liiva " + pctRange(lab.sand) + "%");
    if (lab.fines.length) parts.push("peenosiseid " + pctRange(lab.fines) + "%");
    if (parts.length) {
      txt += " Laboris katsetatud " + lab.n + (lab.n === 1 ? " proov sisaldas " : " proovi sisaldasid ")
        + joinEt(parts) + ".";
    }
    if (lab.cu.length) {
      /* His wording: "Materjal on ühtlasest väga ebaühtlase koostisega
         (Cu=3,5…36,2)". The describing word comes from EVS-EN ISO 14688-2
         Tabel 2, which iso-naming.js already implements - so the range of Cu
         values gives a range of words. */
      const words = [];
      (lab.curves || []).forEach((c) => {
        const g = window.IsoNaming && IsoNaming.gradingOf
          ? IsoNaming.gradingOf(c.cu, c.cc) : null;
        if (g && words.indexOf(g.word) === -1) words.push(g.word);
      });
      /* "Materjal on ühtlasest väga ebaühtlase koostisega" - the first word in
         the elative, the last in the genitive. Five known words from Tabel 2,
         so the forms are written out: guessing Estonian cases by chopping
         suffixes gets "ebaühtlane koostisega", which is wrong. */
      const CASES = {
        "väga ühtlane": ["väga ühtlasest", "väga ühtlase"],
        "ühtlane": ["ühtlasest", "ühtlase"],
        "ebaühtlane": ["ebaühtlasest", "ebaühtlase"],
        "väga ebaühtlane": ["väga ebaühtlasest", "väga ebaühtlase"],
        "lünklik": ["lünklikust", "lünkliku"],
      };
      const gen = (w) => (CASES[w] ? CASES[w][1] : w);
      const ela = (w) => (CASES[w] ? CASES[w][0] : w);
      const desc = words.length
        ? (words.length === 1 ? gen(words[0])
          : ela(words[0]) + " " + gen(words[words.length - 1]))
        : "";
      txt += desc
        ? " Materjal on " + desc + " koostisega (Cu=" + pctRange(lab.cu) + ")."
        : " Materjali lõimisekõvera ühtlikkusteguriks määrati Cu=" + pctRange(lab.cu) + ".";
    }
    if (lab.wl.length && lab.ip.length) {
      txt += " Laboris katsetatud proovide voolavuspiir oli WL=" + pctRange(lab.wl)
        + "% ja plastsusarv Ip=" + pctRange(lab.ip) + ".";
      /* WHERE THOSE POINTS LAND on Joonis 1 of EVS-EN ISO 14688-2. The band
         comes from the liquid limit and the soil from which side of the A-line
         the point falls; plasticityOf does both. Samples it declines to place -
         a missing limit, or a point above the U-line, which is a result rather
         than a soil - contribute nothing, and if none can be placed this whole
         sentence is skipped and the measured numbers stand alone. */
      const words = [], soils = [], syms = [];
      (lab.atter || []).forEach((a) => {
        const c = window.IsoNaming && IsoNaming.plasticityOf
          ? IsoNaming.plasticityOf(a.wl, a.ip) : null;
        if (!c) return;
        if (words.indexOf(c.word) === -1) words.push(c.word);
        if (soils.indexOf(c.soil) === -1) soils.push(c.soil);
        if (syms.indexOf(c.sym) === -1) syms.push(c.sym);
      });
      if (syms.length) {
        /* the plasticity words in the diagram's own order, so a span reads
           from the less plastic end to the more */
        const order = (IsoNaming.PLASTICITY || []).map((p) => p.word);
        words.sort((a, b) => order.indexOf(a) - order.indexOf(b));
        const wPhrase = words.length === 1
          ? words[0] : words[0] + " kuni " + words[words.length - 1];
        txt += " Plastsusdiagrammi järgi on pinnas " + wPhrase + " "
          + joinEt(soils) + " (" + syms.join(", ") + ").";
      }
    }
    if (lab.org.length) {
      txt += " Orgaanika sisaldus oli " + pctRange(lab.org) + "%.";
    }
    if (lab.wn.length) {
      txt += " Pinnase looduslikeks veesisaldusteks määrati " + pctRange(lab.wn) + "%.";
    }
    /* permeability, censored values kept verbatim - "<0,1" is what the lab
       wrote and rounding it to 0,1 would overstate the measurement */
    const kTexts = Object.keys(lab.kText);
    if (lab.k.length || kTexts.length) {
      const num = lab.k.length ? pctRange(lab.k) : "";
      const shown = kTexts.length && num ? kTexts.join(", ") + " ja " + num
        : (kTexts.length ? kTexts.join(", ") : num);
      txt += " Filtratsioonimooduliks määrati laboris " + shown + " m/ööp.";
      /* only claimed where it was measured: every value at or under 0,1 */
      const allLow = !lab.k.some((v) => v > 0.1)
        && kTexts.every((t) => /^</.test(String(t).trim()));
      if (allLow && (lab.k.length || kTexts.length)) {
        txt += " Kiht liigitub mitte dreenivaks.";
      }
    }
  }

  /* ---- the ISO name last, phrased differently from its neighbours ----
     The name is the one the ENGINEER confirmed in Analüüs, not the lab's. The
     lab's iso_class carries "-" on rows where nothing was classified and
     fragments like "Si" on others, and gathering those produced
     "ISO järgseks nimetuseks on -, Si ja orsiSa". Soils are grouped BY that
     confirmed symbol anyway, so there is exactly one per paragraph. */
  const group = lab ? dominant(lab.groups, 0.5) : "";
  if (s.iso && s.iso !== s.name) {
    txt += isoSentence(s.iso, group, idx || 0);
  }
  return txt;
}

/* The bold name only. The ISO symbol used to ride along in brackets, which is
   not how the reports are written - it goes at the END of the description as a
   sentence of its own (Christopher, 03.09: "dont include iso name in bracets but
   add to the end that kihi iso järgseks nimetuseks on iso name"). */
function soilHeading(s) {
  return s.name;
}

/* The title page splits `location` on the first comma: "Harju maakond, Anija
   vald" fills both lines. One field, because that is what the project form has,
   and two columns that could disagree with it would be worse. */
function splitLocation(loc) {
  const i = String(loc || "").indexOf(",");
  if (i === -1) return { county: String(loc || "").trim(), muni: "" };
  return { county: loc.slice(0, i).trim(), muni: loc.slice(i + 1).trim() };
}

/* ---------------------------------------------------------------------------
   The Word export. Patches RP's own template; see the file header.
--------------------------------------------------------------------------- */
/* WHAT THE EXPORT'S OWN SENTENCES LOOK LIKE. Times New Roman 12, black
   (Christopher, 18.09). Not inherited: the template's example body text is red,
   which is how RP mark a passage as needing replacement, and the prototype
   paragraph the soil block is built from carries no run properties at all.

   sz is in HALF-points, so 12 pt is 24. */
const BODY_RPR =
  '<w:rPr><w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman"'
  + ' w:cs="Times New Roman"/><w:color w:val="000000"/>'
  + '<w:sz w:val="24"/><w:szCs w:val="24"/></w:rPr>';
const BODY = { rpr: BODY_RPR };

function exportReportDocx(pid) {
  const T = window.DocxTemplate;
  if (!T) return Promise.reject(new Error("docx-template.js ei ole laetud"));
  const f = reportFacts(pid);
  const p = f.proj;
  if (!f.holes.length) return Promise.reject(new Error("Projektil ei ole puurauke"));
  if (!f.soils.length) return Promise.reject(new Error("Projektil ei ole kihiandmeid"));

  const code = (p.code || "") + (p.part ? "-" + p.part : "");
  const loc = splitLocation(p.location);

  /* The lab is fetched here rather than in reportFacts: loadAll deliberately
     skips lab_results, and the descriptions are the only thing that needs it. */
  return Promise.all([RPStore.reportTemplate(), RPStore.loadLabForProject(pid)])
    .then(([buf, labData]) => {
    const labSoil = labBySoil(pid, (labData && labData.results) || []);
    const entries = T.readZip(buf);
    const need = ["word/document.xml", "word/header1.xml", "word/header2.xml", "word/footer1.xml"];
    const byName = {};
    entries.forEach((e) => { byName[e.name] = e; });
    if (!byName["word/document.xml"]) throw new Error("Mall ei ole korrektne .docx");

    return Promise.all(need.filter((n) => byName[n]).map((n) =>
      T.inflate(byName[n]).then((u8) => ({ name: n, xml: new TextDecoder("utf-8").decode(u8) }))
    )).then((parts) => {
      const xmlOf = {};
      parts.forEach((x) => { xmlOf[x.name] = x.xml; });
      let doc = xmlOf["word/document.xml"];
      const missed = [];

      /* --- the title page. Twice each: mc:Choice and mc:Fallback. --- */
      const title = [
        ["GLxxxx", "Töö nr " + code],
        ["XXX maakond", loc.county],
        ["XXX vald", loc.muni],
        ["Tellija nimi", p.client || ""],
        ["Vastutav töötäitja: XXXX", "Vastutav töötäitja: " + (p.engineer || p.lead || "")],
        ["Eelprojekt / PÕhiprojekt", p.designStage || ""],
        ["TÖÖ / PROJEKTI NIMETUS", p.name || ""],
      ];
      title.forEach(([needle, value]) => {
        if (!value) return;                 /* nothing known - leave the placeholder */
        const r = T.replaceWhere(doc, needle, [{ t: value }], { leaf: true });
        doc = r.xml;
        if (!r.count) missed.push(needle);
      });

      /* --- what the field work was, from the register --- */
      const dateSpan = f.dates.length
        ? (f.dates[0] === f.dates[f.dates.length - 1]
          ? etDate(f.dates[0])
          : etDate(f.dates[0]) + "–" + etDate(f.dates[f.dates.length - 1]))
        : "";
      const kinds = {};
      f.holes.forEach((b) => { kinds[b.type || "PA"] = (kinds[b.type || "PA"] || 0) + 1; });
      let fw = "Geotehnilise uuringu välitöö toimus " + (dateSpan || REPORT_TODO)
        + ". Rajati kokku " + f.holes.length + " uuringupunkti ("
        + Object.keys(kinds).sort().map((k) => kinds[k] + " " + k).join(", ")
        + "), suurim uuritud sügavus " + num2(f.deepest) + " m.";
      if (f.rigs.length) fw += " Uuringu teostamiseks kasutati puuragregaate " + f.rigs.join(", ") + ".";
      const rFw = T.replaceWhere(doc, "Geotehnilise uuringu välitöö toimus", [{ t: fw }],
        { depth: 0, rpr: BODY_RPR });
      doc = rFw.xml;
      if (!rFw.count) missed.push("välitöö kirjeldus");

      /* WHO DID THE WORK. The template offers a slash-separated menu of names;
         the app knows which of them were actually on this job - the crew from
         each borehole's field_workers, the author from the project. Roles are
         not stored per person, so the sentence names them without inventing a
         title for anyone. */
      const crew = f.workers.slice();
      const author = p.engineer || p.lead || "";
      if (crew.length || author) {
        let who = "";
        if (crew.length) {
          who += "Välitöö tegid objektil "
            + (crew.length === 1 ? crew[0]
              : crew.slice(0, -1).join(", ") + " ja " + crew[crew.length - 1]);
        }
        if (author) who += (who ? ", aruande koostas " : "Aruande koostas ") + author;
        who += ".";
        const rCrew = T.replaceWhere(doc, "Välitöö tegid objektil", [{ t: who }],
          { depth: 0, rpr: BODY_RPR });
        doc = rCrew.xml;
        if (!rCrew.count) missed.push("välitöö tegijad");
      }

      /* WHAT THE LAB ACTUALLY TESTED. Not the bags the crew filled - not every
         sample is sent on - so the count is the lab's own rows, and each
         determination is counted separately (Christopher, 21.09). The lab's
         name and the standards stay for the engineer: neither is recorded. */
      const labRows = (labData && labData.results) || [];
      if (labRows.length || f.sampled) {
        const n = (fn) => labRows.filter(fn).length;
        const det = [];
        const add = (count, what) => { if (count) det.push(what + " " + count + " proovil"); };
        add(n((r) => r.gravel_pct != null || r.sand_pct != null || r.fines_pct != null), "lõimis");
        add(n((r) => r.w_natural != null), "looduslik niiskussisaldus");
        add(n((r) => r.w_liquid != null && r.plasticity_ix != null), "plastsuspiirid");
        add(n((r) => r.organic_pct != null), "orgaanika sisaldus");
        add(n((r) => r.permeability != null || r.permeability_text), "filtratsioonimoodul");
        let lab = labRows.length
          ? "Laborisse saadeti " + labRows.length
            + (labRows.length === 1 ? " pinnaseproov." : " pinnaseproovi.")
          : "Puuraukudest võeti " + f.sampled + " pinnaseproovi.";
        if (det.length) lab += " Määrati " + joinEt(det) + ".";
        lab += " " + REPORT_TODO + " Labor, katsemetoodika ja standardid.";
        const rLab = T.replaceWhere(doc, "pinnaseproovi", [{ t: lab }],
          { depth: 0, rpr: BODY_RPR });
        doc = rLab.xml;
      }

      /* WHICH HOLES HAVE NO PHOTOGRAPH. RP name the missing shots by kind -
         "nii lati kui ka tausta pilt" - and we cannot: a photo is stored as
         "PA22_001.jpg", a sequence number with no kind in it. What is recorded
         is whether a hole has any photo at all.

         ONLY WHEN SOMETHING WAS PHOTOGRAPHED. If the photo cache has not been
         loaded every hole looks bare, and the report would name all of them -
         which is the difference between "none were taken" and "none were
         loaded", and only one of those is worth printing. */
      if (f.anyPhoto && f.noPhotos.length) {
        const rPh = T.replaceWhere(doc, "tehti väliuuringust fotod", [{
          t: "Vastavalt juhises toodule tehti väliuuringust fotod, mis on GPS info"
            + " põhiselt kantud uuringupunktide asukoha plaanile ning on digitaalselt"
            + " esitatud aruandes. "
            + (f.noPhotos.length === 1 ? "Uuringupunktist nr " : "Uuringupunktidest nr ")
            + bhRangeText(f.noPhotos, { conj: true })
            + (f.noPhotos.length === 1 ? " fotot ei ole." : " fotosid ei ole."),
        }], { depth: 0, rpr: BODY_RPR });
        doc = rPh.xml;
      }

      /* --- ONLY THE RIGS THAT WERE USED ---
         The template carries a photo and a caption for each of RP's three rigs.
         Which ones were on this job is recorded per borehole, so the others go -
         caption and photo together, since the caption's paragraph follows the
         one holding the picture (Christopher, 03.09). */
      const RIG_CAPTIONS = [
        ["GM 65 GTT", ["GM65", "GM 65"]],
        ["GM 100 GT", ["GM100", "GM 100"]],
        ["Geotech", ["Geotech"]],
      ];
      const usedRig = (aliases) => f.rigs.some((r) => {
        const flat = String(r).replace(/\s+/g, "").toUpperCase();
        return aliases.some((a) => flat.indexOf(String(a).replace(/\s+/g, "").toUpperCase()) !== -1);
      });
      /* NO RIG RECORDED MEANS WE DO NOT KNOW WHICH, not that none was used, so
         all three stay and the engineer strikes the two that were not there
         (Christopher, 08.10: „if drill rig is not specified leave all 3 in“).
         Deleting all three on a project where nobody filled the field left the
         report with no rig at all. */
      RIG_CAPTIONS.forEach(([caption, aliases]) => {
        if (!f.rigs.length || usedRig(aliases)) return;
        doc = T.removeWhere(doc, "Puuragregaat " + caption, { alsoBefore: true }).xml;
      });

      /* --- TESTS RP GEOLOG DOES NOT RECORD: LEFT IN, IN RED ---
         These passages used to be deleted - CPTU, tiivikkatse, MWD always, and
         the probing paragraphs whenever the project had no soundings - on the
         reasoning that a paragraph about a test nobody ran is worse than a gap.

         That reasoning was wrong in one step: the app not recording a test is
         not the same as the test not happening. Nothing in RP Geolog stores a
         CPTU or a tiivikkatse at all, so deleting on that basis deletes on no
         evidence. „for 26050 you deleted too much, had to bring text back in
         from template“ (Christopher, 08.10).

         Every one of these paragraphs is already FF0000 in the template, which
         is how RP mark a passage as needing attention - so leaving them alone
         leaves them red, and the engineer removes what did not happen. Nothing
         replaces the knowledge of somebody who was on site.

         The rig photos above are the one thing still deleted, and only when the
         boreholes positively name which rigs were used. */

      /* --- EVERY layer from the geology profiles, in the text ---
         "every geological layer that has been input in the geology profiles
         needs to be presented in the text as well" (Christopher, 02.09). The
         template's example layers are replaced, not appended to. */
      /* A BLANK LINE AFTER EVERY LAYER, which is how the template reads and how
         RP's delivered reports read: its example paragraphs alternate with
         empty ones. replaceBlock writes exactly the paragraphs it is given, so
         without this the layers ran together. The last one doubles as the gap
         before "Hüdrogeoloogilised tingimused", which starts a new chapter
         (Christopher, 21.09).

         An empty spec is a paragraph with no runs: it keeps the prototype's
         pPr and holds nothing, which is what a blank line is in Word. */
      const blocks = [];
      f.soils.forEach((s, i) => {
        blocks.push([
          /* the material name, bold, exactly as the template's own examples
             read: "Kruusliiv – pinnast esineb peamiselt puuraukudes nr 14 ja 16" */
          { t: soilHeading(s), b: true },
          /* i varies the ISO sentence's wording between neighbouring paragraphs */
          { t: soilSentence(s, labSoil[s.key], i), b: false },
        ]);
        blocks.push([]);
      });
      const rB = T.replaceBlock(doc, "Järgnevalt on iseloomustatud",
        "Hüdrogeoloogilised", blocks, BODY);
      doc = rB.xml;

      /* --- water --- */
      if (f.water.length) {
        const vs = f.water.map((w) => w.v);
        const lo = Math.min.apply(null, vs), hi = Math.max.apply(null, vs);
        /* THE DATE BELONGS IN IT. A water level is only true of the day it was
           read, and the template's own example says so - "Vett esines välitöö
           käigus (26.04.2013)". The span comes from the same field dates the
           välitöö sentence uses. The dry holes are counted too: "5 puuraugus"
           says little without the 17 it is out of. */
        const when = dateSpan ? " (" + dateSpan + ")" : "";
        const outOf = f.holes.length > f.water.length
          ? " " + f.water.length + " puuraugus " + f.holes.length + "-st"
          : " kõikides puuraukudes";
        /* AND WHICH LAYERS CARRY IT. Each strike depth falls inside one of
           that hole's layers, so the water-bearing soils are counted rather
           than judged - "Vett kandvaks kihtideks on turvas või muu orgaanika",
           as RP write it. */
        const bearing = several(f.waterIn, 3);
        const rW = T.replaceWhere(doc, "Vett esines", [{
          t: "Vett esines välitöö käigus" + when + outOf + ", kus see asus"
            + " maapinnast " + rangeText(lo, hi) + " meetri sügavusel."
            + (bearing.length
              ? " Vett kandvateks kihtideks on " + joinEt(bearing.map((x) => x.toLowerCase())) + "."
              : ""),
        }], { depth: 0, rpr: BODY_RPR });
        doc = rW.xml;
      }

      /* --- the running header names the project on EVERY page ---
         The template's still says "Kalakasvatus;Saarepeedi vald", and so does
         RP's delivered GL26016 report - nobody updated it by hand. */
      const edits = { "word/document.xml": doc };
      /* The running header is TWO paragraphs - the template's read
         "Kalakasvatus;" then "Saarepeedi vald", i.e. project then place. Writing
         the project name into both printed it twice (Christopher, 03.09). First
         paragraph the project, second the location. */
      ["word/header1.xml", "word/header2.xml"].forEach((n) => {
        if (!xmlOf[n] || !p.name) return;
        let h = xmlOf[n];
        const lines = [p.name + ";", loc.muni || loc.county || ""];
        const qs = T.scanParas(h)
          .filter((q) => q.leaf && T.textOf(h.slice(q.a, q.b)).trim());
        for (let i = qs.length - 1; i >= 0; i--) {
          if (i >= lines.length) continue;      /* leave any extra lines alone */
          h = h.slice(0, qs[i].a)
            + T.setRuns(h.slice(qs[i].a, qs[i].b), [{ t: lines[i] }])
            + h.slice(qs[i].b);
        }
        edits[n] = h;
      });
      /* the footer's file name is a FILENAME field; set the cached text too so
         it reads right before Word's first refresh */
      if (xmlOf["word/footer1.xml"] && code) {
        edits["word/footer1.xml"] = xmlOf["word/footer1.xml"]
          .split("GLxxxx 01 Tekst.docx").join(code + "_01_Tekst.docx");
      }

      const blob = T.build(entries, edits);
      /* HE CHOOSES THE FOLDER, and the report remembers its own - a profile and
         a report do not live in the same place (Christopher, 18.09). Closing
         the dialog returns false, which run() reads as "nothing to report" and
         stays quiet rather than announcing a file that was never written. */
      const docName = code.replace(/[^\wÀ-ž-]+/g, "_") + "_01_Tekst.docx";
      return RPStore.saveAs(docName, blob, null, "report").then((saved) => (saved
        ? {
          holes: f.holes.length, soils: f.soils.length,
          replaced: rB.replaced, missed: missed,
        }
        : false));
    });
  });
}

/* dd.mm.yyyy, as the reports write dates */
function etDate(iso) {
  if (!iso) return "";
  const p = String(iso).slice(0, 10).split("-");
  return p.length === 3 ? p[2] + "." + p[1] + "." + p[0] : String(iso);
}

Object.assign(window, {
  bhRangeText, soilSummary, reportFacts, exportReportDocx, etDate, REPORT_TODO,
});

/* ---------------------------------------------------------------------------
   The Aruanne page. Everything the report needs already exists somewhere in the
   app; what was missing is a place that gathers it, so the four Väljundid
   buttons in Protsess have somewhere real to go.

   Deliberately shows WHAT WILL BE WRITTEN before writing it. A report is signed
   by an engineer, so it matters that they can see the facts the generator found
   - how many holes, which soils, what water - and notice a wrong one before it
   reaches a Word file rather than after.
--------------------------------------------------------------------------- */
function ReportScreen({ canEdit, onToast, projectId }) {
  /* seeded from the site-wide selection, so arriving here keeps the project you
     were already working in rather than resetting to whatever is first */
  const first = (PROJECTS || [])[0];
  const [pid, setPid] = React.useState(
    projectId && projectId !== "all" ? projectId : (first ? first.id : ""));
  React.useEffect(() => {
    if (projectId && projectId !== "all") setPid(projectId);
  }, [projectId]);
  const [perSheet, setPerSheet] = React.useState(10);

  const facts = React.useMemo(() => (pid ? reportFacts(pid) : null), [pid]);
  /* reportFacts already defaults this to {} - taking it from there means one
     source, and no crash while PROJECTS is still loading. A bare `proj.code` on
     an undefined proj is the same class of error that blanked this page.
     Declared AFTER facts: a const is not hoisted, so reading it above would be
     a temporal dead zone. */
  const proj = (facts && facts.proj) || {};
  const holes = facts ? facts.holes : [];
  const withLogs = holes.filter((b) => SAMPLE_LOG[b.key] && (SAMPLE_LOG[b.key].geos || []).length);
  const blockers = pid && window.RPStore && RPStore.agsBlockers ? RPStore.agsBlockers(pid) : [];
  /* coordinates, counted separately from layer data: a hole with no log still
     has a position, and the point export is about positions */
  const withCoords = holes.filter((b) => b.lat != null && b.lon != null).length;
  /* samples, counted the same way and for the same reason: the lab sheet is
     about what was taken, so a hole whose layers are not yet named still
     contributes its samples */
  const nSamples = holes.reduce((n, b) => {
    const l = SAMPLE_LOG[b.key];
    return n + ((l && l.samp) ? l.samp.length : 0);
  }, 0);
  const withGps = holes.filter((b) => {
    const l = SAMPLE_LOG[b.key];
    return l && l.loca && l.loca.gpsLat != null;
  }).length;
  const code = (proj.code || "") + (proj.part ? "-" + proj.part : "");

  const [busy, setBusy] = React.useState("");
  const [tmpl, setTmpl] = React.useState(undefined);   /* undefined = checking */
  React.useEffect(() => {
    let live = true;
    RPStore.reportTemplateInfo().then((t) => { if (live) setTmpl(t); });
    return () => { live = false; };
  }, []);

  /* AWAITED. The Word export fetches the template from Storage, so it is a
     Promise now; reporting success before it resolves would put "laaditud" over
     a failure - the same blindness that hid the Asfalt bug for three rounds. */
  const run = (label, fn) => {
    setBusy(label);
    Promise.resolve()
      .then(fn)
      .then((r) => {
        /* false means the job decided there was nothing to report - the save
           dialog was closed. Not an error, so no toast either way. */
        if (r === false) return;
        let msg = label + " laaditud";
        if (r && r.holes) msg += " · " + r.holes + " puurauku";
        if (r && r.soils) msg += " · " + r.soils + " pinnast";
        onToast && onToast(msg);
        if (r && r.missed && r.missed.length) {
          onToast && onToast("Mallis jäid asendamata: " + r.missed.join(", ")
            + " — kontrolli malli", true);
        }
      })
      .catch((e) => onToast && onToast(e && e.message ? e.message : String(e), true))
      .then(() => setBusy(""));
  };

  const pickTemplate = () => {
    const inp = document.createElement("input");
    inp.type = "file";
    inp.accept = ".docx";
    inp.onchange = () => {
      const file = inp.files && inp.files[0];
      if (!file) return;
      setBusy("Mall");
      RPStore.uploadReportTemplate(file)
        .then(() => RPStore.reportTemplateInfo())
        .then((t) => { setTmpl(t); onToast && onToast("Aruande mall uuendatud"); })
        .catch((e) => onToast && onToast(e && e.message ? e.message : String(e), true))
        .then(() => setBusy(""));
    };
    inp.click();
  };

  return (
    <div className="page-inner fade-in">
      <div className="page-head">
        <div>
          <h1 className="page-title">Väljundid</h1>
          <p className="page-sub">Väljundid ühest kohast · faktid andmebaasist, järeldused insenerilt</p>
        </div>
      </div>

      <div className="fence-bar">
        <ProjectPicker value={pid} onChange={setPid} />
        <span className="fence-bar-gap"></span>
        {holes.length > 0 && (
          <span className="badge">{withLogs.length}/{holes.length} puurauku kihiandmetega</span>
        )}
      </div>

      {!pid && <Panel title="Vali projekt"><p className="muted">Aruande koostamiseks vali projekt.</p></Panel>}

      {pid && (
        <div className="grid-2-1">
          <Panel title="Mida aruandesse kirjutatakse" sub="Kontrolli enne eksporti">
            {!facts || !holes.length ? (
              <p className="muted">Projektil ei ole veel puurauke.</p>
            ) : (
              <div className="rep-facts">
                <div className="rep-row"><span>Töö nr</span><b>{(proj.code || "") + (proj.part ? "-" + proj.part : "")}</b></div>
                <div className="rep-row"><span>Staadium</span>
                  <b>{proj.designStage || <span className="muted">määramata</span>}</b></div>
                <div className="rep-row"><span>Asukoht</span>
                  <b>{proj.location || <span className="muted">määramata</span>}</b></div>
                <div className="rep-row"><span>Uuringupunktid</span><b>{holes.length}</b></div>
                <div className="rep-row"><span>Välitöö</span><b>{facts.dates.length
                  ? etDate(facts.dates[0]) + (facts.dates.length > 1 ? "–" + etDate(facts.dates[facts.dates.length - 1]) : "")
                  : "—"}</b></div>
                <div className="rep-row"><span>Suurim sügavus</span><b>{num2(facts.deepest)} m</b></div>
                <div className="rep-row"><span>Puuragregaadid</span><b>{facts.rigs.join(", ") || "—"}</b></div>
                <div className="rep-row"><span>Laboriproovid</span><b>{facts.sampled || "—"}</b></div>
                <div className="rep-row"><span>Penetratsioonikatsed</span><b>{facts.sounded || "—"}</b></div>
                <div className="rep-row"><span>Vett esines</span><b>{facts.water.length
                  ? facts.water.length + " augus" : "ei esinenud"}</b></div>
                <div className="rep-sep">Eraldatud pinnased ({facts.soils.length})</div>
                {facts.soils.map((s) => (
                  <div className="rep-soil" key={s.name}>
                    <b>{s.name}</b>
                    <span className="mono">
                      {" nr " + bhRangeText(s.holes) + " · " + thicknessPhrase(s.thinnest, s.thickest)}
                    </span>
                  </div>
                ))}
                {!facts.soils.length && <p className="muted">Kihiandmeid ei ole.</p>}
              </div>
            )}
          </Panel>

          <Panel title="Väljundid" sub="Aruanne ja lisad">
            <div className="outputs">
              <button type="button" className="output"
                disabled={!withLogs.length || !!busy || tmpl === null}
                onClick={() => run("Aruanne", () => exportReportDocx(pid))}>
                <span className="output-ico"><Icon name="table" size={17} /></span>
                <div>
                  <div className="output-t">Aruanne (Word)</div>
                  <div className="output-s mono">
                    {tmpl === undefined ? "malli kontrollin…"
                      : tmpl === null ? "mall puudub — laadi ülesse"
                        : "RP mall · " + (facts ? facts.soils.length : 0) + " kihikirjeldust"}
                  </div>
                </div>
                <Icon name="download" size={15} />
              </button>

              <button type="button" className="output" disabled={!withLogs.length || !!busy}
                onClick={() => run("Läbilõiked", async () => {
                  const saved = await exportAnalysisXls(pid, withLogs.map((b) => b.key), { perSheet: perSheet });
                  return saved ? { holes: withLogs.length } : false;
                })}>
                <span className="output-ico"><Icon name="activity" size={17} /></span>
                <div>
                  <div className="output-t">Geoloogilised profiilid (Excel)</div>
                  <div className="output-s mono">{perSheet} puurauku lehel</div>
                </div>
                <Icon name="download" size={15} />
              </button>

              {/* PROOVID (Triin, 23.09): "tegin justkui valjas sisestamine
                  koos proovide infoga ja nuud peaksin siis selle info saama
                  excelisse". The only way out was AGS4, which is a text file,
                  not something you work in. This is the office's own
                  GLxxxxx Labor_toodeldud.xlsx with her samples already in it. */}
              <button type="button" className="output" disabled={!nSamples || !!busy}
                onClick={() => run("Proovid", () => exportLabSheet(pid))}>
                <span className="output-ico"><Icon name="flask" size={17} /></span>
                <div>
                  <div className="output-t">Proovid (Excel)</div>
                  <div className="output-s mono">
                    {nSamples ? nSamples + " proovi · laboritabeli kujul" : "proove pole"}
                  </div>
                </div>
                <Icon name="download" size={15} />
              </button>

              {/* PUNKTIDE ASUKOHAD (Leivi, 03.09). Planned against as-drilled,
                  so the drift can be pulled into CAD instead of being compared
                  by hand against a handheld GPS or a Leica file. Enabled on
                  coordinates, not on layer data - a hole with no log still has
                  a position worth exporting. */}
              <button type="button" className="output" disabled={!withCoords || !!busy}
                onClick={() => run("Punktid (DXF)", () => RPStore.exportPointsDXF(pid, null, code))}>
                <span className="output-ico"><Icon name="map" size={17} /></span>
                <div>
                  <div className="output-t">Punktide asukohad (DXF)</div>
                  <div className="output-s mono">
                    {withCoords
                      ? withCoords + " punkti · " + withGps + " GPS-iga"
                      : "koordinaadid puuduvad"}
                  </div>
                </div>
                <Icon name="download" size={15} />
              </button>

              <button type="button" className="output" disabled={!withCoords || !!busy}
                onClick={() => run("Punktid (CSV)", () => RPStore.exportPointsCSV(pid, null, code))}>
                <span className="output-ico"><Icon name="table" size={17} /></span>
                <div>
                  <div className="output-t">Punktide asukohad (CSV)</div>
                  <div className="output-s mono">L-EST97 · kavandatud ja teostatud</div>
                </div>
                <Icon name="download" size={15} />
              </button>

              <button type="button" className="output" disabled={!withLogs.length || !!busy}
                onClick={() => run("AGS4", () => RPStore.exportAGS4(pid)
                  .then((saved) => (saved ? {} : false)))}>
                <span className="output-ico"><Icon name="cloud" size={17} /></span>
                <div>
                  <div className="output-t">AGS4 andmepakett</div>
                  <div className="output-s mono">
                    {blockers.length ? blockers.length + " kihil puudub ISO nimi" : "kõik kihid nimetatud"}
                  </div>
                </div>
                <Icon name="download" size={15} />
              </button>
            </div>

            {/* The template is the format. It lives in a private bucket so it
                reaches signed-in staff only, and so it can be replaced here
                without a deploy when RP change the report. */}
            <div className="rep-tmpl">
              <div>
                <div className="output-t">Aruande mall</div>
                <div className="output-s mono">
                  {tmpl === undefined ? "kontrollin…"
                    : tmpl === null ? "puudub — aruannet ei saa koostada"
                      : RPStore.REPORT_TEMPLATE_PATH
                        + (tmpl.updated_at ? " · " + etDate(tmpl.updated_at) : "")}
                </div>
              </div>
              {canEdit && (
                <button type="button" className="btn btn-ghost btn-sm"
                  disabled={!!busy} onClick={pickTemplate}>
                  <Icon name="upload" size={14} /> {tmpl ? "Asenda" : "Laadi ülesse"}
                </button>
              )}
            </div>

            <label className="fence-per mono" style={{ marginTop: 12 }}
              title="Mitu puurauku ühele Exceli lehele">
              <span>profiilid: lehel</span>
              <select className="select" value={perSheet} onChange={(e) => setPerSheet(+e.target.value)}>
                {Array.from({ length: 15 }, (_, i) => i + 1).map((n) => <option key={n} value={n}>{n}</option>)}
              </select>
            </label>

            {/* Said plainly, because a report that quietly invented its own
                conclusions would be worse than one with gaps. */}
            <p className="muted" style={{ marginTop: 14, lineHeight: 1.5 }}>
              Word-fail sisaldab tiitellehe, koosseisu, teostatud tööde kirjelduse,
              pinnaste kihikirjeldused ja veetasemed — kõik andmebaasist.
              Reljeef, geoloogiline ehitus, geotehnilised tingimused ja tabel 1 on
              märgitud <b>{REPORT_TODO}</b> ja need täidab geoloogiainsener.
              Asendiplaan ja läbilõigete joonised tehakse CAD-is.
            </p>
          </Panel>
        </div>
      )}
    </div>
  );
}

Object.assign(window, { ReportScreen });
