// Parser for Artisan .alog roast logs, ported from
// /Users/shane/dev/hope_roaster/sidecar/alog_parser.py (parse_alog + alog_to_target_curve).
// .alog files are usually JSON, but the ones actually produced on this machine are Python
// dict-literal reprs (single-quoted strings, True/False/None) — JSON.parse fails on all 14
// files under ref/roasts/, confirmed empirically. pyLiteralToJson() below tokenizes and
// converts before parsing. Do not replace this with a plain regex substitution — that
// corrupts any apostrophe inside a quoted string (e.g. a roast title).

const TIMEINDEX_LABELS = ["CHARGE", "DRY_END", "FCs", "FCe", "SCs", "SCe", "DROP", "COOL_END"];
const TIMEINDEX_TO_MILESTONE_KEY = { CHARGE: "charge", DRY_END: "yellow", FCs: "fc", DROP: "drop" };
const MAX_CURVE_POINTS = 200;
const TP_SEARCH_WINDOW_S = 150; // turning point = min BT within this many seconds after charge

function fToC(f) {
  return ((f - 32) * 5) / 9;
}

/** Converts a Python dict-literal string to a JSON string. Tokenizer-based, not regex-based —
 * a naive global replace of `'` -> `"` corrupts apostrophes inside quoted string values. */
export function pyLiteralToJson(text) {
  let out = "";
  let i = 0;
  const n = text.length;

  while (i < n) {
    const ch = text[i];

    if (ch === "'" || ch === '"') {
      const quote = ch;
      let raw = "";
      i++;
      while (i < n && text[i] !== quote) {
        if (text[i] === "\\" && i + 1 < n) {
          const next = text[i + 1];
          const map = { n: "\n", t: "\t", r: "\r", "\\": "\\", "'": "'", '"': '"' };
          raw += map[next] !== undefined ? map[next] : next;
          i += 2;
        } else {
          raw += text[i];
          i++;
        }
      }
      i++; // consume closing quote
      out += JSON.stringify(raw);
      continue;
    }

    if (ch === "-" && /^(inf|Infinity)\b/.test(text.slice(i + 1))) {
      const m = text.slice(i + 1).match(/^(inf|Infinity)/);
      out += "null";
      i += 1 + m[0].length;
      continue;
    }

    if (/[0-9]/.test(ch) || (ch === "-" && /[0-9]/.test(text[i + 1] ?? ""))) {
      const m = text.slice(i).match(/^-?\d+(\.\d+)?([eE][+-]?\d+)?/);
      out += m[0];
      i += m[0].length;
      continue;
    }

    if (/[A-Za-z_]/.test(ch)) {
      const m = text.slice(i).match(/^[A-Za-z_][A-Za-z0-9_]*/);
      const word = m[0];
      if (word === "True") out += "true";
      else if (word === "False") out += "false";
      else if (word === "None") out += "null";
      else if (/^(nan|NaN|inf|Infinity)$/.test(word)) out += "null";
      else out += JSON.stringify(word); // shouldn't occur in well-formed .alog data
      i += word.length;
      continue;
    }

    out += ch;
    i++;
  }
  return out;
}

export function parseAlogRaw(content) {
  try {
    return JSON.parse(content);
  } catch {
    return JSON.parse(pyLiteralToJson(content));
  }
}

/** Serializes a JS value as a Python dict/list literal — the format Artisan itself writes
 * and re-reads with ast.literal_eval. JSON is NOT safe for re-import (true/false/null are
 * not Python literals), so the supplemental "updated" .alog must go out in this form. */
export function jsToPyLiteral(value) {
  if (value === null || value === undefined) return "None";
  if (value === true) return "True";
  if (value === false) return "False";
  if (typeof value === "number") return Number.isFinite(value) ? String(value) : "None";
  if (typeof value === "string") {
    // Python string literal with double quotes; escape backslashes, quotes, newlines.
    return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")}"`;
  }
  if (Array.isArray(value)) return `[${value.map(jsToPyLiteral).join(", ")}]`;
  if (typeof value === "object")
    return `{${Object.entries(value)
      .map(([k, v]) => `${jsToPyLiteral(String(k))}: ${jsToPyLiteral(v)}`)
      .join(", ")}}`;
  return "None";
}

/** The supplemental .alog: the original file re-serialized with `updates` merged in (only
 * keys whose value is not undefined are touched — everything else is preserved verbatim
 * from the parse). Returns a Python-literal string Artisan can import. */
export function buildUpdatedAlog(originalContent, updates) {
  const data = parseAlogRaw(originalContent);
  for (const [key, value] of Object.entries(updates)) {
    if (value !== undefined) data[key] = value;
  }
  return jsToPyLiteral(data);
}

/**
 * @param {string} content raw file text
 * @param {string} filename for the title fallback
 */
export function parseAlog(content, filename) {
  const data = parseAlogRaw(content);
  const warnings = [];

  const mode = String(data.mode ?? "C").toUpperCase();
  const toC = mode === "F" ? fToC : (x) => x;

  const title = data.title || filename.replace(/\.alog$/i, "");
  const roastDate = data.roastdate ?? "";
  const roasterType = data.roastertype ?? "Unknown";
  const weight = Array.isArray(data.weight) ? data.weight : [0, 0];
  const weightInG = Number(weight[0]) || 0;
  const weightOutG = Number(weight[1]) || 0;

  const timex = Array.isArray(data.timex) ? data.timex : [];
  const temp1 = Array.isArray(data.temp1) ? data.temp1 : []; // ET
  const temp2 = Array.isArray(data.temp2) ? data.temp2 : []; // BT
  const timeindex = Array.isArray(data.timeindex) ? data.timeindex : [];

  const n = Math.min(timex.length, temp1.length, temp2.length);

  let chargeOffsetS = 0;
  if (timeindex.length > 0) {
    const chargeIdx = Number(timeindex[0]);
    if (chargeIdx > 0 && chargeIdx < timex.length) chargeOffsetS = Number(timex[chargeIdx]);
  }

  const telemetry = [];
  for (let i = 0; i < n; i++) {
    const et = temp1[i];
    const bt = temp2[i];
    if (bt === null || bt === -1 || et === null || et === -1) continue;
    telemetry.push({ timeS: Number(timex[i]) - chargeOffsetS, bt: toC(Number(bt)), et: toC(Number(et)) });
  }

  const milestones = [];
  for (let pos = 0; pos < TIMEINDEX_LABELS.length && pos < timeindex.length; pos++) {
    const label = TIMEINDEX_LABELS[pos];
    const idx = Number(timeindex[pos]);
    if (idx <= 0 || idx >= timex.length) continue;
    const key = TIMEINDEX_TO_MILESTONE_KEY[label];
    if (!key) continue; // FCe/SCs/SCe not used by this worksheet
    const btAt = idx < temp2.length && temp2[idx] !== -1 && temp2[idx] !== null ? toC(Number(temp2[idx])) : null;
    milestones.push({ key, label: readableLabel(key), timeS: Number(timex[idx]) - chargeOffsetS, tempC: btAt });
  }
  if (!milestones.some((m) => m.key === "yellow")) warnings.push("No DRY_END (yellow) marked in this log.");
  if (!milestones.some((m) => m.key === "charge")) warnings.push("No CHARGE marked in this log — times are relative to the recording start, not charge.");

  // Turning point isn't in timeindex — the worksheet defines it as the lowest BT reading
  // shortly after charge.
  let turningPoint = null;
  const windowPoints = telemetry.filter((p) => p.timeS >= 0 && p.timeS <= TP_SEARCH_WINDOW_S);
  if (windowPoints.length > 0) {
    const min = windowPoints.reduce((a, b) => (b.bt < a.bt ? b : a));
    turningPoint = { timeS: min.timeS, tempC: min.bt };
  }

  const curve = downsampleCurve(telemetry, milestones, MAX_CURVE_POINTS);

  const chargeM = milestones.find((m) => m.key === "charge");
  const yellowM = milestones.find((m) => m.key === "yellow");
  const fcM = milestones.find((m) => m.key === "fc");
  const dropM = milestones.find((m) => m.key === "drop");

  let derived = null;
  if (fcM && dropM) {
    const firstCrackS = fcM.timeS;
    const developmentS = dropM.timeS - fcM.timeS;
    const dropS = dropM.timeS;
    derived = {
      firstCrackS,
      developmentS,
      dropS,
      dryingSharePct: yellowM ? round1((yellowM.timeS / dropS) * 100) : null,
      maillardSharePct: yellowM ? round1(((firstCrackS - yellowM.timeS) / dropS) * 100) : null,
      dtrPct: round1((developmentS / dropS) * 100),
    };
  } else {
    warnings.push("First crack and/or drop not marked — cannot compute development/DTR for this log.");
  }

  return {
    roast: {
      title,
      roastDate,
      roasterType,
      weightInG,
      weightOutG,
      weightLossPct: weightInG > 0 && weightOutG > 0 ? round1(((weightInG - weightOutG) / weightInG) * 100) : null,
      tempUnitInFile: mode,
    },
    milestones,
    turningPoint,
    curve,
    derived,
    warnings,
  };
}

function readableLabel(key) {
  return { charge: "Charge", yellow: "Yellow", fc: "First crack", drop: "Drop" }[key] ?? key;
}

function round1(n) {
  return n === null || n === undefined ? null : Math.round(n * 10) / 10;
}

/** Downsample to <= maxPoints, always keeping milestone-adjacent samples. */
function downsampleCurve(telemetry, milestones, maxPoints) {
  if (telemetry.length <= maxPoints) return telemetry.map((p) => ({ t: p.timeS, bt: round1(p.bt) }));
  const milestoneTimes = new Set(milestones.map((m) => Math.round(m.timeS)));
  const step = telemetry.length / maxPoints;
  const out = [];
  const seen = new Set();
  for (let i = 0; i < telemetry.length; i += 1) {
    const keepByStride = Math.floor(i / step) !== Math.floor((i - 1) / step);
    const isMilestone = milestoneTimes.has(Math.round(telemetry[i].timeS));
    if ((keepByStride || isMilestone) && !seen.has(i)) {
      seen.add(i);
      out.push({ t: telemetry[i].timeS, bt: round1(telemetry[i].bt) });
    }
  }
  return out;
}
