// <layout-foundry-quantities>: whole packs, rolls, tins, boards, panels or bags on a product page, for tiles,
// flooring, paving, wallpaper, paint, decking, fencing, gravel and aggregates, and turf.
// The shop sets the product facts as attributes; the shopper enters sizes; the element shows the answer with
// waste, rounding and assumptions beside a diagram, and fires `lf-quantity` with the whole number to buy.
// Words follow the element's `lang` attribute, else the page's <html lang>, else English
// (src/i18n/quantities/); the worker writes the result in the same language.

import { parseQuantitiesConfig, QUANTITIES_ATTRIBUTES, type QuantitiesConfig } from "./config";
import { fromInputValue, toInputValue } from "./format";
import { loadLanguage, SUPPORTED_LANGUAGES } from "./i18n/quantities/index";
import type { QuantitiesUiKey } from "./i18n/quantities/ui-en";
import { chooseLanguage } from "./i18n/translate";
import type { QuantityInputs, QuantityResult } from "./quantities-core";
import { ENGLISH_TEXT, QuantitiesText } from "./quantities-ui-text";
import { applyStyles, el, quantitiesStyles, template } from "./styles";
import { resolveWorkerUrl, startWorker, WorkerClient } from "./worker-client";
import type { QuantityRequest, QuantityResponse } from "./worker-protocol";

export const WORKER_FILE = "lf-quantities.worker.js";
const MAX_WALLS = 4;
// The event names of src/payload.ts. The element does not import that file: the result view builds the
// lf-quantity payload (src/quantities-payload.ts), and a file both used would become a chunk the element
// loads at start.
const EVENT_QUANTITY = "lf-quantity";
const EVENT_ERROR = "lf-error";

// The result view (answer, working, diagram, the lf-quantity payload) is its own file, loaded when the
// shopper starts on the form (src/quantities-view.ts). If it cannot load, the answer sentence shows in the
// status line instead and no lf-quantity event is sent; the next result asks for the file again (a browser
// may keep the failure until the page is reloaded).
type ResultView = typeof import("./quantities-view");
let resultView: Promise<ResultView> | null = null;
const loadResultView = () =>
  (resultView ??= import("./quantities-view").catch((error: unknown) => {
    resultView = null;
    throw error;
  }));

// One string with no spaces between the tags (the build joins the lines), so the file stays small.
const markup =
  '<section class="lf" part="container" aria-labelledby="lf-title" lang="en">' +
  '<h2 id="lf-title" part="heading"></h2>' +
  '<p class="muted" data-intro></p>' +
  '<p class="warning" role="alert" data-config-error hidden></p>' +
  '<form class="stack" data-form novalidate>' +
  '<div class="row" data-fields></div>' +
  '<div class="row" data-wall-buttons hidden>' +
  '<button type="button" class="secondary" data-add-wall data-t="button.addWall">Add a wall</button>' +
  '<button type="button" class="secondary" data-remove-wall data-t="button.removeWall">Remove the last wall</button>' +
  "</div>" +
  "</form>" +
  '<p role="status" aria-live="polite" data-status class="muted"></p>' +
  '<div class="result" data-result hidden>' +
  "<figure>" +
  "<div data-diagram></div>" +
  '<figcaption class="muted small" data-caption></figcaption>' +
  "</figure>" +
  '<div class="stack">' +
  '<p class="answer" data-answer></p>' +
  '<ul class="stack" data-details></ul>' +
  "<details>" +
  '<summary data-t="assumptions">Assumptions</summary>' +
  '<ul class="small stack" data-assumptions></ul>' +
  "</details>" +
  '<div class="row" data-action-row hidden><button type="button" data-action part="button"></button></div>' +
  "</div>" +
  "</div>" +
  "</section>";

export class LayoutFoundryQuantities extends HTMLElement {
  static observedAttributes = [...QUANTITIES_ATTRIBUTES, "lang"];

  private root: ShadowRoot;
  private config: QuantitiesConfig = parseQuantitiesConfig({});
  private client: WorkerClient<QuantityRequest, QuantityResponse> | null = null;
  private result: QuantityResult | null = null;
  private view: ResultView | null = null;
  private walls = 1;
  private generation = 0;
  private timer: ReturnType<typeof setTimeout> | undefined;
  // The words on screen and in the result, in the shopper's language (English until another has loaded).
  private text: QuantitiesText = ENGLISH_TEXT;
  private languageWanted = ENGLISH_TEXT.lang;
  private language: Promise<void> = Promise.resolve();
  // Resolves once the worker has started and the shopper's language has loaded.
  whenReady: Promise<void> = Promise.resolve();

  constructor() {
    super();
    this.root = this.attachShadow({ mode: "open" });
    applyStyles(this.root, quantitiesStyles);
    this.root.append(template(markup));
    const form = this.q<HTMLFormElement>("[data-form]");
    // The result view and another language's result words load while the shopper types, so they are
    // there when the first answer is.
    form.addEventListener("focusin", () => {
      void loadResultView()
        .then((view) => view.coreWords(this.text.lang))
        .catch(() => undefined);
    });
    form.addEventListener("input", () => this.schedule());
    form.addEventListener("submit", (event) => {
      event.preventDefault();
      this.schedule(0);
    });
    this.q("[data-add-wall]").addEventListener("click", () => this.setWalls(this.walls + 1));
    this.q("[data-remove-wall]").addEventListener("click", () => this.setWalls(this.walls - 1));
    this.q("[data-action]").addEventListener("click", () => {
      if (this.result && this.view) this.emit(EVENT_QUANTITY, this.view.buildQuantityDetail(this.result, true));
    });
  }

  private q<T extends Element>(selector: string): T {
    return this.root.querySelector(selector) as T;
  }

  connectedCallback() {
    this.loadLanguage();
    this.applyConfig();
    // The status line is there from the first view, so the planner does not grow a line after it
    // appears (a layout shift on the shop's page).
    if (!this.result) this.idleStatus();
    if (this.client) return;
    try {
      this.client = new WorkerClient(startWorker(resolveWorkerUrl(WORKER_FILE, import.meta.url, this.config.workerSrc), "lf-quantities"));
      this.whenReady = this.client.ready.then(
        () => this.language.then(() => void this.emit("lf-ready", {})),
        (error: Error) => this.fail(error.message),
      );
    } catch {
      this.fail(this.text.t("status.cannotStart"));
    }
  }

  disconnectedCallback() {
    this.client?.terminate();
    this.client = null;
  }

  attributeChangedCallback(name: string) {
    if (!this.isConnected) return;
    if (name === "lang") return this.loadLanguage();
    this.applyConfig();
    this.schedule();
  }

  // The language: the element's own `lang` attribute (the shop's choice), else the page's <html lang>,
  // else English. Any language but English loads its words from a small file beside the element, during
  // page load. The planner stays hidden until they arrive, and shows English if the file cannot load.
  // While it waits, the element carries `data-lf-loading`, so a page can keep its space (the Shopify
  // theme block does).
  private loadLanguage() {
    const lang = chooseLanguage([this.getAttribute("lang"), document.documentElement.lang], SUPPORTED_LANGUAGES);
    if (lang === this.languageWanted) return;
    this.languageWanted = lang;
    if (lang === ENGLISH_TEXT.lang) {
      this.language = Promise.resolve();
      this.useText(ENGLISH_TEXT);
      return;
    }
    const section = this.q<HTMLElement>(".lf");
    section.hidden = true;
    this.toggleAttribute("data-lf-loading", true);
    this.language = loadLanguage(lang)
      .then(
        (translation) => {
          if (this.languageWanted === lang) this.useText(new QuantitiesText(lang, translation));
        },
        () => {
          if (this.languageWanted === lang) this.useText(ENGLISH_TEXT);
        },
      )
      .finally(() => {
        if (this.languageWanted !== lang) return;
        section.hidden = false;
        this.removeAttribute("data-lf-loading");
      });
  }

  private useText(text: QuantitiesText) {
    if (text === this.text) return;
    this.text = text;
    // The language of the words inside, for screen readers (it can differ from the page's).
    this.q<HTMLElement>(".lf").lang = text.lang;
    for (const node of this.root.querySelectorAll<HTMLElement>("[data-t]")) node.textContent = text.t(node.dataset.t as QuantitiesUiKey);
    this.renderConfigText();
    // A result or message on screen is worked out again in the new language. Without a result the
    // status line changes at once, so the planner shows in one language and does not shift.
    if (this.result) this.schedule(0);
    else if (this.q("[data-status]").textContent) {
      clearTimeout(this.timer);
      void this.calculate();
    }
  }

  private emit(type: string, detail: unknown) {
    this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }));
  }

  // `quiet` keeps the message for screen readers only, when the same words are already on screen.
  private setStatus(message: string, quiet = false) {
    const status = this.q<HTMLElement>("[data-status]");
    status.textContent = message;
    status.classList.toggle("visually-hidden", quiet);
  }

  // The status line before there is a result: what to do next, or nothing beside a settings error.
  private idleStatus() {
    this.setStatus(this.config.errors.length ? "" : this.text.t("status.enter"));
  }

  private fail(message: string) {
    const shown = this.text.shared(message);
    this.setStatus(shown);
    this.emit(EVENT_ERROR, { kind: "calculation", message: shown });
  }

  // Unit the shopper types room sizes in: metres, or feet for imperial shops.
  private get roomUnit(): "m" | "ft" {
    return this.config.units === "in" ? "ft" : "m";
  }

  private configErrors() {
    return this.config.errors.map((error) => this.text.shared(error)).join(" ");
  }

  private applyConfig() {
    const attrs: Record<string, string | null> = {};
    for (const name of QUANTITIES_ATTRIBUTES) attrs[name] = this.getAttribute(name);
    this.config = parseQuantitiesConfig(attrs);
    this.renderConfigText();
    if (this.config.errors.length) this.emit(EVENT_ERROR, { kind: "config", message: this.configErrors() });
  }

  // The words that depend on the settings: title, introduction, any error, the button and the fields.
  private renderConfigText() {
    // Keep what the shopper typed when the shop changes a setting or the language changes.
    const typed = this.typedValues();
    const c = this.config;
    const x = this.text;
    const u = c.units;
    // Wallpaper, paint, fencing, gravel and turf have a title of their own; tiles, flooring, paving and
    // decking sold in packs keep the pack title, and decking sold by the board says boards.
    const product = c.product;
    const title =
      product === "decking" ? (c.piecesPerPack ? "title.packs" : "title.boards") : product === "wallpaper" || NEW_PRODUCTS.has(product) ? `title.${product}` : "title.packs";
    this.q("#lf-title").textContent = x.t(title as QuantitiesUiKey);

    let intro = "";
    if (c.product === "wallpaper") {
      if (c.rollWidthMm && c.rollLengthMm) {
        const size = { width: x.length(c.rollWidthMm, u), length: x.room(c.rollLengthMm, u) };
        intro = c.patternRepeatMm
          ? x.t("intro.wallpaperRepeat", {
              ...size,
              repeat: x.length(c.patternRepeatMm, u),
              match: x.t(c.match === "half-drop" ? "intro.matchHalfDrop" : "intro.matchStraight"),
            })
          : x.t("intro.wallpaper", size);
      }
    } else if (NEW_PRODUCTS.has(c.product)) {
      intro = this.materialIntro();
    } else if (c.pieceSizeMm && c.piecesPerPack) {
      intro = x.t("intro.pieces", { pieces: x.t("n.piece", { count: c.piecesPerPack }), width: x.length(c.pieceSizeMm[0], u), length: x.length(c.pieceSizeMm[1], u) });
    } else if (c.coverageMm2) {
      intro = x.t("intro.coverage", { area: x.area(c.coverageMm2, u) });
    }
    if (intro && c.productName) intro = x.t("intro.named", { name: c.productName, intro });
    const sentence = intro ? `${intro.charAt(0).toUpperCase()}${intro.slice(1)} ` : "";
    this.q("[data-intro]").textContent = `${sentence}${x.t("intro.privacy")}`;

    const error = this.q<HTMLElement>("[data-config-error]");
    error.hidden = c.errors.length === 0;
    error.textContent = c.errors.length ? x.t("config.error", { errors: this.configErrors() }) : "";

    const action = this.q<HTMLButtonElement>("[data-action]");
    this.q<HTMLElement>("[data-action-row]").hidden = !c.actionLabel;
    action.textContent = this.actionLabel() ?? "";

    this.renderFields(typed);
  }

  // The shop's button label, as written; the usual English label in the planner's own words.
  private actionLabel(): string | null {
    const label = this.config.actionLabel;
    return label === DEFAULT_ACTION_LABEL ? this.text.t("action.default") : label;
  }

  // Intro line for paint, decking, fencing, gravel and turf. Empty while the product facts are incomplete.
  private materialIntro(): string {
    const c = this.config;
    const x = this.text;
    const u = c.units;
    switch (c.product) {
      case "paint": {
        if (!c.spreadRateMm2PerL || !c.tinSizesMl.length) return "";
        const tins = x.list(c.tinSizesMl.map((ml) => x.volume(ml, u)));
        return u === "in"
          ? x.t("intro.paintGallon", { area: x.area(Math.round(c.spreadRateMm2PerL * 3.785411784), u), tins })
          : x.t("intro.paintLitre", { area: x.area(c.spreadRateMm2PerL, u), tins });
      }
      case "decking": {
        if (!c.pieceSizeMm) return "";
        const size = { width: x.length(c.pieceSizeMm[0], u), length: x.room(c.pieceSizeMm[1], u) };
        return c.piecesPerPack ? x.t("intro.deckingPack", { ...size, boards: x.t("n.board", { count: c.piecesPerPack }) }) : x.t("intro.decking", size);
      }
      case "fencing":
        return c.panelWidthMm ? x.t("intro.fencing", { panel: x.length(c.panelWidthMm, u), post: x.length(c.postWidthMm, u) }) : "";
      case "gravel":
        return c.bagG && c.densityKgM3 ? x.t("intro.gravel", { mass: x.mass(c.bagG, u) }) : "";
      case "turf":
        if (c.rollWidthMm && c.rollLengthMm) return x.t("intro.turfSize", { width: x.length(c.rollWidthMm, u), length: x.room(c.rollLengthMm, u) });
        return c.coverageMm2 ? x.t("intro.turfCoverage", { area: x.area(c.coverageMm2, u) }) : "";
      default:
        return "";
    }
  }

  // Values the shopper has typed, by field name. A ticked box is "on"; an unticked one is "".
  private typedValues() {
    return new Map(
      [...this.root.querySelectorAll<HTMLInputElement>("[data-fields] input")].map((i) => [i.name, i.type === "checkbox" ? (i.checked ? "on" : "") : i.value]),
    );
  }

  private check(name: string, label: string, checked: boolean) {
    const wrap = el("label", undefined, "check");
    const input = el("input");
    input.type = "checkbox";
    input.name = name;
    input.checked = checked;
    wrap.append(input, el("span", label));
    return wrap;
  }

  private field(name: string, label: string, value: string, step: string, max: string) {
    const wrap = el("label", label);
    const input = el("input");
    input.type = "number";
    input.name = name;
    input.inputMode = "decimal";
    input.min = "0";
    input.step = step;
    input.max = max;
    input.value = value;
    wrap.append(input);
    return wrap;
  }

  private renderFields(typed: Map<string, string>) {
    const x = this.text;
    const unit = this.roomUnit;
    const fields: HTMLElement[] = [];
    const keep = (name: string, fallback = "") => typed.get(name) ?? fallback;
    const product = this.config.product;
    const allowance = (key: QuantitiesUiKey) => this.field("allowance", x.t(key), keep("allowance", String(this.config.allowancePercent)), "1", "100");
    if (product === "wallpaper" || product === "paint") {
      fields.push(this.field("height", x.t("field.wallHeight", { unit }), keep("height"), "0.01", "20"));
      for (let i = 1; i <= this.walls; i++) fields.push(this.field(`wall${i}`, x.t("field.wallWidth", { number: String(i), unit }), keep(`wall${i}`), "0.01", "100"));
      if (product === "paint") {
        fields.push(
          this.field("doors", x.t("field.doors"), keep("doors", "0"), "1", "50"),
          this.field("windows", x.t("field.windows"), keep("windows", "0"), "1", "50"),
          this.field("coats", x.t("field.coats"), keep("coats", String(this.config.coats)), "1", "10"),
        );
      }
    } else if (product === "fencing") {
      fields.push(
        this.field("run", x.t("field.run", { unit }), keep("run"), "0.01", "1000"),
        this.check("gravel-boards", x.t("field.gravelBoards"), keep("gravel-boards") === "on"),
      );
    } else {
      // Width, length, then the depth for gravel, then the allowance.
      const [noun, extra] = AREA_FIELDS[product] ?? ["floor", "field.extraBreakages"];
      fields.push(
        this.field("width", x.t(`field.${noun}Width`, { unit }), keep("width"), "0.01", "100"),
        this.field("length", x.t(`field.${noun}Length`, { unit }), keep("length"), "0.01", "100"),
      );
      if (product === "gravel") {
        const depthUnit = this.config.units === "in" ? "in" : "cm";
        fields.push(this.field("depth", x.t("field.depth", { unit: depthUnit }), keep("depth", toInputValue(this.config.depthMm, depthUnit)), "0.1", "100"));
      }
      fields.push(allowance(extra));
    }
    this.q("[data-fields]").replaceChildren(...fields);
    const buttons = this.q<HTMLElement>("[data-wall-buttons]");
    buttons.hidden = product !== "wallpaper" && product !== "paint";
    this.q<HTMLButtonElement>("[data-add-wall]").disabled = this.walls >= MAX_WALLS;
    this.q<HTMLButtonElement>("[data-remove-wall]").disabled = this.walls <= 1;
  }

  private setWalls(count: number) {
    this.walls = Math.min(MAX_WALLS, Math.max(1, count));
    this.renderFields(this.typedValues());
    this.root.querySelector<HTMLInputElement>(`input[name="wall${this.walls}"]`)?.focus();
    this.schedule();
  }

  private value(name: string) {
    return this.root.querySelector<HTMLInputElement>(`input[name="${name}"]`)?.value ?? "";
  }

  // A whole number typed in a count field; empty is `fallback`. Null when it is not a whole number.
  private count(name: string, fallback: number): number | null {
    const text = this.value(name).trim();
    if (text === "") return fallback;
    const n = Number(text);
    return Number.isInteger(n) && n >= 0 ? n : null;
  }

  // Reads the form. Returns null while a needed field is empty, so no answer shows for half-typed input.
  private inputs(): QuantityInputs | null {
    const unit = this.roomUnit;
    const product = this.config.product;
    if (product === "wallpaper" || product === "paint") {
      const wallHeightMm = fromInputValue(this.value("height"), unit);
      const wallWidthsMm = Array.from({ length: this.walls }, (_, i) => fromInputValue(this.value(`wall${i + 1}`), unit));
      if (wallHeightMm === null || wallWidthsMm.some((w) => w === null)) return null;
      if (product === "wallpaper") return { wallHeightMm, wallWidthsMm: wallWidthsMm as number[] };
      const doors = this.count("doors", 0);
      const windows = this.count("windows", 0);
      const coats = this.count("coats", this.config.coats);
      if (doors === null || windows === null || coats === null || coats < 1) return null;
      return { wallHeightMm, wallWidthsMm: wallWidthsMm as number[], doors, windows, coats };
    }
    if (product === "fencing") {
      const runLengthMm = fromInputValue(this.value("run"), unit);
      if (runLengthMm === null) return null;
      return { runLengthMm, gravelBoards: Boolean(this.root.querySelector<HTMLInputElement>('input[name="gravel-boards"]')?.checked) };
    }
    const roomWidthMm = fromInputValue(this.value("width"), unit);
    const roomLengthMm = fromInputValue(this.value("length"), unit);
    const allowanceText = this.value("allowance");
    const allowancePercent = allowanceText.trim() === "" ? 0 : Number(allowanceText);
    if (roomWidthMm === null || roomLengthMm === null) return null;
    if (product === "gravel") {
      const depthMm = fromInputValue(this.value("depth"), this.config.units === "in" ? "in" : "cm");
      if (depthMm === null) return null;
      return { roomWidthMm, roomLengthMm, allowancePercent, depthMm };
    }
    return { roomWidthMm, roomLengthMm, allowancePercent };
  }

  private schedule(delay = 200) {
    clearTimeout(this.timer);
    this.timer = setTimeout(() => void this.calculate(), delay);
  }

  private async calculate() {
    const generation = ++this.generation;
    const inputs = this.inputs();
    if (!inputs || !this.client || this.config.errors.length) {
      this.result = null;
      this.q<HTMLElement>("[data-result]").hidden = true;
      this.idleStatus();
      return;
    }
    try {
      await this.whenReady;
      // Another language sends its result words with the request (the result view loads them); English is
      // built into the worker. Without the result view (its file did not load), the answer sentence still
      // shows.
      const text = this.text;
      const viewLoad = loadResultView().catch(() => null);
      const messages = text.lang === "en" ? null : await (await viewLoad)?.coreWords(text.lang);
      const words = messages ? { lang: text.lang, messages } : {};
      const request = this.client.request<Extract<QuantityResponse, { type: "quantity" }>>({ type: "quantity", config: this.config, inputs, ...words });
      const [reply, view] = await Promise.all([request, viewLoad]);
      if (generation !== this.generation) return;
      this.result = reply.result;
      this.view = view;
      view?.renderResult(this.root, reply.result, this.text, this.config.units === "in", this.actionLabel());
      this.setStatus(reply.result.summary, view !== null);
      if (view) this.emit(EVENT_QUANTITY, view.buildQuantityDetail(reply.result, false));
    } catch (error) {
      if (generation !== this.generation) return;
      this.result = null;
      this.q<HTMLElement>("[data-result]").hidden = true;
      this.fail((error as Error).message);
    }
  }
}

const NEW_PRODUCTS = new Set<QuantitiesConfig["product"]>(["paint", "decking", "fencing", "gravel", "turf"]);
// The products measured as an area: the word in their width and length labels (field.<noun>Width) and
// their allowance label. Flooring is the default.
const AREA_FIELDS: Partial<Record<QuantitiesConfig["product"], ["deck" | "area" | "lawn" | "surface" | "floor", QuantitiesUiKey]>> = {
  decking: ["deck", "field.extraCuts"],
  gravel: ["area", "field.extraSpreading"],
  turf: ["lawn", "field.extraEdges"],
  paving: ["area", "field.extraBreakages"],
  tiles: ["surface", "field.extraBreakages"],
};
const DEFAULT_ACTION_LABEL = ENGLISH_TEXT.t("action.default");

if (!customElements.get("layout-foundry-quantities")) customElements.define("layout-foundry-quantities", LayoutFoundryQuantities);
