import { slugify } from "..";
import "../prototype-overrides";
import { fillAnswerInput, readAnswer } from "./question-io";

const QUESTIONS_PARENT_SELECTOR = "table.BodyText tbody";

export type TQuestionType = "textinput" | "textarea" | "radio" | "checkbox" | "scan-input";

export type TAnswerMethod = "input" | "scan-input" | "radio" | "checkbox";

export interface QuestionRow {
  element: HTMLElement;
  qType: TQuestionType;
  infoGroup: number;
}

export interface QuestionEntry {
  originalText: string;
  questions: QuestionRow[];
}

// infoText is extracted once at parse time so _isRowInSection doesn't re-query and re-read
// innerText from the DOM on every lookup.
interface InfoRow {
  element: HTMLElement;
  infoText: string;
}

export class Survey {
  private static _instance: Survey | null = null;

  private readonly _questionsBySlug = new Map<string, QuestionEntry>();
  private readonly _infoGroups: Record<number, InfoRow[]> = {};

  private constructor() {
    this._parse();
  }

  public static getInstance(): Survey {
    if (Survey._instance === null) {
      Survey._instance = new Survey();
      // Exposed for debugging in the browser console, eg. `survey._questionsBySlug`.
      (window as unknown as { survey: Survey }).survey = Survey._instance;
    }

    return Survey._instance;
  }

  public getAnswer(questionText: RegExp | string): string | null {
    const row = this._resolveRow("getAnswer", this._findQuestionRows(questionText), questionText);
    return readAnswer(row);
  }

  public getAnswerBySection(
    sectionName: RegExp | string,
    questionText: RegExp | string,
  ): string | null {
    const rowsInSection = this._findQuestionRows(questionText).filter((row) =>
      this._isRowInSection(row, sectionName),
    );
    const row = this._resolveRow("getAnswerBySection", rowsInSection, questionText, sectionName);
    return readAnswer(row);
  }

  // Scan-input questions render submitted items as rows in a nested `table.inventory-grid`;
  // callers use this to verify every scanned UPC actually landed in the grid.
  public getInventoryGridRowCount(questionText: RegExp | string): number | null {
    const row = this._resolveRow(
      "getInventoryGridRowCount",
      this._findQuestionRows(questionText),
      questionText,
    );
    const tbody = row.element.querySelector("table.inventory-grid tbody");
    return tbody === null ? null : tbody.children.length;
  }

  // clickAdd (scan-input only) indicates that the `Add Item` button should be clicked
  // after filling in the input box.
  public answer(
    method: TAnswerMethod,
    questionText: RegExp | string,
    value: string,
    kwargs?: Record<string, unknown>,
  ): void {
    const row = this._resolveRow("answer", this._findQuestionRows(questionText), questionText);
    fillAnswerInput(method, row, value, kwargs);
  }

  public answerBySection(
    method: TAnswerMethod,
    sectionName: RegExp | string,
    questionText: RegExp | string,
    value: string,
  ): void {
    const rowsInSection = this._findQuestionRows(questionText).filter((row) =>
      this._isRowInSection(row, sectionName),
    );
    const row = this._resolveRow("answerBySection", rowsInSection, questionText, sectionName);
    fillAnswerInput(method, row, value);
  }

  // Every public lookup must resolve to exactly one row; zero or multiple matches means the
  // questionText (optionally narrowed by sectionName) is not specific enough to act on safely.
  private _resolveRow(
    callerName: string,
    matchingRows: QuestionRow[],
    questionText: RegExp | string,
    sectionName?: RegExp | string,
  ): QuestionRow {
    const formattedQuestionText = questionText.toLogString();
    const sectionSuffix =
      sectionName === undefined ? "" : ` in section: '${sectionName.toLogString()}'`;

    if (matchingRows.length > 1) {
      throw new Error(
        `[Survey.${callerName}] Multiple question elements matched questionText: '${formattedQuestionText}'${sectionSuffix}`,
      );
    }
    if (matchingRows.length === 0) {
      throw new Error(
        `[Survey.${callerName}] No question element matched questionText: '${formattedQuestionText}'${sectionSuffix}`,
      );
    }

    return matchingRows[0];
  }

  // A string `questionText` matches by slug substring rather than exact equality: real question
  // wording drifts over time (added punctuation, appended clauses) and boilerplate presets
  // intentionally pass a truncated prefix, so an exact match would be too brittle.
  private _findQuestionRows(questionText: RegExp | string): QuestionRow[] {
    const matches: QuestionRow[] = [];

    for (const entry of this._questionsBySlug.values()) {
      const isMatch =
        typeof questionText === "string"
          ? slugify(entry.originalText).includes(slugify(questionText))
          : questionText.test(entry.originalText);

      if (isMatch) {
        matches.push(...entry.questions);
      }
    }

    return matches;
  }

  // `row.infoGroup` identifies the contiguous clump of InfoQuestion rows immediately above
  // it. The row belongs to `sectionName` if any InfoQuestion in that clump has matching
  // QuestionText.
  private _isRowInSection(row: QuestionRow, sectionName: RegExp | string): boolean {
    const infoRows = this._infoGroups[row.infoGroup];
    if (infoRows === undefined) {
      return false;
    }

    const sectionNameSlug = typeof sectionName === "string" ? slugify(sectionName) : null;

    return infoRows.some((infoRow) =>
      sectionNameSlug !== null
        ? slugify(infoRow.infoText) === sectionNameSlug
        : (sectionName as RegExp).test(infoRow.infoText),
    );
  }

  private _parse(): void {
    this._questionsBySlug.clear();

    const questionsParent = document.querySelector(QUESTIONS_PARENT_SELECTOR);
    if (questionsParent === null) {
      throw new Error(
        `Questions parent element not found for selector: '${QUESTIONS_PARENT_SELECTOR}'`,
      );
    }

    // Tracks the contiguous clump of InfoQuestion rows immediately preceding the row
    // currently being visited, so each real question can be stamped with the group number
    // of the closest InfoQuestion clump above it.
    let currentInfoGroup = -1;
    let isPrevRowInfoQuestion = false;

    for (const rowNode of questionsParent.children) {
      const rowElement = rowNode as HTMLElement;

      if (isInfoQuestion(rowElement)) {
        if (!isPrevRowInfoQuestion) {
          currentInfoGroup += 1;
          this._infoGroups[currentInfoGroup] = [];
        }

        const infoTextNode = rowElement.querySelector<HTMLElement>("[id$='QuestionText']");
        if (infoTextNode !== null) {
          this._infoGroups[currentInfoGroup].push({
            element: rowElement,
            infoText: infoTextNode.innerText.trim(),
          });
        }

        isPrevRowInfoQuestion = true;
        continue;
      }

      isPrevRowInfoQuestion = false;

      const questionTextNode = rowElement.querySelector<HTMLElement>("[id$='QuestionText']");
      if (questionTextNode === null) {
        continue;
      }

      const questionText = questionTextNode.innerText.trim();
      const questionSlug = slugify(questionText);
      const questionRow: QuestionRow = {
        element: rowElement,
        qType: getQuestionType(rowElement),
        infoGroup: currentInfoGroup,
      };
      const existingEntry = this._questionsBySlug.get(questionSlug);

      if (existingEntry === undefined) {
        this._questionsBySlug.set(questionSlug, {
          originalText: questionText,
          questions: [questionRow],
        });
      } else {
        existingEntry.questions.push(questionRow);
      }
    }
  }
}

// Heuristic: a `tr` is a real question (rather than plain informational/instructional text)
// only if its `WebQuestion` container has at least one actual answer-input descendant
// (text field, textarea, radio/checkbox, select, etc). Info rows render an empty
// `WebQuestion` span with nothing interactive inside.
function isInfoQuestion(rowElement: HTMLElement): boolean {
  const webQuestionNode = rowElement.querySelector("[id$='WebQuestion']");
  if (webQuestionNode === null) {
    return true;
  }

  const answerInputNode = webQuestionNode.querySelector("input, textarea, select");
  return answerInputNode === null;
}

/**
 * Assumes `rowElement` already passed the `isInfoQuestion` check (ie. has a `WebQuestion` node).
 */
function getQuestionType(rowElement: HTMLElement): TQuestionType {
  const webQuestionNode = rowElement.querySelector("[id$='WebQuestion']")!;

  if (webQuestionNode.querySelector("input[type='radio']") !== null) {
    return "radio";
  }
  if (webQuestionNode.querySelector("input[type='checkbox']") !== null) {
    return "checkbox";
  }
  // `scan-input` (barcode/UPC scan grid, eg. `BoxScan`/`OutOfStock`) uses a plain
  // `input[type=text]` under the hood, so it must be checked before `textinput`.
  if (webQuestionNode.querySelector("input.item-search") !== null) {
    return "scan-input";
  }
  if (webQuestionNode.querySelector("textarea") !== null) {
    return "textarea";
  }

  return "textinput";
}
