import type { StepTechStepView } from "@batch-cooking/shared";

/**
 * One run of a step's `description` — either plain text, or part of a
 * detected technique (`techStep` set). A technique's own text is itself
 * split into up to three runs (see {@link splitDescriptionByTechSteps}):
 * the tight keyword span (`isKeyword: true`, e.g. "préchauffer") and, when
 * `StepTechStepView.contextStart`/`contextEnd` are present, the wider
 * surrounding clause around it (`isKeyword: false`, e.g. "Dans une poêle
 * chaude" around a keyword of "poêle chaude"). `StepDescription.tsx`
 * currently renders `isKeyword: false` segments as plain text (no visual
 * distinction from a segment with no technique at all) — the context split
 * still happens here so the data stays available, but its own dedicated
 * highlight was turned back off; see that component's doc comment.
 */
export interface DescriptionSegment {
  text: string;
  techStep: StepTechStepView["techStep"] | null;
  /** Always `false` when `techStep` is `null`. */
  isKeyword: boolean;
  /** Mirrors the source `StepTechStepView.source` this segment came from — `null` when `techStep` is `null` (nothing to attribute a source to). See `StepDescription.tsx` for how `"auto"` vs `"manual"` render differently. */
  source: StepTechStepView["source"] | null;
}

/**
 * Splits `description` into an ordered sequence of plain/context/keyword
 * {@link DescriptionSegment}s using each `techSteps` entry's `start`/`end`
 * (the keyword) and, when present, `contextStart`/`contextEnd` (the wider
 * clause it was found in — see `StepTechStepView`, resolved server-side by
 * `tech-step-matcher.ts`'s `matchTechStepSpans`). An entry with no context
 * (older data, saved before that column pair existed, or a manual
 * correction — see `StepTechStep`'s schema doc comment) degrades to a
 * keyword-only segment, same as before context spans existed at all.
 *
 * `techSteps` is expected already sorted by `start` (the API returns it in
 * `StepTechStep.order`, which *is* reading order — see that model's schema
 * doc comment) but this re-sorts defensively by each entry's own tight
 * `start` rather than assuming it, and silently drops any entry whose own
 * bounds don't make sense against `description` or a previously-accepted
 * entry's own tight keyword span — a malformed/out-of-date span degrades to
 * "just don't highlight that one" rather than a garbled slice or a crash.
 *
 * Two entries' tight keyword spans are never allowed to overlap (the later
 * one is dropped, same as always), but two entries' wider *context* clauses
 * are allowed to overlap each other and are silently clipped to make room —
 * context is cosmetic only (`StepDescription.tsx` renders it identically to
 * plain text) and must never cost a *different* entry its own real keyword
 * highlight. This matters far more than it looks: a clause `splitIntoClauses`
 * (`tech-step-matcher.ts`) found only one NER candidate in gets that
 * candidate's context spanning the *entire* clause — often the entire
 * description — so without clipping, a single auto-detected match anywhere
 * in a step could silently swallow every manual correction added anywhere
 * else in that same step's description, with no error, just an unstyled
 * word in the rendered text. Found via live testing: a "simmer" match's
 * whole-description context ate a manual "setAside" correction added to a
 * later, otherwise-plain word in the same step.
 */
export function splitDescriptionByTechSteps(
  description: string,
  techSteps: StepTechStepView[],
): DescriptionSegment[] {
  const sorted = [...techSteps].sort((a, b) => a.start - b.start);

  // First pass: decide which entries survive at all, using only each
  // entry's own tight keyword span for the cross-entry overlap check
  // (`start < keywordCursor`) — the one thing two independent matches are
  // never allowed to genuinely share. An entry's own context is still
  // validated against its *own* keyword span here (`wideStart > start`,
  // `end > wideEnd`, `wideEnd > description.length`) — a self-inconsistent
  // span is dropped regardless of any other entry.
  const valid: StepTechStepView[] = [];
  let keywordCursor = 0;
  for (const entry of sorted) {
    const { start, end, contextStart, contextEnd } = entry;
    const wideStart = contextStart ?? start;
    const wideEnd = contextEnd ?? end;
    if (
      start < keywordCursor ||
      wideStart > start ||
      start >= end ||
      end > wideEnd ||
      wideEnd > description.length
    ) {
      continue;
    }
    valid.push(entry);
    keywordCursor = end;
  }

  const segments: DescriptionSegment[] = [];
  let cursor = 0;
  for (const [index, entry] of valid.entries()) {
    const { techStep, start, end, contextStart, contextEnd, source } = entry;
    const next = valid[index + 1];
    // Clipped against `cursor` (this entry can't render context over
    // territory already emitted) and the next surviving entry's own tight
    // `start` (this entry's context can't reach into a neighbor's real
    // keyword span) — provably within `[cursor, start]`/`[end, next.start]`
    // respectively given `valid`'s own non-overlapping-tight-span
    // invariant from the first pass, so never produces a negative-length
    // slice.
    const wideStart = Math.max(contextStart ?? start, cursor);
    const wideEnd = Math.min(contextEnd ?? end, next?.start ?? description.length);

    if (wideStart > cursor) {
      segments.push({
        text: description.slice(cursor, wideStart),
        techStep: null,
        isKeyword: false,
        source: null,
      });
    }
    if (start > wideStart) {
      segments.push({
        text: description.slice(wideStart, start),
        techStep,
        isKeyword: false,
        source,
      });
    }
    segments.push({ text: description.slice(start, end), techStep, isKeyword: true, source });
    if (wideEnd > end) {
      segments.push({
        text: description.slice(end, wideEnd),
        techStep,
        isKeyword: false,
        source,
      });
    }
    cursor = wideEnd;
  }
  if (cursor < description.length) {
    segments.push({
      text: description.slice(cursor),
      techStep: null,
      isKeyword: false,
      source: null,
    });
  }
  return segments;
}
