Ctrl K
ComunidadGuías…

ics-interview.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
/**
 * ics-interview.mjs — read an interview invite's .ics into an
 * active-interviews.md row (#5743).
 *
 * Interview invites very often carry an .ics attachment, and that file is the
 * one place holding the exact start, end, time zone and link in machine-readable
 * form. invite-match.mjs reads the invite TEXT; nothing read the .ics itself,
 * so the candidate copied the date, zone, link and length by hand.
 *
 * For the FIRST VEVENT this prints (JSON, or --summary):
 *   - start / end as stated (original TZID kept) and the same instant in the
 *     display zone, plus durationMinutes (from DTEND or DURATION)
 *   - summary, location, a meeting URL found in LOCATION / DESCRIPTION / URL
 *   - organizer and attendees (display names only, never addresses)
 *   - a ready-to-paste `| Company | Role | Round | Date/Time | Interviewer |
 *     Status | Notes |` row for data/active-interviews.md, with every field
 *     that could not be read left as an em dash rather than guessed
 *
 * Read-only and zero-LLM: it never writes data/active-interviews.md (the user
 * pastes the row), and the iCalendar subset it needs is parsed by hand, so
 * there is no new dependency.
 *
 * Time semantics, reported rather than assumed:
 *   - `...Z`                     UTC instant
 *   - `;TZID=Area/City`          wall time in that zone, converted to an instant
 *   - no zone and no `Z`         floating: a wall time with NO zone; not converted
 *   - `;VALUE=DATE`              all-day: reported as a date, never as a time
 *   - a TZID that is not an IANA zone (e.g. Outlook's "Eastern Standard Time")
 *     is converted with the file's own VTIMEZONE block; without a readable
 *     one it is reported as `unknown-tzid` and not converted
 *   - RRULE is ignored (only the first occurrence is read; a warning says so)
 *   - a cancellation (METHOD:CANCEL or STATUS:CANCELLED) gets no row
 *
 * Display zone: --tz, else config/profile.yml `location.timezone` (when it is
 * a valid IANA name), else this machine's zone.
 *
 * Usage:
 *   node ics-interview.mjs invite.ics            # JSON
 *   node ics-interview.mjs invite.ics --summary  # human-readable
 *   node ics-interview.mjs < invite.ics          # read stdin
 *   node ics-interview.mjs invite.ics --tz America/Toronto
 *   node ics-interview.mjs invite.ics --match "<invite email text>"
 */

import { readFileSync, existsSync } from 'fs';
import { loadYaml } from './lib/yaml-document.mjs';
import { getProfilePath } from './path-resolver.mjs';
import { flagValue, validateFlags } from './lib/cli-flags.mjs';
import { isMainModule } from './lib/is-main-module.mjs';

const DASH = '—';

// ---------------------------------------------------------------------------
// iCalendar parsing
// ---------------------------------------------------------------------------

/** RFC 5545 line unfolding: a CRLF (or LF) followed by one space/tab continues the line. */
export function unfoldLines(text) {
  const raw = String(text ?? '').replace(/^\uFEFF/, '').split(/\r\n|\n|\r/);
  const out = [];
  for (const line of raw) {
    if ((line.startsWith(' ') || line.startsWith('\t')) && out.length > 0) {
      out[out.length - 1] += line.slice(1);
    } else {
      out.push(line);
    }
  }
  return out.filter((l) => l.length > 0);
}

/**
 * Split one unfolded content line into { name, params, value }. Parameter
 * values may be double-quoted and then may contain ':' / ';' / ','.
 */
export function parseContentLine(line) {
  let i = 0;
  const n = line.length;
  const readUntil = (stops) => {
    let s = '';
    let quoted = false;
    while (i < n) {
      const c = line[i];
      if (c === '"') { quoted = !quoted; s += c; i += 1; continue; }
      if (!quoted && stops.includes(c)) break;
      s += c;
      i += 1;
    }
    return s;
  };
  const name = readUntil([':', ';']).toUpperCase();
  const params = {};
  while (i < n && line[i] === ';') {
    i += 1;
    const key = readUntil(['=', ':', ';']).toUpperCase();
    let val = '';
    if (line[i] === '=') {
      i += 1;
      val = readUntil([':', ';']);
    }
    params[key] = val.replace(/^"(.*)"$/, '$1');
  }
  if (line[i] !== ':') return null;
  return { name, params, value: line.slice(i + 1) };
}

/** iCalendar TEXT unescaping: \n \N newline, \, \; \\ literals. */
export function unescapeText(value) {
  return String(value ?? '').replace(/\\([nN,;\\])/g, (_, c) => (c === 'n' || c === 'N' ? '\n' : c));
}

/** Parse an iCalendar DURATION (PT45M, PT1H30M, P1D, P1W, -PT15M) into minutes, or null. */
export function parseDurationMinutes(value) {
  const m = /^([+-])?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/i.exec(String(value ?? '').trim());
  if (!m || value.trim().length <= 1) return null;
  const [, sign, w, d, h, mi, s] = m;
  const total = (Number(w || 0) * 7 + Number(d || 0)) * 1440 + Number(h || 0) * 60 + Number(mi || 0) + Number(s || 0) / 60;
  return (sign === '-' ? -1 : 1) * Math.round(total);
}

/**
 * Parse a DTSTART / DTEND value. Returns null when the value is unreadable.
 * kind: 'date' (all-day) | 'utc' | 'zoned' | 'floating'
 */
export function parseIcsDate(value, params = {}) {
  const m = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})?(Z)?)?$/i.exec(String(value ?? '').trim());
  if (!m) return null;
  const [, y, mo, d, h, mi, s, z] = m;
  // Date.UTC remaps years 0-99 to 1900-1999, and an interview in those years is
  // not a real invite, so a year before 1000 is treated as unreadable.
  if (+y < 1000) return null;
  const base = { y: +y, mo: +mo, d: +d, h: +(h ?? 0), mi: +(mi ?? 0), s: +(s ?? 0), raw: value.trim() };
  if (mo === '00' || +mo > 12 || +d < 1 || base.h > 23 || base.mi > 59 || base.s > 60) return null;
  // The day must exist in that month (Feb 30 would otherwise roll into March).
  if (+d > new Date(Date.UTC(+y, +mo, 0)).getUTCDate()) return null;
  const isDateOnly = h === undefined || String(params.VALUE ?? '').toUpperCase() === 'DATE';
  if (isDateOnly) return { ...base, kind: 'date', tzid: null };
  if (z) return { ...base, kind: 'utc', tzid: null };
  if (params.TZID) return { ...base, kind: 'zoned', tzid: params.TZID };
  return { ...base, kind: 'floating', tzid: null };
}

/**
 * Parse the FIRST VEVENT of an .ics body, plus the calendar's METHOD.
 * @returns {{ props: Array<{name:string, params:object, value:string}>, method: string|null } | null}
 */
export function firstEvent(text) {
  const lines = unfoldLines(text);
  let depth = 0;
  let inEvent = false;
  let method = null;
  const props = [];
  for (const line of lines) {
    const p = parseContentLine(line);
    if (!p) continue;
    if (p.name === 'BEGIN') {
      if (!inEvent && p.value.toUpperCase() === 'VCALENDAR') { method = null; continue; }
      if (!inEvent && p.value.toUpperCase() === 'VEVENT') { inEvent = true; depth = 0; continue; }
      if (inEvent) depth += 1;
      continue;
    }
    if (p.name === 'END') {
      if (inEvent && depth === 0 && p.value.toUpperCase() === 'VEVENT') return { props, method };
      if (inEvent) depth -= 1;
      continue;
    }
    if (!inEvent && p.name === 'METHOD') method = p.value.trim().toUpperCase();
    // Properties of nested components (e.g. VALARM) are not the event's.
    if (inEvent && depth === 0) props.push(p);
  }
  // A truncated file that never closes the VEVENT still yields what it had.
  return inEvent ? { props, method } : null;
}

// ---------------------------------------------------------------------------
// Time zones (Intl, else the file's own VTIMEZONE)
// ---------------------------------------------------------------------------

const WEEKDAYS = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];

function parseUtcOffset(value) {
  const m = /^([+-])(\d{2})(\d{2})(\d{2})?$/.exec(String(value ?? '').trim());
  if (!m) return null;
  // Hours stop at 23, minutes at 59, and seconds at 60. A minute of 60 would
  // still convert and hide a broken zone.
  const hour = Number(m[2]);
  const minute = Number(m[3]);
  const second = Number(m[4] ?? 0);
  if (hour > 23 || minute > 59 || second > 60) return null;
  const sign = m[1] === '-' ? -1 : 1;
  return sign * (hour * 60 + minute + second / 60);
}

// Seconds make the minute count fractional. Round so the instant lands on a whole millisecond.
function offsetMinutesToMs(minutes) {
  return Math.round(minutes * 60000);
}

/**
 * One STANDARD or DAYLIGHT block. Only the yearly BYMONTH + BYDAY rule that
 * Outlook, Exchange and most generators write is read; anything else is null.
 */
function parseObservance(props) {
  const get = (name) => props.find((p) => p.name === name)?.value;
  const start = parseIcsDate(get('DTSTART'));
  const from = parseUtcOffset(get('TZOFFSETFROM'));
  const to = parseUtcOffset(get('TZOFFSETTO'));
  if (!start || start.kind !== 'floating' || from === null || to === null) return null;
  // RDATE lists transitions this parser does not read. Treating the block as
  // one onset at DTSTART would apply the wrong offset and still look converted.
  if (props.some((p) => p.name === 'RDATE')) return null;
  const rrule = get('RRULE');
  if (!rrule) return { start, from, to, rule: null };
  const parts = Object.fromEntries(rrule.split(';').map((kv) => kv.split('=').map((x) => x.trim().toUpperCase())));
  const allowed = new Set(['FREQ', 'INTERVAL', 'BYMONTH', 'BYDAY', 'UNTIL', 'WKST']);
  if (parts.FREQ !== 'YEARLY' || (parts.INTERVAL ?? '1') !== '1' || Object.keys(parts).some((k) => !allowed.has(k))) return null;
  const day = /^([+-]?[1-5])(SU|MO|TU|WE|TH|FR|SA)$/.exec(parts.BYDAY ?? '');
  const month = Number(parts.BYMONTH);
  if (!day || !Number.isInteger(month) || month < 1 || month > 12) return null;
  const until = parts.UNTIL ? parseIcsDate(parts.UNTIL) : null;
  if (parts.UNTIL && !until) return null;
  return { start, from, to, rule: { month, nth: Number(day[1]), weekday: WEEKDAYS.indexOf(day[2]), until } };
}

/** Every VTIMEZONE in the body, keyed by TZID. A zone with any unreadable block is left out. */
export function parseVtimezones(text) {
  const zones = new Map();
  let tzid = null;
  let blocks = null;
  let block = null;
  for (const line of unfoldLines(text)) {
    const p = parseContentLine(line);
    if (!p) continue;
    const value = p.value.trim().toUpperCase();
    if (p.name === 'BEGIN' && value === 'VTIMEZONE') { tzid = null; blocks = []; continue; }
    if (!blocks) continue;
    if (p.name === 'BEGIN' && (value === 'STANDARD' || value === 'DAYLIGHT')) { block = []; continue; }
    if (p.name === 'END' && block && (value === 'STANDARD' || value === 'DAYLIGHT')) { blocks.push(parseObservance(block)); block = null; continue; }
    if (p.name === 'END' && value === 'VTIMEZONE') {
      if (tzid && blocks.length > 0 && blocks.every(Boolean)) zones.set(tzid, blocks);
      blocks = null;
      continue;
    }
    if (block) block.push(p);
    else if (p.name === 'TZID') tzid = p.value.trim();
  }
  return zones;
}

/** Wall-clock ms (the wall time read as if it were UTC) when an observance starts in `year`, or null. */
function onsetIn(obs, year) {
  const { start, rule } = obs;
  const at = (y, mo, d) => Date.UTC(y, mo - 1, d, start.h, start.mi, start.s);
  const first = at(start.y, start.mo, start.d);
  if (!rule) return first;
  let day;
  if (rule.nth > 0) {
    const firstWeekday = new Date(Date.UTC(year, rule.month - 1, 1)).getUTCDay();
    day = 1 + ((rule.weekday - firstWeekday + 7) % 7) + (rule.nth - 1) * 7;
  } else {
    const last = new Date(Date.UTC(year, rule.month, 0)).getUTCDate();
    const lastWeekday = new Date(Date.UTC(year, rule.month - 1, last)).getUTCDay();
    day = last - ((lastWeekday - rule.weekday + 7) % 7) + (rule.nth + 1) * 7;
  }
  if (day < 1 || day > new Date(Date.UTC(year, rule.month, 0)).getUTCDate()) return null;
  const onset = at(year, rule.month, day);
  if (onset < first) return null;
  if (rule.until) {
    const u = rule.until;
    const untilMs = Date.UTC(u.y, u.mo - 1, u.d, u.h, u.mi, u.s) + (u.kind === 'utc' ? offsetMinutesToMs(obs.from) : 0);
    if (onset > untilMs) return null;
  }
  return onset;
}

/**
 * Wall-clock time -> epoch ms through a VTIMEZONE, with the same overlap and
 * gap rules as wallToInstant. Null when no observance covers the time.
 */
export function vtimezoneToInstant(d, observances) {
  const wallMs = Date.UTC(d.y, d.mo - 1, d.d, d.h, d.mi, d.s);
  let best = null;
  for (const obs of observances) {
    // A yearly rule can fire its last onset many years before the event.
    // Walk back to the year it started. The first onset that already applies
    // is the latest one, so an earlier year cannot replace it.
    const firstYear = obs.rule ? obs.start.y : d.y;
    for (let year = d.y; year >= firstYear; year -= 1) {
      const onset = onsetIn(obs, year);
      if (onset === null || onset > wallMs) continue;
      if (!best || onset > best.onset) best = { onset, obs };
      break;
    }
  }
  if (!best) return null;
  const { onset, obs } = best;
  // Inside a spring-forward gap the pre-gap offset applies (RFC 5545 3.3.5).
  const fromMs = offsetMinutesToMs(obs.from);
  const toMs = offsetMinutesToMs(obs.to);
  const appliedMs = wallMs < onset + (toMs - fromMs) ? fromMs : toMs;
  return wallMs - appliedMs;
}

export function isValidZone(zone) {
  if (typeof zone !== 'string' || !zone.trim()) return false;
  try {
    new Intl.DateTimeFormat('en-US', { timeZone: zone });
    return true;
  } catch {
    return false;
  }
}

export function machineZone() {
  return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
}

function zoneParts(ms, zone) {
  const fmt = new Intl.DateTimeFormat('en-US', {
    timeZone: zone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit',
    hour: '2-digit', minute: '2-digit', second: '2-digit', timeZoneName: 'short',
  });
  const o = {};
  for (const p of fmt.formatToParts(new Date(ms))) o[p.type] = p.value;
  return o;
}

function offsetMs(ms, zone) {
  const o = zoneParts(ms, zone);
  const asUtc = Date.UTC(+o.year, +o.month - 1, +o.day, +o.hour % 24, +o.minute, +o.second);
  return asUtc - Math.floor(ms / 1000) * 1000;
}

/** Wall-clock time -> epoch ms, using RFC 5545's overlap and gap rules. */
export function wallToInstant(d, zone) {
  const guess = Date.UTC(d.y, d.mo - 1, d.d, d.h, d.mi, d.s);
  // Sample both sides of a transition instead of letting the UTC guess choose
  // an arbitrary side. Offsets need not differ by a whole hour.
  const offsets = new Set([-86400000, 0, 86400000].map(delta => offsetMs(guess + delta, zone)));
  const candidates = [...offsets].map(offset => guess - offset);
  const matches = candidates.filter(ms => ms + offsetMs(ms, zone) === guess);
  // An overlap means two matches: use the first occurrence. A gap has none:
  // applying the pre-gap offset gives the later instant (RFC 5545 3.3.5).
  return matches.length ? Math.min(...matches) : Math.max(...candidates);
}

/** Instant formatted in a zone: { date: 'YYYY-MM-DD', time: 'HH:MM', abbr, zone }. */
export function formatInZone(ms, zone) {
  const o = zoneParts(ms, zone);
  return { date: `${o.year}-${o.month}-${o.day}`, time: `${o.hour}:${o.minute}`, abbr: o.timeZoneName, zone };
}

const pad = (n) => String(n).padStart(2, '0');
const wallString = (d) => `${d.y}-${pad(d.mo)}-${pad(d.d)}T${pad(d.h)}:${pad(d.mi)}:${pad(d.s)}`;

/**
 * Describe one parsed DTSTART/DTEND: the time as stated, plus its instant when
 * one can be established without assuming anything.
 */
function describeTime(t, warnings, label, vtimezones = new Map()) {
  if (!t) return null;
  const out = { raw: t.raw, kind: t.kind, tzid: t.tzid, local: t.kind === 'date' ? `${t.y}-${pad(t.mo)}-${pad(t.d)}` : wallString(t), utc: null, epochMs: null };
  if (t.kind === 'utc') {
    out.epochMs = Date.UTC(t.y, t.mo - 1, t.d, t.h, t.mi, t.s);
  } else if (t.kind === 'zoned') {
    const viaVtimezone = isValidZone(t.tzid) ? null : vtimezones.get(t.tzid);
    const fromVtimezone = viaVtimezone ? vtimezoneToInstant(t, viaVtimezone) : null;
    if (isValidZone(t.tzid)) {
      out.epochMs = wallToInstant(t, t.tzid);
    } else if (fromVtimezone !== null) {
      out.epochMs = fromVtimezone;
    } else {
      out.kind = 'unknown-tzid';
      warnings.push({ code: 'unknown-tzid', message: `${label} TZID "${t.tzid}" is not an IANA time zone this tool can read; the time is shown as written and not converted.` });
    }
  } else if (t.kind === 'floating') {
    warnings.push({ code: 'floating-time', message: `${label} has no time zone (floating); it is shown as written and not converted.` });
  }
  if (out.epochMs !== null) out.utc = new Date(out.epochMs).toISOString().replace('.000Z', 'Z');
  return out;
}

// ---------------------------------------------------------------------------
// Event extraction
// ---------------------------------------------------------------------------

const URL_RE = /https?:\/\/[^\s<>"'\\)\]]+/gi;
const MEETING_HOST_RE = /(zoom\.us|zoomgov\.com|teams\.microsoft\.com|teams\.live\.com|meet\.google\.com|webex\.com|gotomeeting\.com|goto\.com|whereby\.com|bluejeans\.com|chime\.aws|hirevue\.com|calendly\.com)/i;

/** First meeting-looking URL across the given texts, else the first URL at all, else null. */
export function findMeetingUrl(...texts) {
  const urls = [];
  for (const t of texts) {
    for (const m of String(t ?? '').matchAll(URL_RE)) urls.push(m[0].replace(/[.,;:!?]+$/, ''));
  }
  return urls.find((u) => MEETING_HOST_RE.test(u)) ?? urls[0] ?? null;
}

function personName(prop) {
  const cn = prop?.params?.CN;
  const name = cn ? unescapeText(cn).trim() : '';
  // Names only: a CN that is itself an address (or a mailto:) is never emitted.
  if (!name || /^mailto:/i.test(name) || /\S+@\S+/.test(name)) return null;
  return name;
}

/**
 * Parse an .ics body.
 * @param {string} text
 * @param {{displayZone?: string}} [opts]
 * @returns {{event: object|null, warnings: Array<{code:string,message:string}>}}
 */
export function parseIcs(text, { displayZone } = {}) {
  const warnings = [];
  const ev = firstEvent(text);
  if (!ev) {
    warnings.push({ code: 'no-vevent', message: 'No VEVENT found in the input.' });
    return { event: null, warnings };
  }
  const get = (name) => ev.props.find((p) => p.name === name);
  const text_ = (name) => { const p = get(name); return p ? unescapeText(p.value).trim() || null : null; };

  // A cancellation carries the same start and end as the invite it cancels.
  const cancelled = ev.method === 'CANCEL' || text_('STATUS')?.toUpperCase() === 'CANCELLED';
  if (cancelled) warnings.push({ code: 'cancelled', message: 'This .ics cancels the event. There is no row to paste; update the existing row in data/active-interviews.md instead.' });
  if (get('RRULE')) warnings.push({ code: 'rrule-ignored', message: 'RRULE present and ignored; only the first occurrence is read.' });

  const vtimezones = parseVtimezones(text);
  const dtstartProp = get('DTSTART');
  const start = describeTime(dtstartProp ? parseIcsDate(dtstartProp.value, dtstartProp.params) : null, warnings, 'DTSTART', vtimezones);
  if (!start) warnings.push({ code: 'no-start', message: dtstartProp ? `DTSTART "${dtstartProp.value}" is not readable.` : 'No DTSTART.' });

  const dtendProp = get('DTEND');
  const durProp = get('DURATION');
  let end = describeTime(dtendProp ? parseIcsDate(dtendProp.value, dtendProp.params) : null, warnings, 'DTEND', vtimezones);
  if (dtendProp && !end) warnings.push({ code: 'bad-end', message: `DTEND "${dtendProp.value}" is not readable.` });

  let durationMinutes = null;
  if (start && end) {
    if (start.kind === 'date' || end.kind === 'date') {
      if (start.kind === 'date' && end.kind === 'date') {
        durationMinutes = Math.round((Date.UTC(+end.local.slice(0, 4), +end.local.slice(5, 7) - 1, +end.local.slice(8, 10))
          - Date.UTC(+start.local.slice(0, 4), +start.local.slice(5, 7) - 1, +start.local.slice(8, 10))) / 60000);
      }
    } else if (start.epochMs !== null && end.epochMs !== null) {
      durationMinutes = Math.round((end.epochMs - start.epochMs) / 60000);
    } else if (start.kind === end.kind && start.tzid === end.tzid && start.kind !== 'zoned') {
      // Same floating clock on both ends: the difference is meaningful without a zone.
      durationMinutes = Math.round((Date.parse(`${end.local}Z`) - Date.parse(`${start.local}Z`)) / 60000);
    } else if (start.tzid && start.tzid === end.tzid) {
      // Same unreadable TZID on both ends: wall-clock difference.
      durationMinutes = Math.round((Date.parse(`${end.local}Z`) - Date.parse(`${start.local}Z`)) / 60000);
    }
  } else if (start && !end && durProp) {
    durationMinutes = parseDurationMinutes(durProp.value);
    if (durationMinutes === null) warnings.push({ code: 'bad-duration', message: `DURATION "${durProp.value}" is not readable.` });
    else if (start.kind !== 'date' && start.epochMs !== null) {
      const epochMs = start.epochMs + durationMinutes * 60000;
      end = { raw: null, kind: 'derived', tzid: start.tzid, local: null, utc: new Date(epochMs).toISOString().replace('.000Z', 'Z'), epochMs };
    }
  } else if (start && !end) {
    warnings.push({ code: 'no-end', message: 'No DTEND or DURATION; the length is not stated in the .ics.' });
  }
  if (durationMinutes !== null && durationMinutes < 0) {
    warnings.push({ code: 'negative-duration', message: 'End is before start; length left unstated.' });
    durationMinutes = null;
  }

  const allDay = start?.kind === 'date';
  const zone = displayZone ?? machineZone();
  let display = null;
  if (start && start.epochMs !== null && !allDay) {
    display = { zone, start: formatInZone(start.epochMs, zone), end: end && end.epochMs !== null ? formatInZone(end.epochMs, zone) : null };
  }

  const organizerProp = get('ORGANIZER');
  const attendees = ev.props.filter((p) => p.name === 'ATTENDEE').map(personName).filter(Boolean);
  const organizer = personName(organizerProp);

  const strip = (t) => (t ? { raw: t.raw, kind: t.kind, tzid: t.tzid, local: t.local, utc: t.utc } : null);
  return {
    event: {
      summary: text_('SUMMARY'),
      location: text_('LOCATION'),
      meetingUrl: findMeetingUrl(text_('LOCATION'), text_('DESCRIPTION'), text_('URL')),
      organizer,
      attendees,
      allDay,
      cancelled,
      start: strip(start),
      end: strip(end),
      durationMinutes: allDay ? null : durationMinutes,
      display,
    },
    warnings,
  };
}

// ---------------------------------------------------------------------------
// active-interviews.md row
// ---------------------------------------------------------------------------

const cell = (v) => {
  const s = String(v ?? '').replace(/\s+/g, ' ').replace(/\|/g, '/').trim();
  return s || DASH;
};

/** Date/Time cell text for the row (never invents a time the .ics did not carry). */
export function dateTimeCell(event) {
  if (!event?.start) return DASH;
  const s = event.start;
  if (event.allDay) return `${s.local} (all-day, no time stated)`;
  if (s.kind === 'floating') return `${s.local.slice(0, 10)} ${s.local.slice(11, 16)} (no time zone)`;
  if (s.kind === 'unknown-tzid') return `${s.local.slice(0, 10)} ${s.local.slice(11, 16)} (unknown TZID ${s.tzid})`;
  const d = event.display;
  if (!d) return DASH;
  let out = `${d.start.date} ${d.start.time}`;
  if (d.end) out += d.end.date === d.start.date ? `-${d.end.time}` : ` - ${d.end.date} ${d.end.time}`;
  return `${out} ${d.start.abbr}`;
}

/**
 * Build the pasteable row. `tracker` (optional) is a matched tracker candidate
 * supplying Company and Role; otherwise those stay em dashes.
 */
export function buildRow(event, tracker = null) {
  if (!event || event.cancelled) return null;
  const notes = [];
  if (event.durationMinutes !== null && event.durationMinutes !== undefined) notes.push(`${event.durationMinutes} min`);
  if (event.start?.tzid && event.start.kind !== 'unknown-tzid') notes.push(`invite zone ${event.start.tzid}`);
  if (event.meetingUrl) notes.push(event.meetingUrl);
  const cells = [
    cell(tracker?.company), cell(tracker?.role), DASH, cell(dateTimeCell(event)),
    cell(event.organizer), 'Scheduled', cell(notes.join('; ')),
  ];
  return `| ${cells.join(' | ')} |`;
}

// ---------------------------------------------------------------------------
// Display zone
// ---------------------------------------------------------------------------

/** --tz > profile location.timezone (valid IANA only) > machine zone. */
export function resolveDisplayZone(tzFlag, warnings = []) {
  if (tzFlag !== undefined) {
    if (isValidZone(tzFlag)) return { zone: tzFlag, source: '--tz' };
    warnings.push({ code: 'display-zone-invalid', message: `--tz "${tzFlag}" is not an IANA time zone; using the next fallback.` });
  }
  try {
    const path = getProfilePath();
    if (path && existsSync(path)) {
      const tz = loadYaml(readFileSync(path, 'utf-8'))?.location?.timezone;
      if (typeof tz === 'string' && tz.trim()) {
        if (isValidZone(tz.trim())) return { zone: tz.trim(), source: 'profile' };
        warnings.push({ code: 'display-zone-invalid', message: `config/profile.yml location.timezone "${tz}" is not a time zone name Intl knows (e.g. America/Toronto); using the machine zone.` });
      }
    }
  } catch (e) {
    warnings.push({ code: 'profile-unreadable', message: `config/profile.yml could not be read (${e.message}); using the machine zone.` });
  }
  return { zone: machineZone(), source: 'machine' };
}

// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------

const KNOWN_FLAGS = ['--summary', '--tz', '--match', '--help', '-h'];
const VALUE_FLAGS = ['--tz', '--match'];
const USAGE = `Usage:
  node ics-interview.mjs <invite.ics>                 # JSON: first VEVENT + a row for data/active-interviews.md
  node ics-interview.mjs < invite.ics                 # read the .ics from stdin
  node ics-interview.mjs <invite.ics> --summary       # human-readable
  node ics-interview.mjs <invite.ics> --tz <IANA>     # display zone (default: profile location.timezone, then machine zone)
  node ics-interview.mjs <invite.ics> --match "<invite text>"   # also suggest the tracker row (text, or a path to a file holding it)
  node ics-interview.mjs --help

Read-only: prints a row to paste; it never writes data/active-interviews.md.`;

function positionalOperand(args) {
  for (let i = 0; i < args.length; i += 1) {
    const a = args[i];
    if (VALUE_FLAGS.includes(a)) { i += 1; continue; }
    if (!a.startsWith('-')) return a;
  }
  return null;
}

async function matchTracker(matchArg, event) {
  let text = matchArg;
  try {
    if (existsSync(matchArg)) text = readFileSync(matchArg, 'utf-8');
  } catch { /* treat the argument as the invite text itself */ }
  // Imported on demand: invite-match.mjs reads process.argv at module load.
  const { analyzeInvite } = await import('./invite-match.mjs');
  const result = analyzeInvite(text);
  return {
    classification: result.classification,
    company: result.signals.company,
    candidates: result.candidates.slice(0, 3),
    lengthMinutes: event?.durationMinutes ?? null,
    lengthSource: event?.durationMinutes != null ? '.ics' : null,
  };
}

function printSummary(r) {
  const e = r.event;
  console.log(`\n${'='.repeat(70)}\n  Interview .ics — career-ops\n${'='.repeat(70)}\n`);
  if (!e) {
    console.log('  No event could be read.');
  } else {
    const line = (k, v) => console.log(`  ${k.padEnd(22)}${v ?? DASH}`);
    line('Summary:', e.summary);
    line('Starts (as sent):', e.start ? `${e.start.local}${e.start.utc ? ' = ' + e.start.utc : ''}${e.start.tzid ? ' [' + e.start.tzid + ']' : ''} (${e.start.kind})` : null);
    line('Ends (as sent):', e.end ? `${e.end.local ?? DASH}${e.end.utc ? ' = ' + e.end.utc : ''}` : null);
    line('Length:', e.durationMinutes != null ? `${e.durationMinutes} min` : (e.allDay ? 'all-day' : 'not stated'));
    line(`In ${r.displayZone.zone}:`, e.display ? `${e.display.start.date} ${e.display.start.time} ${e.display.start.abbr}${e.display.end ? ' - ' + e.display.end.time : ''}` : null);
    line('Meeting link:', e.meetingUrl);
    line('Location:', e.location);
    line('Organizer:', e.organizer);
    line('Attendees:', e.attendees.length ? e.attendees.join(', ') : null);
  }
  for (const w of r.warnings) console.log(`  ! ${w.code}: ${w.message}`);
  if (r.match) {
    console.log(`\n  Tracker match (${r.match.classification}): ${r.match.company ?? '(no company found)'}`);
    for (const c of r.match.candidates) console.log(`    #${c.appNumber} ${c.company} - ${c.role} [${c.status}]`);
    if (r.match.lengthMinutes != null) console.log(`  Length from .ics: ${r.match.lengthMinutes} min`);
  }
  if (r.row) console.log(`\n  Row for data/active-interviews.md:\n  ${r.row}`);
  console.log('');
}

async function main() {
  const args = process.argv.slice(2);
  validateFlags(args, KNOWN_FLAGS, USAGE, { valueFlags: VALUE_FLAGS, requireOperand: true });

  const operand = positionalOperand(args);
  let body;
  if (operand) {
    try {
      body = readFileSync(operand, 'utf-8');
    } catch (e) {
      console.error(`ics-interview: cannot read ${operand}: ${e.code === 'ENOENT' ? 'file not found' : e.message}`);
      process.exit(1);
    }
  } else {
    try {
      body = readFileSync(0, 'utf-8');
    } catch (e) {
      console.error(`ics-interview: no .ics file given and stdin could not be read (${e.message}). ${USAGE.split('\n')[1].trim()}`);
      process.exit(1);
    }
  }

  const warnings = [];
  const displayZone = resolveDisplayZone(flagValue(args, '--tz'), warnings);
  const parsed = parseIcs(body, { displayZone: displayZone.zone });
  warnings.push(...parsed.warnings);

  let match = null;
  const matchArg = flagValue(args, '--match');
  if (matchArg !== undefined) {
    try {
      match = await matchTracker(matchArg, parsed.event);
    } catch (e) {
      warnings.push({ code: 'match-failed', message: `invite-match could not run (${e.message}).` });
    }
  }
  const top = match?.candidates?.[0] ?? null;
  const row = buildRow(parsed.event, top && top.nameScore === 1 && match.candidates.length === 1 ? top : null);

  const result = { event: parsed.event, displayZone, row, match, warnings };
  if (args.includes('--summary')) printSummary(result);
  else console.log(JSON.stringify(result, null, 2));
  process.exit(parsed.event ? 0 : 1);
}

if (isMainModule(import.meta.url)) {
  await main();
}