/* ===== admin-archive.jsx — Arhiiv: reading RP's old profile workbooks ========

   Christopher, 03.09: "just making sure we are not importing any of the old
   projects yet, just want to enable doing it in the future and have a place for
   it."

   So this READS and nothing else. It parses an archived
   "NN Geoloogia_profiil.xlsx", shows what an import would create, and writes
   nothing to the database. The write is the one remaining step, deliberately not
   built - an import button sitting here would be exactly the thing he hedged
   against.

   WHAT THE ARCHIVE ACTUALLY HOLDS, from surveying 1403 projects back to 2005:

     2005-2009  no geology spreadsheet at the project's top level
     2010-2013  the old BINARY .xls - a different reader entirely (BIFF, not a
                zip), so out of reach of xlsx-reader.js
     2014-2022  this format, 465 projects
     2023-2026  this format, but one folder down in 02_WORK

   And the profiles DO carry coordinates, which I had assumed they did not: row
   28 holds "X=6534919 / Y=612940" per borehole, L-EST97 in metres, X north and
   Y east in the Estonian surveying convention. So a profile import can place a
   hole on the map without touching the DWG.

   THREE THINGS THE REAL FILES DO THAT THE APP'S OWN EXPORTS DO NOT:

   1. THE SHEET NAME LIES. GL15001's second sheet is called "PA 11-13" and holds
      PA 7 to PA 12. Only the header row can be trusted.
   2. THE IDS ARE UNEVENLY SPACED - "PA - 1", "PA  - 11", "PA -12" - so the
      parser normalises whitespace before reading the number.
   3. THERE IS A TRAILING "Kontroll" COLUMN of OK/- markers, which today's
      export drops. It has to be recognised so it is not read as a borehole.
--------------------------------------------------------------------------- */

/* "PA - 1" / "PA  - 11" / "PA -12" / "KP3" -> "PA1" / "PA11" / "PA12" / "KP3",
   and "PA - 2a" -> "PA2a", "T - 41" -> "T41".
   Returns "" for anything that is not a borehole heading, which is how the
   Kontroll column and the empty corner cells are rejected.

   THE SHAPE, not a list of known types: twenty years of archive use codes this
   app no longer has (GL23003 numbers fifty holes "T - 1" upward), so the prefix
   is 1-3 uppercase letters, optionally PA/LP's slashed pair. The digits are
   capped at three - a hole is never PA1000, while a project code like GL23003
   would otherwise pass as one.

   The trailing letter is a REPOSITIONED HOLE, not noise: PA2a sits beside PA2
   and has its own log and its own lab samples. */
function archBhId(raw) {
  const t = String(raw == null ? "" : raw).replace(/\s+/g, " ").trim();
  if (!t) return "";
  const m = t.match(
    /^([A-ZÄÖÜÕ]{1,3}(?:\/[A-ZÄÖÜÕ]{1,3})?)\s*-?\s*(\d{1,3})\s*([a-zäöüõ])?\s*(?:-\s*(\d{1,2}))?$/i);
  if (!m) return "";
  return m[1].toUpperCase() + m[2] + (m[3] ? m[3].toLowerCase() : "")
    + (m[4] ? "-" + m[4] : "");
}

/* "X=6534919\nY=612940" -> { north: 6534919, east: 612940 }.
   X is the NORTHING in Estonian surveying, which is the opposite of a CAD
   drawing's X - the same trap as the DXF export, so the names here say which
   is which rather than carrying X and Y through. */
function archCoords(raw) {
  const t = String(raw == null ? "" : raw);
  const x = t.match(/X\s*=\s*([\d\s.,]+)/i);
  const y = t.match(/Y\s*=\s*([\d\s.,]+)/i);
  const num = (s) => {
    const n = parseFloat(String(s).replace(/\s/g, "").replace(",", "."));
    return isFinite(n) ? n : null;
  };
  const north = x ? num(x[1]) : null;
  const east = y ? num(y[1]) : null;
  /* sanity, not decoration: L-EST97 northings are ~6.4M and eastings ~0.4-0.7M,
     so a swapped pair is obvious and worth reporting rather than importing */
  const swapped = north != null && east != null && north < 1000000 && east > 1000000;
  return { north: swapped ? east : north, east: swapped ? north : east, swapped: swapped };
}

const ARCH_E = [370000, 740000];
const ARCH_N = [6377000, 6635000];

function archInEstonia(c) {
  return c.east != null && c.north != null
    && c.east >= ARCH_E[0] && c.east <= ARCH_E[1]
    && c.north >= ARCH_N[0] && c.north <= ARCH_N[1];
}

/* One workbook -> what an import would create. Pure: takes the grids
   xlsx-reader.js returns and touches nothing else. */
function parseArchiveProfile(wb, fileName) {
  const out = {
    file: fileName,
    code: (String(fileName).match(/GL[\s_]?\d+[-\d]*/i) || [""])[0].replace(/[\s_]/g, ""),
    sheets: [], boreholes: [], warnings: [],
  };

  (wb.sheetNames || []).forEach((name) => {
    const rows = wb.sheet(name) || [];
    /* FIND THE HEADER by content, not by row number: it is row 27 in the files
       seen so far, but a row number is exactly the kind of assumption that
       breaks on the next workbook. The header is the row with the most
       borehole-shaped cells. */
    let hdr = -1, best = 0;
    rows.forEach((r, i) => {
      const n = (r || []).filter((c) => archBhId(c)).length;
      if (n > best) { best = n; hdr = i; }
    });
    if (hdr === -1 || best === 0) {
      /* a sheet holding a title and nothing else is a blank template, not a
         profile that failed to parse - GL23003 carries five of them, and five
         identical warnings about them said nothing useful */
      const filled = rows.reduce((a, r) => a
        + (r || []).filter((c) => c != null && String(c).trim() !== "").length, 0);
      if (filled <= 3) out.blankSheets = (out.blankSheets || 0) + 1;
      else out.warnings.push(name + ": ei leidnud puuraukude päist");
      out.sheets.push({ name: name, boreholes: 0, rows: rows.length });
      return;
    }

    const cols = [];
    (rows[hdr] || []).forEach((c, ci) => {
      const id = archBhId(c);
      if (id) cols.push({ ci: ci, id: id });
    });

    /* coordinates: the row under the header, when it carries X=/Y= */
    const coordRow = rows[hdr + 1] || [];
    const hasCoords = cols.some((c) => /X\s*=/i.test(String(coordRow[c.ci] || "")));

    /* the water row, by its label in the name column - it also carries the
       field date, which is the only date in the workbook */
    let waterRow = -1, waterDate = "";
    for (let i = hdr + 1; i < rows.length; i++) {
      const lbl = String((rows[i] || [])[1] || "");
      if (/veetase/i.test(lbl)) {
        waterRow = i;
        const d = lbl.match(/(\d{1,2}\.\d{1,2}\.\d{4})/);
        waterDate = d ? d[1] : "";
        break;
      }
    }

    /* material rows: between the coordinates and the water row, a row counts
       when it has a name in column B. The trailing Kontroll column is excluded
       already, because archBhId rejected its heading. */
    const matFrom = hasCoords ? hdr + 2 : hdr + 1;
    const matTo = waterRow === -1 ? rows.length : waterRow;
    const mats = [];
    for (let i = matFrom; i < matTo; i++) {
      const nm = String((rows[i] || [])[1] || "").replace(/\s+/g, " ").trim();
      if (nm) mats.push({ ri: i, name: nm });
    }

    /* A SHEET WITH NO MATERIAL ROWS IS NOT A PROFILE. GL23001 carries a
       "PP_abi" helper tab whose one borehole-shaped cell made it report a
       borehole with zero layers - a phantom hole in the preview. Found by
       running the parser over real archived files rather than over this app's
       own exports. */
    if (!mats.length) {
      out.warnings.push(name + ": abileht, kihte ei ole — vahele jäetud");
      out.sheets.push({ name: name, boreholes: 0, materials: 0, skipped: true });
      return;
    }

    cols.forEach((c) => {
      const layers = [];
      mats.forEach((m) => {
        const v = parseFloat(String((rows[m.ri] || [])[c.ci] || "").replace(",", "."));
        if (isFinite(v) && v > 0) layers.push({ name: m.name, cm: v });
      });
      const coords = hasCoords ? archCoords(coordRow[c.ci]) : { north: null, east: null };
      const w = waterRow === -1 ? null
        : parseFloat(String((rows[waterRow] || [])[c.ci] || "").replace(",", "."));
      if (coords.swapped) out.warnings.push(c.id + ": X ja Y olid vahetuses, parandatud");
      if ((coords.east != null || coords.north != null) && !archInEstonia(coords)) {
        out.warnings.push(c.id + ": koordinaadid väljaspool L-EST97 ala");
      }
      out.boreholes.push({
        sheet: name, id: c.id, layers: layers,
        depthCm: layers.reduce((n, l) => n + l.cm, 0),
        east: coords.east, north: coords.north,
        water: isFinite(w) ? w : null, waterDate: waterDate,
      });
    });

    out.sheets.push({
      name: name, boreholes: cols.length, materials: mats.length,
      coords: hasCoords, waterRow: waterRow !== -1, waterDate: waterDate,
    });
  });

  /* the sheet names are not to be trusted - GL15001's "PA 11-13" holds PA 7-12
     - so say so rather than letting someone read the tabs and believe them */
  const dupes = {};
  out.boreholes.forEach((b) => { dupes[b.id] = (dupes[b.id] || 0) + 1; });
  Object.keys(dupes).filter((k) => dupes[k] > 1).forEach((k) => {
    out.warnings.push(k + ": esineb " + dupes[k] + " korda");
  });
  return out;
}

/* ---------------------------------------------------------------------------
   THE LAB TABLE -> samples with depths.

   Reuses LabParse.parseSheet, which already reads the workbooks RP receives
   today - the archived ones have the same columns, which is why this is a few
   lines rather than a second parser. The lab names a hole by a bare number
   ("1", "13"), so it is matched to "PA1"/"PA13" the same way the live lab
   import does: by trailing number, never blind.
--------------------------------------------------------------------------- */
/* Fold a header cell down to letters and digits, so "Puur-augu (PA) nr" and
   "Väli-\nmäärang" compare as words rather than as typography. */
function archFoldHdr(v) {
  return String(v == null ? "" : v)
    .toLowerCase()
    .replace(/ä/g, "a").replace(/ö/g, "o").replace(/ü/g, "u")
    .replace(/õ/g, "o").replace(/š/g, "s").replace(/ž/g, "z")
    .replace(/[^a-z0-9]/g, "");
}

/* The first row that looks like lab DATA: a sampling interval, or a bare sample
   number beside a borehole number. Everything above it is header. Depth is the
   reliable signal - "0,30-0,65" is not a thing a heading says. */
function archLabDataStart(grid) {
  for (let r = 0; r < Math.min(grid.length, 25); r++) {
    const row = grid[r] || [];
    for (let c = 0; c < Math.min(row.length, 8); c++) {
      const v = row[c];
      if (v == null) continue;
      const iv = LabParse.parseInterval(v);
      /* a range, or a plain depth under 100 m in a cell that is not an integer
         count - an interval is what distinguishes data from a sieve-size row */
      if (iv.top != null && iv.base != null && iv.base > iv.top && iv.base < 100) return r;
    }
  }
  return -1;
}

/* Collapse a multi-row header into the single row parseSheet expects, renaming
   each column to a heading lab-parse.js already knows. */
function archNormalizeLabGrid(grid) {
  const d = archLabDataStart(grid);
  if (d < 1) return null;
  const hdrRows = [];
  for (let r = Math.max(0, d - 4); r < d; r++) hdrRows.push(grid[r] || []);
  if (!hdrRows.length) return null;

  let width = 0;
  grid.forEach((row) => { width = Math.max(width, (row || []).length); });

  const synth = new Array(width).fill(null);
  for (let c = 0; c < width; c++) {
    const parts = hdrRows.map((row) => row[c]).filter((v) => v != null && String(v).trim() !== "");
    if (!parts.length) continue;
    const last = parts[parts.length - 1];
    /* SIEVE FIRST. The size sits in the lowest header row under a group
       heading, and joining that heading in would hide it. */
    const lastTxt = String(last).trim();
    if (typeof last === "number" || /^<?\s*[0-9]+(?:[.,][0-9]+)?$/.test(lastTxt)) {
      synth[c] = last;
      continue;
    }
    const f = archFoldHdr(parts.join(" "));
    /* puurauk before proov: "Tellija poolne proovi tähistus" sits above the
       borehole column and contains "proovi" */
    if (f.indexOf("puuraug") !== -1 || f.indexOf("puurauk") !== -1) synth[c] = "puuraugu";
    else if (f.indexOf("laborireg") !== -1 || f.indexOf("labnr") !== -1
      || f.indexOf("laborinr") !== -1 || f.indexOf("regnr") !== -1) synth[c] = "lab nr";
    else if (f.indexOf("proovinr") !== -1 || f.indexOf("proovinumber") !== -1) synth[c] = "proovi nr";
    else if (f.indexOf("intervall") !== -1) synth[c] = "intervall";
    else if (f.indexOf("pinnasegrupp") !== -1) synth[c] = "pinnasegrupp";
    else if (f === "wn" || f === "wl" || f === "wp" || f === "ip" || f === "k" || f === "u") synth[c] = f;
    /* anything else keeps its own words, including Välimäärang, which is read
       out of map.unknown further down */
    else synth[c] = String(last).trim();
  }
  if (synth.every((v) => v == null)) return null;
  return [synth].concat(grid.slice(d));
}

/* The identifying tail of a borehole id: the number plus any letter suffix,
   with the type prefix removed. "PA12" -> "12", "KP1" -> "1", "PA - 2a" -> "2a",
   and a bare lab "2a" -> "2a". Returns "" when there is no number at all. */
function archIdPrefix(v) {
  const t = String(v == null ? "" : v).trim().toUpperCase().replace(/[\s_.\-]/g, "");
  const m = /^([A-ZÄÖÜÕ\/]+)/.exec(t);
  return m ? m[1] : "";
}

function archIdTail(v) {
  const s = String(v == null ? "" : v).trim().toLowerCase().replace(/[\s_.\-]/g, "");
  const m = /([0-9]+[a-zäöüõ]?)$/.exec(s);
  /* leading zeros go: a survey plan writes PA001 where the profile writes PA1,
     and they are the same hole */
  return m ? m[1].replace(/^0+(?=\d)/, "") : "";
}

function parseArchiveLab(wb, fileName) {
  const out = { file: fileName, samples: [], warnings: [], sheet: "" };
  if (!window.LabParse) {
    out.warnings.push("lab-parse.js ei ole laetud");
    return out;
  }
  let best = null, bestGrid = null;
  (wb.sheetNames || []).forEach((name) => {
    const grid = wb.sheet(name) || [];
    /* the modern layout first, then the archived three-row header */
    const tries = [grid];
    const norm = archNormalizeLabGrid(grid);
    if (norm) tries.push(norm);
    tries.forEach((g) => {
      const r = LabParse.parseSheet(g);
      if (r.ok && r.rows.length && (!best || r.rows.length > best.rows.length)) {
        best = r;
        bestGrid = g;                       /* sourceRow indexes THIS grid */
        out.sheet = name;
      }
    });
  });
  if (!best) {
    out.warnings.push("ei leidnud laboritabelit (otsisin 'LAB NR' või 'Puuraugu' päist)");
    return out;
  }
  /* VÄLIMÄÄRANG comes from the raw grid, not from the parsed row. lab-parse maps
     only the columns it computes with; everything else lands in map.unknown - and
     the field description is exactly that. It is the column an engineer reads
     first when checking an old hole, so it is worth fetching by hand: find it in
     map.unknown, then read it per row via the sourceRow parseSheet recorded. */
  let fieldCol = -1;
  ((best.map && best.map.unknown) || []).forEach((u) => {
    const t = String(u.text || "").toLowerCase().replace(/[^a-zäöüõš]/g, "");
    if (fieldCol < 0 && (t.indexOf("valimaarang") !== -1 || t.indexOf("välimäärang") !== -1
      || t.indexOf("pinnasenimetus") !== -1)) fieldCol = u.col;
  });

  best.rows.forEach((r) => {
    const bh = String(r.labBorehole == null ? "" : r.labBorehole).trim();
    const raw = fieldCol >= 0 && r.sourceRow ? (bestGrid[r.sourceRow - 1] || [])[fieldCol] : null;
    out.samples.push({
      /* the tail carries the letter suffix: "2a" is the hole next to 2, not a
         broken "2" */
      bhTail: archIdTail(bh),
      bhRaw: bh,
      ref: String(r.labSampleNr == null ? "" : r.labSampleNr).trim(),
      top: r.depthTop, base: r.depthBase,
      field: raw == null ? "" : String(raw).trim(),
      iso: String(r.isoClass || r.iso2018Class || "").trim(),
      group: String(r.soilGroup || "").trim(),
    });
  });
  const noBh = out.samples.filter((x) => !x.bhTail).length;
  if (noBh) out.warnings.push(noBh + " laborirea puuraugu tähis ei ole loetav");
  return out;
}

/* ---------------------------------------------------------------------------
   A DXF -> borehole positions.

   Every DWG in the archive is a compressed version (AC1018 through AC1032), so
   it cannot be read here at all; a DXF exported from the same drawing can, and
   this app already writes DXF so the format is familiar ground.

   Points come from POINT, INSERT (a block, which is how CAD usually places a
   borehole marker) and CIRCLE; labels from TEXT and MTEXT. A label is attached
   to the NEAREST point within 25 m, because a plan puts the id beside the
   marker rather than on it. Unlabelled points are reported, not guessed at.
--------------------------------------------------------------------------- */
function parseArchiveDxf(text, fileName) {
  const out = { file: fileName, points: [], warnings: [] };
  const lines = String(text).split(/\r?\n/);

  /* A DXF IS BIG - the converted plans run from 1.8 MB to 81 MB - so this walks
     the lines and keeps only the entities that matter, rather than first
     building an array of every code/value pair in the file. */
  const ents = [];
  const KEEP = { INSERT: 1, ATTRIB: 1, SEQEND: 1, POINT: 1, CIRCLE: 1, TEXT: 1, MTEXT: 1 };
  let cur = null;
  for (let i = 0; i + 1 < lines.length; i += 2) {
    const code = lines[i].trim();
    const val = lines[i + 1];
    if (code === "0") {
      if (cur) ents.push(cur);
      const t = String(val).trim().toUpperCase();
      cur = KEEP[t] ? { type: t } : null;
      continue;
    }
    if (!cur) continue;
    if (code === "10") cur.x = parseFloat(val);
    else if (code === "20") cur.y = parseFloat(val);
    else if (code === "1") cur.text = String(val).trim();
    else if (code === "2" && cur.type === "INSERT") cur.block = String(val).trim();
    else if (code === "8") cur.layer = String(val).trim();
  }
  if (cur) ents.push(cur);

  /* ONE POSITION PER ID, chosen by where the project is.

     A plan repeats an id: GL15001 has PA2 on the road at 613220/6535025 and
     another PA2 ten kilometres away at 602205/6535850, on the pikiprofiil sheet
     drawn beside the model. The holes of a project sit together and a sheet copy
     does not, so the occurrence nearest the median of the unambiguous points
     wins. Median rather than mean: a single point 10 km out would drag a mean
     far enough to pick wrong. */
  const pickOne = (cands) => {
    const once = {}, many = {};
    cands.forEach((c) => {
      if (once[c.id] === undefined) once[c.id] = c;
      else { (many[c.id] = many[c.id] || [once[c.id]]).push(c); }
    });
    const solo = Object.keys(once).filter((k) => !many[k]).map((k) => once[k]);
    const mid = (arr) => {
      const v = arr.slice().sort((p, q) => p - q);
      return v.length ? v[Math.floor(v.length / 2)] : null;
    };
    const cx = mid((solo.length ? solo : cands).map((p) => p.east));
    const cy = mid((solo.length ? solo : cands).map((p) => p.north));
    const dup = [];
    Object.keys(many).forEach((id) => {
      dup.push(id);
      let best = many[id][0], bd = Infinity;
      many[id].forEach((c) => {
        const d = Math.pow(c.east - cx, 2) + Math.pow(c.north - cy, 2);
        if (d < bd) { bd = d; best = c; }
      });
      once[id] = best;
    });
    if (dup.length) {
      out.warnings.push(dup.length + " tähis on joonisel mitu korda (nt pikiprofiili koopia) — "
        + "võtsin uuringuala keskele lähima: " + dup.slice(0, 8).join(", ")
        + (dup.length > 8 ? " jt" : ""));
    }
    return Object.keys(once).map((k) => once[k]);
  };

  const finish = () => {
    const bad = out.points.filter((p) => !archInEstonia({ east: p.east, north: p.north })).length;
    if (bad) out.warnings.push(bad + " punkti väljaspool L-EST97 ala — kontrolli, kas X ja Y on vahetuses");
    return out;
  };

  /* BLOCK ATTRIBUTES FIRST. RP's plans place a hole as a block INSERT - block
     "Puurauk" on layer "Puuraugud" in every plan seen - carrying its number as
     an ATTRIB. An ATTRIB follows its INSERT in the entity stream until SEQEND,
     so the DXF states which marker the id belongs to and no distance guess is
     involved. The INSERT's own point is the position: the attribute is offset to
     sit beside the marker, ~6 m out in these drawings, which on a dense plan is
     the next hole along.

     ATTDEF is deliberately not read - it is the block DEFINITION's placeholder,
     sitting wherever the block was drawn, and it names no real hole. */
  const attrs = [];
  let host = null;
  ents.forEach((e) => {
    if (e.type === "INSERT") { host = isFinite(e.x) && isFinite(e.y) ? e : null; return; }
    if (e.type === "SEQEND") { host = null; return; }
    if (e.type !== "ATTRIB" || !host) return;
    const id = archBhId(e.text);
    if (!id) return;
    attrs.push({ id: id, east: host.x, north: host.y, fromLabel: false,
                 layer: host.layer || "", block: host.block || "" });
  });
  if (attrs.length) {
    out.points = pickOne(attrs);
    return finish();
  }

  /* No attributes: a plan drawn with plain text beside plain markers. */
  const marks = ents.filter((e) => ["POINT", "INSERT", "CIRCLE"].indexOf(e.type) !== -1
    && isFinite(e.x) && isFinite(e.y));
  const labels = ents.filter((e) => ["TEXT", "MTEXT"].indexOf(e.type) !== -1
    && archBhId(e.text) && isFinite(e.x) && isFinite(e.y));
  if (!marks.length && !labels.length) {
    out.warnings.push("DXF-is ei leidnud punkte ega puuraugu tähiseid");
    return out;
  }

  /* a label with no marker is still a position - the id text sits where the
     hole is often enough that dropping it would lose more than it saves */
  const used = {};
  const cands = [];
  labels.forEach((l) => {
    let near = null, dist = Infinity;
    marks.forEach((m, mi) => {
      if (used[mi]) return;
      const d = Math.sqrt(Math.pow(m.x - l.x, 2) + Math.pow(m.y - l.y, 2));
      if (d < dist) { dist = d; near = mi; }
    });
    const hit = near != null && dist <= 25;
    const take = hit ? marks[near] : l;
    if (hit) used[near] = 1;
    cands.push({
      id: archBhId(l.text), east: take.x, north: take.y,
      fromLabel: !hit, layer: take.layer || "",
    });
  });
  out.points = pickOne(cands);
  const orphan = marks.filter((m, mi) => !used[mi]).length;
  if (orphan) out.warnings.push(orphan + " punkti ilma loetava tähiseta — jäid välja");
  return finish();
}

/* ---------------------------------------------------------------------------
   THE FIELD GPS -> where the hole actually is.

   Delegates to parseBoreholeFile() in admin-import.jsx, which has read Garmin
   GPX and Leica TXT since the planned-import was built and already knows to
   strip "GL26027-" off a waypoint name. One parser, one place to fix.

   A .txt or .csv is accepted only if something in it lands inside L-EST97: an
   archived project folder also holds machine logs, notes and export dumps with
   those extensions, and the coordinate range is the honest discriminator.
--------------------------------------------------------------------------- */
function parseArchiveGps(text, fileName) {
  const out = { file: fileName, points: [], warnings: [], format: "" };
  if (!window.parseBoreholeFile) {
    out.warnings.push("admin-import.jsx ei ole laetud — GPS-faili ei loetud");
    return out;
  }
  const res = parseBoreholeFile(fileName, text);
  out.format = res.format;
  if (res.error) { out.warnings.push(res.error); return out; }

  const named = [];
  res.rows.forEach((r) => {
    if (r.east == null || r.north == null) return;
    if (!archInEstonia({ east: r.east, north: r.north })) return;
    const proper = archBhId(r.id);
    named.push({
      id: proper || String(r.id || "").trim(),
      tail: archIdTail(r.id),
      /* is this a borehole LABEL, or just something a GPS named? */
      labelled: !!proper,
      east: r.east, north: r.north, lat: r.lat, lon: r.lon,
      date: r.date || "", raw: r.raw,
    });
  });
  if (!named.length) {
    out.warnings.push(fileName + ": ei leidnud L-EST97 alasse jäävaid punkte");
    return out;
  }
  const dropped = res.rows.length - named.length;
  if (dropped > 0) out.warnings.push(fileName + ": " + dropped + " punkti ilma kasutatava koordinaadiga");

  /* ONE WAYPOINT PER HOLE, and a real label beats a bare number.

     A Garmin names its own waypoints "001", "002" - and since leading zeros are
     stripped when ids are compared, "001" would otherwise claim PA1 and put its
     position 1.9 km away from the hole. Where two waypoints resolve to the same
     hole, the one written as a borehole ("PA001", "GL15001-PA4") wins and the
     other is reported. */
  const best = {}, clash = [];
  named.forEach((p) => {
    const k = p.tail || p.id;
    const held = best[k];
    if (!held) { best[k] = p; return; }
    if (p.labelled && !held.labelled) { best[k] = p; clash.push(held.raw); return; }
    clash.push(p.raw);
  });
  if (clash.length) {
    out.warnings.push(fileName + ": " + clash.length + " punkt(i) osutas samale puuraugule, "
      + "jätsin kõrvale: " + clash.slice(0, 8).join(", ") + (clash.length > 8 ? " jt" : ""));
  }
  out.points = Object.keys(best).map((k) => best[k]);
  return out;
}

/* ---------------------------------------------------------------------------
   MERGE, per borehole. Layers stack into real top/base depths, samples come
   from the lab table, coordinates from whichever source has them.

   This is the part that makes the preview read like a borehole the app created:
   the profile alone gives thicknesses, and thicknesses are not a log.
--------------------------------------------------------------------------- */
function mergeArchiveBundle(profile, lab, dxf, gps) {
  const byId = {};
  const take = (id) => (byId[id] = byId[id] || {
    id: id, layers: [], samples: [], east: null, north: null,
    water: null, waterDate: "", sheet: "", coordFrom: "",
    /* TWO POSITIONS, not one. plan = what was drawn, field = where the rig
       actually stood. Leivi asked to see both and the difference; a single
       merged coordinate hides exactly that. */
    plan: null, field: null, offset: null,
  });

  (profile ? profile.boreholes : []).forEach((b) => {
    const r = take(b.id);
    r.sheet = b.sheet;
    r.water = b.water;
    r.waterDate = b.waterDate;
    /* THICKNESSES BECOME DEPTHS. The profile lists cm per material top-down, so
       the running sum is the layer's base - which is what every other view in
       this app expects a layer to carry. */
    let acc = 0;
    b.layers.forEach((l) => {
      const top = acc, base = acc + l.cm / 100;
      acc = base;
      r.layers.push({ name: l.name, top: top, base: base, cm: l.cm });
    });
    if (b.east != null) r.plan = { east: b.east, north: b.north, from: "profiil" };
  });

  /* the DXF wins on coordinates when both have them: it is the drawn plan, while
     the profile's are typed in by hand.

     EXCEPT for a point that came from a label with no marker beside it - that is
     the insertion point of a piece of text, which can sit metres from the hole,
     so a coordinate the profile already carries is the better of the two. */
  (dxf ? dxf.points : []).forEach((p) => {
    const r = take(p.id);
    if (p.fromLabel && r.plan) return;
    r.plan = { east: p.east, north: p.north, from: "DXF" + (p.fromLabel ? " (tähis)" : "") };
  });

  /* The lab says "13" or "2a"; the profile says "PA13". Matched on the tail -
     but ONLY when exactly one hole has that tail. GL23003 has PA1 and KP1, so
     lab row "1" is genuinely ambiguous, and attaching it to whichever hole came
     first in the workbook would put a sample in the wrong borehole without ever
     saying so. Ambiguity is reported instead. */
  const byTail = {};
  Object.keys(byId).forEach((id) => {
    const t = archIdTail(id);
    if (!t) return;
    (byTail[t] = byTail[t] || []).push(id);
  });

  /* PASS 1: the rows that can only mean one hole. They also vote on which
     series this lab table is sampling. */
  const missing = {}, ambiguous = {}, pending = [], series = {};
  (lab ? lab.samples : []).forEach((sm) => {
    const hits = sm.bhTail ? (byTail[sm.bhTail] || []) : [];
    if (hits.length === 1) {
      byId[hits[0]].samples.push(sm);
      const p = archIdPrefix(hits[0]);
      if (p) series[p] = (series[p] || 0) + 1;
      return;
    }
    const label = sm.bhRaw || sm.bhTail || "?";
    if (hits.length > 1) { ambiguous[label] = hits; pending.push(sm); }
    else missing[label] = (missing[label] || 0) + 1;
  });

  /* PASS 2: the elected series, but only if the vote was unanimous - a split
     vote means the table really does span types and nothing can be inferred. */
  const voted = Object.keys(series);
  const winner = voted.length === 1 ? voted[0] : "";
  let inferred = 0;
  if (winner) {
    pending.forEach((sm) => {
      const hits = byTail[sm.bhTail] || [];
      const only = hits.filter((id) => archIdPrefix(id) === winner);
      if (only.length !== 1) return;
      byId[only[0]].samples.push(sm);
      sm.matchedBy = winner + "-seeria";
      inferred++;
      delete ambiguous[sm.bhRaw || sm.bhTail || "?"];
    });
  }

  /* THE FIELD GPS. Matched on the tail like the lab, and only when it is
     unambiguous - but unlike the lab, an unmatched waypoint still gets a row of
     its own when there is no profile at all, so a GPX on its own still puts
     something on the map. With a profile loaded, a waypoint that matches
     nothing is reported rather than invented as a borehole. */
  const gpsMissed = [], gpsAmbig = {};
  (gps ? gps.points : []).forEach((p) => {
    const hits = p.tail ? (byTail[p.tail] || []) : [];
    let target = null;
    if (hits.length === 1) target = hits[0];
    else if (hits.length > 1) { gpsAmbig[p.raw || p.id] = hits; return; }
    else if (byId[p.id]) target = p.id;
    else if (!profile || !profile.boreholes.length) target = p.id;
    if (!target) { gpsMissed.push(p.raw || p.id); return; }
    const r = take(target);
    r.field = { east: p.east, north: p.north, from: "GPS" + (p.date ? " " + p.date : ""),
                date: p.date || "" };
  });

  /* what the rest of the page reads: the field position when there is one,
     since that is where the hole is, with the plan kept for comparison */
  Object.keys(byId).forEach((k) => {
    const r = byId[k];
    const use = r.field || r.plan;
    if (use) { r.east = use.east; r.north = use.north; r.coordFrom = use.from; }
    if (r.field && r.plan) {
      r.offset = Math.sqrt(Math.pow(r.field.east - r.plan.east, 2)
        + Math.pow(r.field.north - r.plan.north, 2));
    }
  });

  const rows = Object.keys(byId).map((k) => byId[k]).sort((a, b) =>
    window.bhCompare ? bhCompare(a.id, b.id) : a.id.localeCompare(b.id));
  rows.forEach((r) => r.samples.sort((a, b) =>
    (a.top == null ? 1e9 : a.top) - (b.top == null ? 1e9 : b.top)));

  const notes = [];
  const missKeys = Object.keys(missing);
  if (missKeys.length) {
    notes.push("laboris on puuraugud, mida profiilis ei ole: "
      + missKeys.slice(0, 12).join(", ") + (missKeys.length > 12 ? " jt" : ""));
  }
  if (inferred) {
    notes.push(inferred + " proovi seoti " + winner + "-seeriaga: labor kirjutas"
      + " ainult numbri, aga üheselt sobivad read olid kõik " + winner);
  }
  const ambKeys = Object.keys(ambiguous);
  if (ambKeys.length) {
    notes.push("mitmeti mõistetav puuraugu tähis, proovid jäid sidumata: "
      + ambKeys.slice(0, 10).map((k) => k + " (" + ambiguous[k].join("/") + ")").join(", ")
      + (ambKeys.length > 10 ? " jt" : ""));
  }
  if (gpsMissed.length) {
    notes.push(gpsMissed.length + " GPS-punkti ei vastanud ühelegi puuraugule: "
      + gpsMissed.slice(0, 10).join(", ") + (gpsMissed.length > 10 ? " jt" : ""));
  }
  Object.keys(gpsAmbig).forEach((k) => {
    notes.push("GPS-punkt \"" + k + "\" võib olla " + gpsAmbig[k].join(" või ") + " — jäi sidumata");
  });
  const far = rows.filter((r) => r.offset != null && r.offset > 25);
  if (far.length) {
    notes.push(far.length + " puurauku on plaanist üle 25 m kaugusel: "
      + far.slice(0, 8).map((r) => r.id + " " + r.offset.toFixed(0) + " m").join(", ")
      + (far.length > 8 ? " jt" : ""));
  }
  const unmatched = (lab ? lab.samples : []).length
    - rows.reduce((a, r) => a + r.samples.length, 0);
  return { rows: rows, unmatchedSamples: unmatched, notes: notes };
}

Object.assign(window, {
  archBhId, archCoords, archInEstonia, parseArchiveProfile,
  parseArchiveLab, parseArchiveDxf, parseArchiveGps, mergeArchiveBundle,
});

/* ---------------------------------------------------------------------------
   THE PAGE. Read-only on purpose: it answers "what could be imported" without
   importing anything, which is what was asked for.
--------------------------------------------------------------------------- */
/* ---------------------------------------------------------------------------
   Where the boreholes are. Not BhMap: that one reads window.BOREHOLE_ITEMS and
   filters by project, and the archive's holes are deliberately not in the
   store. Same tiles, same zoom-gated labels, explicit points.
--------------------------------------------------------------------------- */
function ArchiveMap({ points, height = 340 }) {
  const elRef = React.useRef(null);
  const mapRef = React.useRef(null);
  const layerRef = React.useRef(null);
  /* the observer outlives this effect, so it calls through a ref rather than
     capturing the fit closure of whichever render installed it */
  const fitRef = React.useRef(null);
  const sig = points.map((p) => p.id + "@" + p.lat.toFixed(6) + "," + p.lon.toFixed(6)).join("|");

  React.useEffect(() => {
    if (!window.L || !elRef.current) return;
    if (!mapRef.current) {
      mapRef.current = L.map(elRef.current, {
        scrollWheelZoom: true, zoomControl: true,
        zoomAnimation: false, fadeAnimation: false, markerZoomAnimation: false,
      });
      const osm = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
        maxZoom: 19, attribution: "&copy; OpenStreetMap",
      });
      const foto = L.tileLayer("https://tiles.maaamet.ee/tm/tms/1.0.0/foto@GMC/{z}/{x}/{y}.png", {
        tms: true, maxZoom: 19, maxNativeZoom: 18,
        attribution: "&copy; Maa- ja Ruumiamet", className: "orthotile",
      });
      osm.addTo(mapRef.current);
      L.control.layers({ "Kaart": osm, "Ortofoto": foto }, {},
        { position: "topleft", collapsed: true }).addTo(mapRef.current);
      layerRef.current = L.layerGroup().addTo(mapRef.current);
      mapRef.current.setView([58.8, 25.2], 7);
      const gate = () => {
        const el = mapRef.current && mapRef.current.getContainer();
        if (el) el.classList.toggle("lbl-on", mapRef.current.getZoom() >= 15);
      };
      mapRef.current.on("zoomend", gate);
      gate();
    }
    const lg = layerRef.current;
    lg.clearLayers();
    const pts = [];
    points.forEach((p) => {
      /* PLANNED AND ACTUAL, both drawn. "näha mis asukoht oli plaanis ja kuhu
         välitööl päriselt tehti" - so the two are joined by a line and the
         offset is in the popup, rather than one quietly replacing the other. */
      if (p.plan && p.field) {
        L.polyline([[p.plan.lat, p.plan.lon], [p.field.lat, p.field.lon]], {
          color: "#64748b", weight: 1.5, dashArray: "4 4", opacity: 0.9,
        }).addTo(lg);
      }
      [["plan", "#b45309", 5.5], ["field", "#2563eb", 6.5]].forEach(([kind, hex, rad]) => {
        const q = p[kind];
        if (!q) return;
        pts.push([q.lat, q.lon]);
        const mk = L.circleMarker([q.lat, q.lon], {
          radius: rad, color: "#ffffff", weight: 1.6, fillColor: hex, fillOpacity: 1,
        });
        /* one label per hole, on the actual position when there is one */
        if (kind === (p.field ? "field" : "plan")) {
          mk.bindTooltip(p.id, { permanent: true, direction: "right", offset: [7, 0], className: "bh-lbl" });
        }
        mk.bindPopup(p.id + "<br>" + (kind === "plan" ? "plaanis" : "välitööl") + ": "
          + (q.from || "") + "<br>N " + q.north.toFixed(1) + " E " + q.east.toFixed(1)
          + (p.offset != null ? "<br>nihe plaanist " + p.offset.toFixed(1) + " m" : ""))
          .addTo(lg);
      });
    });
    /* INVALIDATE BEFORE FITTING. Leaflet fits against the size it thinks the
       container has, and on first render inside a Panel that size is stale - the
       pins came out 159 px above the top edge with the whole project projected
       onto one point at zoom 7. Fitting again after a tick covers the case where
       the panel is still laying out. */
    const fit = () => {
      if (!mapRef.current) return;
      mapRef.current.invalidateSize();
      if (!pts.length) return;
      mapRef.current.fitBounds(L.latLngBounds(pts).pad(0.25), { animate: false });
      if (pts.length === 1) mapRef.current.setZoom(16);
    };
    fit();
    setTimeout(fit, 80);
    /* and again whenever the container actually changes size - the panel fades
       in, the window resizes, the sidebar collapses. A timeout is a guess about
       when layout settles; this is the answer. */
    if (window.ResizeObserver && !mapRef.current._archRO) {
      mapRef.current._archRO = new ResizeObserver(() => fitRef.current && fitRef.current());
      mapRef.current._archRO.observe(elRef.current);
    }
    fitRef.current = fit;
  }, [sig]);

  return <div className="arch-map" ref={elRef} style={{ height }} />;
}

/* Parse results are cached per file, so adding one file to a folder already
   read does not re-read the rest. */
const ARCH_PARSED = new Map();
function archFileKey(f) {
  /* NAME AND SIZE, not path or mtime: the same workbook is copied between
     02 WORK and 03 OUTPUT with a different timestamp, and offering it twice as
     a candidate is noise. Two genuinely different files of the same name differ
     in size in every case seen in the archive. */
  return f.name + "|" + f.size;
}

/* Everything under a dropped folder, depth-first. Chrome hands a directory over
   as an entry rather than a File, so a drop of "GL15001" arrives as one item
   with a hundred files inside it. */
async function archReadEntries(items) {
  const out = [];
  const walk = async (entry, path) => {
    if (!entry) return;
    if (entry.isFile) {
      await new Promise((res) => entry.file((f) => {
        try { Object.defineProperty(f, "webkitRelativePath", { value: path + f.name }); } catch (e) {}
        out.push(f);
        res();
      }, res));
      return;
    }
    if (entry.isDirectory) {
      const rd = entry.createReader();
      /* readEntries returns at most 100 at a time and signals the end with an
         empty batch - a single call silently truncates a big project folder */
      for (;;) {
        const batch = await new Promise((res) => rd.readEntries(res, () => res([])));
        if (!batch.length) break;
        for (const e of batch) await walk(e, path + entry.name + "/");
      }
    }
  };
  const entries = [...items].map((it) => (it.webkitGetAsEntry ? it.webkitGetAsEntry() : null));
  for (const e of entries) await walk(e, "");
  return out;
}

function ArchiveScreen({ onToast }) {
  const [files, setFiles] = useState([]);
  const [bundle, setBundle] = useState(null);
  const [busy, setBusy] = useState("");
  const [openBh, setOpenBh] = useState("");
  const [useProfile, setUseProfile] = useState(0);
  const [useLab, setUseLab] = useState(0);
  const [drag, setDrag] = useState(false);

  /* WHAT KIND OF FILE IS THIS - decided by content, not by name. The archive has
     three naming eras and GL15001's lab table is called "Book11.xlsx", so a
     name-based guess would be wrong often enough to be useless: a workbook is a
     profile if it yields boreholes, otherwise a lab table if LabParse finds a
     header in it. */
  const classify = async (f) => {
    const key = archFileKey(f);
    if (ARCH_PARSED.has(key)) return ARCH_PARSED.get(key);
    const low = f.name.toLowerCase();
    let res = { kind: "other", name: f.name, path: f.webkitRelativePath || f.name, size: f.size };
    try {
      if (low.endsWith(".xlsx") || low.endsWith(".xlsm")) {
        const wb = await XlsxRead.read(f);
        const prof = parseArchiveProfile(wb, f.name);
        if (prof.boreholes.length) res = Object.assign(res, { kind: "profile", profile: prof });
        else {
          const lab = parseArchiveLab(wb, f.name);
          if (lab.samples.length) res = Object.assign(res, { kind: "lab", lab: lab });
        }
      } else if (low.endsWith(".dxf")) {
        const dxf = parseArchiveDxf(await f.text(), f.name);
        if (dxf.points.length) res = Object.assign(res, { kind: "dxf", dxf: dxf });
        else res = Object.assign(res, { kind: "dxf-empty", dxf: dxf });
      } else if (low.endsWith(".dwg")) {
        res.kind = "dwg";
      } else if (/\.(gpx|txt|csv)$/i.test(low)) {
        /* a GPX is always positions; a TXT or CSV only counts as positions when
           something in it lands inside L-EST97, since an archived folder holds
           machine logs and notes with the same extension */
        const gps = parseArchiveGps(await f.text(), f.name);
        if (gps.points.length) res = Object.assign(res, { kind: "gps", gps: gps });
        else res.kind = "doc";
      } else if (/\.(pdf|docx?|jpe?g|png|ags)$/i.test(low)) {
        /* THE LAB'S PROTOCOL is the one document worth naming: it is the
           evidence behind the lab table, and RP file it as the teimiprotokoll.
           By name, unavoidably - a PDF's content is not readable here - but
           only ever to LABEL it, never to decide what gets parsed. */
        res.kind = /(katse|teimi)protokoll|protokoll/i.test(low) ? "protokoll"
          : /tekst/i.test(low) ? "tekst"
            : "doc";
      }
    } catch (e) {
      res = Object.assign(res, { kind: "failed", error: e && e.message ? e.message : String(e) });
    }
    ARCH_PARSED.set(key, res);
    return res;
  };

  /* Only the file types worth opening are parsed; a project folder also holds
     hundreds of photos and a dozen DWGs, and reading those would cost minutes
     for nothing. */
  const RELEVANT = /\.(xlsx|xlsm|dxf|dwg|gpx|txt|csv|pdf|docx?|jpe?g|png|ags)$/i;

  const rebuild = async (list, pi, li) => {
    const parsed = [];
    for (const f of list) {
      if (!RELEVANT.test(f.name)) continue;
      parsed.push(await classify(f));
    }
    const profiles = parsed.filter((p) => p.kind === "profile")
      .sort((a, b) => b.profile.boreholes.length - a.profile.boreholes.length);
    const labs = parsed.filter((p) => p.kind === "lab")
      .sort((a, b) => b.lab.samples.length - a.lab.samples.length);
    const dxfs = parsed.filter((p) => p.kind === "dxf");
    const gpss = parsed.filter((p) => p.kind === "gps");
    const docs = parsed.filter((p) => p.kind === "doc");
    const protos = parsed.filter((p) => p.kind === "protokoll");
    const texts = parsed.filter((p) => p.kind === "tekst");
    const dwgs = parsed.filter((p) => p.kind === "dwg");
    const failed = parsed.filter((p) => p.kind === "failed");

    const profile = profiles.length ? profiles[Math.min(pi, profiles.length - 1)].profile : null;
    const lab = labs.length ? labs[Math.min(li, labs.length - 1)].lab : null;
    /* several plans in one folder (Üldskeem and PA plaan) - merge their points,
       first one wins a repeated id */
    let dxf = null;
    if (dxfs.length) {
      const seen = {}, pts = [], warns = [];
      dxfs.forEach((d) => {
        d.dxf.points.forEach((p) => { if (!seen[p.id]) { seen[p.id] = 1; pts.push(p); } });
        d.dxf.warnings.forEach((w) => warns.push(d.name + ": " + w));
      });
      dxf = { file: dxfs.map((d) => d.name).join(", "), points: pts, warnings: warns };
    }

    /* several GPS files is normal - one per field day, or one per crew */
    let gps = null;
    if (gpss.length) {
      const seen = {}, pts = [], warns = [];
      gpss.forEach((g) => {
        g.gps.points.forEach((p) => {
          const k = p.tail || p.id;
          if (seen[k]) return;
          seen[k] = 1;
          pts.push(p);
        });
        g.gps.warnings.forEach((w) => warns.push(w));
      });
      gps = { file: gpss.map((g) => g.name).join(", "), points: pts, warnings: warns };
    }

    const warnings = [];
    if (dwgs.length) {
      /* say what to DO about it, not just that it cannot be read */
      warnings.push(dwgs.length + " DWG-faili ei saa lugeda (fail on pakitud) — ava joonis "
        + "CADis ja salvesta DXF-ina (SAVEAS → DXF), lisa see siia ja koordinaadid tulevad "
        + "sisse. Puuraugud loetakse plokkide (Puurauk) atribuutidest.");
    }
    if (!profile) warnings.push("geoloogia profiili ei olnud valikus — kihte ei ole");
    (gps && gps.warnings ? gps.warnings : []).forEach((w) => warnings.push(w));
    failed.forEach((f) => warnings.push(f.name + ": ei õnnestunud lugeda — " + f.error));
    [profile, lab].forEach((p) => {
      (p && p.warnings ? p.warnings : []).forEach((w) => warnings.push((p.file || "") + ": " + w));
    });
    /* the merged DXF warnings already name their own file */
    (dxf && dxf.warnings ? dxf.warnings : []).forEach((w) => warnings.push(w));
    if (profile && profile.blankSheets) {
      warnings.push(profile.blankSheets + " tühja lehte jäeti vahele");
    }

    const merged = mergeArchiveBundle(profile, lab, dxf, gps);
    if (merged.unmatchedSamples) {
      warnings.push(merged.unmatchedSamples + " laboriproovi ei leidnud vastavat puurauku");
    }
    (merged.notes || []).forEach((n) => warnings.push(n));

    /* the map wants WGS84; the files are L-EST97, and a pair outside Estonia is
       reported rather than plotted into the Baltic */
    const points = [], offGrid = [];
    const toWgs = (q) => {
      if (!q || !window.RPStore) return null;
      const w = RPStore.lestToWgs(q.north, q.east);
      return { lat: w.lat, lon: w.lon, north: q.north, east: q.east, from: q.from };
    };
    merged.rows.forEach((r) => {
      if (r.east == null || r.north == null) return;
      if (!archInEstonia(r)) { offGrid.push(r.id); return; }
      const plan = toWgs(r.plan), field = toWgs(r.field);
      if (plan || field) {
        points.push({ id: r.id, plan: plan, field: field, offset: r.offset,
          lat: (field || plan).lat, lon: (field || plan).lon });
      }
    });
    if (offGrid.length) {
      warnings.push(offGrid.length + " puuraugu koordinaadid on väljaspool Eestit, kaardil ei ole: "
        + offGrid.slice(0, 8).join(", "));
    }

    return { profiles, labs, dxfs, gpss, docs, protos, texts, dwgs,
      warnings, merged, points, parsed };
  };

  const addFiles = async (list) => {
    const incoming = [...list].filter((f) => RELEVANT.test(f.name));
    if (!incoming.length) {
      onToast && onToast("Valitud kaustast ei leidnud ühtegi loetavat faili", true);
      return;
    }
    setBusy("Loen " + incoming.length + " faili");
    try {
      /* ACCUMULATE. Adding the lab table must not throw away the profile. */
      const byKey = new Map();
      files.concat(incoming).forEach((f) => byKey.set(archFileKey(f), f));
      const all = [...byKey.values()];
      const res = await rebuild(all, useProfile, useLab);
      setFiles(all);
      setBundle(res);
      const n = res.merged.rows.length;
      onToast && onToast(n
        ? n + " puurauku · " + res.merged.rows.reduce((a, r) => a + r.samples.length, 0)
          + " proovi · " + res.points.length + " koordinaadiga"
        : "Puurauke ei leidnud", !n);
    } catch (e) {
      onToast && onToast(e && e.message ? e.message : String(e), true);
    }
    setBusy("");
  };

  const reselect = async (pi, li) => {
    setUseProfile(pi);
    setUseLab(li);
    setBusy("Arvutan uuesti");
    try { setBundle(await rebuild(files, pi, li)); } catch (e) {
      onToast && onToast(e && e.message ? e.message : String(e), true);
    }
    setBusy("");
  };

  const pick = (folder) => {
    const inp = document.createElement("input");
    inp.type = "file";
    inp.multiple = true;
    if (folder) {
      /* the whole project folder, subfolders included - the only way to reach
         01 INFO/Labor and 02 WORK in one go */
      inp.webkitdirectory = true;
      inp.setAttribute("webkitdirectory", "");
      inp.setAttribute("directory", "");
    } else {
      inp.accept = ".xlsx,.xlsm,.dxf,.gpx,.txt,.csv,.dwg,.pdf,.docx,.doc,.jpg,.jpeg,.png,.ags";
    }
    inp.onchange = () => { if (inp.files && inp.files.length) addFiles(inp.files); };
    inp.click();
  };

  const onDrop = async (e) => {
    e.preventDefault();
    setDrag(false);
    const dt = e.dataTransfer;
    if (!dt) return;
    setBusy("Loen kausta");
    try {
      const fromEntries = dt.items && dt.items.length ? await archReadEntries(dt.items) : [];
      await addFiles(fromEntries.length ? fromEntries : dt.files);
    } catch (err) {
      onToast && onToast(err && err.message ? err.message : String(err), true);
      setBusy("");
    }
  };

  const clear = () => {
    setFiles([]); setBundle(null); setOpenBh("");
    setUseProfile(0); setUseLab(0);
  };

  const rows = bundle && bundle.merged ? bundle.merged.rows : [];
  const tot = rows.reduce((a, r) => ({
    layers: a.layers + r.layers.length,
    samples: a.samples + r.samples.length,
    water: a.water + (r.water != null ? 1 : 0),
  }), { layers: 0, samples: 0, water: 0 });

  return (
    <div className="page-inner fade-in"
      onDragOver={(e) => { e.preventDefault(); setDrag(true); }}
      onDragLeave={() => setDrag(false)}
      onDrop={onDrop}>
      <div className="page-head">
        <div>
          <h1 className="page-title">Arhiiv</h1>
          <p className="page-sub">
            Vana projekti lugemine · andmebaasi ei kirjutata midagi
          </p>
        </div>
        <div className="page-actions">
          {files.length > 0 && (
            <button className="btn btn-ghost" disabled={!!busy} onClick={clear}>
              Tühjenda
            </button>
          )}
          <button className="btn btn-ghost" disabled={!!busy} onClick={() => pick(false)}>
            <Icon name="plus" size={16} /> Lisa faile
          </button>
          <button className="btn btn-primary" disabled={!!busy} onClick={() => pick(true)}>
            <Icon name="folder" size={16} /> {busy || "Vali projekti kaust"}
          </button>
        </div>
      </div>

      {!bundle && (
        <Panel title="Mida valida" sub="Vali kogu projekti kaust — alamkaustad loetakse kaasa">
          <div className={"arch-drop" + (drag ? " on" : "")}>
            <Icon name="folder" size={22} />
            <div>
              <b>Lohista projekti kaust siia</b> või vajuta „Vali projekti kaust“.
              <div className="muted" style={{ marginTop: 4 }}>
                Faile võib lisada mitmes jaos — varem loetu jääb alles.
              </div>
            </div>
          </div>
          <div className="arch-eras" style={{ marginTop: 12 }}>
            {/* RP'S OWN NAMES, without the stage numbers: those order a folder,
                they are not part of a file's identity, and nobody matching this
                list against a folder needs them to recognise the file. The
                order is the one a report is assembled in. (Christopher, 21.09.)
                Katseprotokoll used to share a line with the text document -
                different things from different places, so a line each. */}
            {[
              ["Geoloogia_profiil.xlsx", "puuraugud, kihid, veetase, kohati koordinaadid", "ok"],
              ["Labor_töödeldud.xlsx", "laboritabel — proovid koos sügavustega", "ok"],
              ["Teimiprotokoll.pdf", "labori protokoll — loetakse nimekirja, sisu ei avata", "none"],
              ["Tekst.docx", "aruande tekst — loetakse nimekirja, sisu ei avata", "none"],
              ["UP_plaan.dxf", "plaanitud asukoht; DWG-d ei saa lugeda", "old"],
              ["Välitöö GPS (.gpx / .txt)", "Garmin või Leica punktid — kus auk päriselt on", "ok"],
            ].map(([what, why, cls]) => (
              <div className={"arch-era " + cls} key={what}>
                <span className="mono arch-era-y" style={{ flexBasis: 220 }}>{what}</span>
                <span className="arch-era-w">{why}</span>
              </div>
            ))}
          </div>
        </Panel>
      )}

      {bundle && (
        <React.Fragment>
          {bundle.points.length > 0 && (
            <Panel title="Asukohad"
              sub={bundle.points.length + " puurauku kaardil · pruun = plaanis, sinine = välitööl"
                + (bundle.points.filter((p) => p.plan && p.field).length
                  ? " · " + bundle.points.filter((p) => p.plan && p.field).length + " mõlemat"
                  : "")}
              bodyFlush>
              <ArchiveMap points={bundle.points} />
            </Panel>
          )}

          <Panel title="Loetud failid"
            sub={rows.length + " puurauku · " + tot.layers + " kihti · " + tot.samples
              + " proovi · " + bundle.points.length + " koordinaadiga · " + tot.water + " veetasemega"}>
            <div className="arch-src">
              {/* RP'S OWN NAMES, in the order a report is put together: the
                  profile, the lab and the protocol behind it, the text, then
                  where the holes are. The label says what the file IS - the
                  archive's own names carry three eras of prefixes and a
                  "Book11.xlsx" - and the real name stays on the button, since
                  telling two candidates apart is what the buttons are for. */}
              {[["Geoloogia_profiil.xlsx", bundle.profiles, useProfile,
                 (i) => reselect(i, useLab), (p) => p.profile.boreholes.length + " puurauku"],
                ["Labor_töödeldud.xlsx", bundle.labs, useLab,
                 (i) => reselect(useProfile, i), (p) => p.lab.samples.length + " proovi"]].map(
                ([label, list, cur, onPick, sub]) => (
                <div className="arch-srcrow" key={label}>
                  <span className="arch-era-y mono">{label}</span>
                  {!list.length ? <span className="muted">ei olnud valikus</span> : (
                    <span className="arch-cands">
                      {list.map((p, i) => (
                        <button type="button" key={p.path}
                          className={"ppick-yr" + (i === cur ? " on" : "")}
                          disabled={!!busy || list.length === 1}
                          title={p.path}
                          onClick={() => onPick(i)}>
                          {p.name} · {sub(p)}
                        </button>
                      ))}
                    </span>
                  )}
                </div>
              ))}
              <div className="arch-srcrow">
                <span className="arch-era-y mono">Teimiprotokoll.pdf</span>
                <span className={bundle.protos.length ? "" : "muted"}>
                  {bundle.protos.length
                    ? bundle.protos.map((d) => d.name).join(", ")
                    : "ei olnud valikus"}
                </span>
              </div>
              <div className="arch-srcrow">
                <span className="arch-era-y mono">Tekst.docx</span>
                <span className={bundle.texts.length ? "" : "muted"}>
                  {bundle.texts.length
                    ? bundle.texts.map((d) => d.name).join(", ")
                    : "ei olnud valikus"}
                </span>
              </div>
              <div className="arch-srcrow">
                <span className="arch-era-y mono">UP_plaan.dwg</span>
                <span className={bundle.dxfs.length ? "" : "muted"}>
                  {bundle.dxfs.length
                    ? bundle.dxfs.map((d) => d.name + " (" + d.dxf.points.length + ")").join(", ")
                    : "DXF-i ei olnud — plaanikoordinaadid tulevad profiilist, kui seal on"}
                </span>
              </div>
              <div className="arch-srcrow">
                <span className="arch-era-y mono">Välitöö GPS</span>
                <span className={bundle.gpss.length ? "" : "muted"}>
                  {bundle.gpss.length
                    ? bundle.gpss.map((g) => g.name + " · " + g.gps.format.toUpperCase()
                        + " · " + g.gps.points.length + " punkti").join(", ")
                    : "ei olnud valikus — .gpx (Garmin) või .txt/.csv (Leica, L-EST97)"}
                </span>
              </div>
              {bundle.docs.length > 0 && (
                <div className="arch-srcrow">
                  <span className="arch-era-y mono">Muud dokumendid</span>
                  <span>{bundle.docs.length} faili — impordil lisataks projekti juurde</span>
                </div>
              )}
            </div>
            {bundle.warnings.length > 0 && (
              <ul className="arch-warn" style={{ marginTop: 10 }}>
                {bundle.warnings.map((w, i) => <li key={i}>{w}</li>)}
              </ul>
            )}
          </Panel>

          {rows.length > 0 && (
            <Panel title="Puuraugud" sub="Klõps avab kihid ja proovid" bodyFlush>
              <div className="arch-list">
                {rows.map((r) => (
                  <div className="arch-file" key={r.id}>
                    <button type="button" className="arch-file-hd"
                      onClick={() => setOpenBh(openBh === r.id ? "" : r.id)}>
                      <Icon name="drill" size={15} />
                      <span className="mono arch-code">{r.id}</span>
                      <span className="arch-nums mono">
                        {r.layers.length} kihti · {(r.layers.length
                          ? r.layers[r.layers.length - 1].base : 0).toFixed(2)} m
                        {r.samples.length ? " · " + r.samples.length + " proovi" : ""}
                        {r.water != null ? " · vesi " + r.water.toFixed(2) + " m"
                          + (r.waterDate ? " (" + r.waterDate + ")" : "") : ""}
                      </span>
                      <span className="arch-fname">
                        {r.east != null
                          ? "N " + r.north.toFixed(0) + "  E " + r.east.toFixed(0)
                            + (r.coordFrom ? "  (" + r.coordFrom + ")" : "")
                            + (r.offset != null ? "  ·  nihe plaanist " + r.offset.toFixed(1) + " m" : "")
                          : "koordinaadid puuduvad"}
                      </span>
                      <Icon name="chevron" size={15} />
                    </button>
                    {openBh === r.id && (
                      <div className="arch-detail">
                        <table className="tbl arch-tbl">
                          <thead>
                            <tr><th>Sügavus</th><th>Paksus</th><th>Materjal</th><th>Proovid</th></tr>
                          </thead>
                          <tbody>
                            {r.layers.map((l, i) => {
                              /* a sample belongs to the layer its interval falls
                                 in - the same depth join the profile plot and
                                 the lab view use */
                              const here = r.samples.filter((sm) => {
                                if (sm.top == null) return false;
                                const mid = sm.base != null && sm.base > sm.top
                                  ? (sm.top + sm.base) / 2 : sm.top;
                                return mid >= l.top - 0.001 && mid <= l.base + 0.001;
                              });
                              return (
                                <tr key={i}>
                                  <td className="mono">{l.top.toFixed(2)}–{l.base.toFixed(2)}</td>
                                  <td className="mono">{l.cm.toFixed(0)} cm</td>
                                  <td>{l.name}</td>
                                  <td className="mono">
                                    {here.map((sm) => "pr " + (sm.ref || "?")).join(", ") || "—"}
                                  </td>
                                </tr>
                              );
                            })}
                          </tbody>
                        </table>
                        {r.samples.length > 0 && (
                          <table className="tbl arch-tbl" style={{ marginTop: 10 }}>
                            <thead>
                              <tr><th>Proov</th><th>Intervall</th><th>Välimäärang</th><th>ISO</th></tr>
                            </thead>
                            <tbody>
                              {r.samples.map((sm, i) => (
                                <tr key={i}>
                                  <td className="mono"><b>pr {sm.ref || "?"}</b></td>
                                  <td className="mono">
                                    {sm.top == null ? "—"
                                      : sm.top.toFixed(2) + (sm.base != null ? "–" + sm.base.toFixed(2) : "")}
                                  </td>
                                  <td>{sm.field || "—"}</td>
                                  <td className="mono">{sm.iso || "—"}</td>
                                </tr>
                              ))}
                            </tbody>
                          </table>
                        )}
                      </div>
                    )}
                  </div>
                ))}
              </div>
            </Panel>
          )}
        </React.Fragment>
      )}
    </div>
  );
}

Object.assign(window, { ArchiveScreen });
