export const STATE_SEQUENCE_SCHEMA_VERSION = 1;

const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);

function detailEvent(type, detail) {
  if (typeof CustomEvent === "function") {
    return new CustomEvent(type, { detail });
  }
  const event = new Event(type);
  Object.defineProperty(event, "detail", { value: detail });
  return event;
}

function abortError(source) {
  if (source?.reason instanceof Error) return source.reason;
  const reason = source?.reason ?? source;
  const message = reason ? String(reason) : "The state sequence was aborted.";
  if (typeof DOMException === "function") {
    return new DOMException(message, "AbortError");
  }
  const error = new Error(message);
  error.name = "AbortError";
  return error;
}

function stateId(sprite) {
  return typeof sprite === "string" ? sprite : sprite?.id || String(sprite);
}

function normalizeTransition(value, path) {
  if (value === undefined || value === false || typeof value === "string") {
    return value;
  }
  if (!value || typeof value !== "object" || Array.isArray(value)) {
    throw new TypeError(`${path} must be false, a transition name, or an options object.`);
  }
  return Object.freeze({ ...value });
}

function normalizeOptions(value, path) {
  if (value === undefined) return undefined;
  if (!value || typeof value !== "object" || Array.isArray(value)) {
    throw new TypeError(`${path} must be an options object.`);
  }
  return Object.freeze({ ...value });
}

function normalizeStep(input, index) {
  const source =
    typeof input === "string"
      ? { sprite: input }
      : input && typeof input === "object"
        ? input
        : null;
  const sprite = source?.sprite;
  if (
    !source ||
    (typeof sprite === "string"
      ? !sprite.trim()
      : !sprite || typeof sprite !== "object")
  ) {
    throw new TypeError(`State sequence step ${index} requires a sprite.`);
  }

  const rawHold = source.holdMs ?? source.durationMs ?? 0;
  const holdMs = Number(rawHold);
  if (!Number.isFinite(holdMs) || holdMs < 0) {
    throw new RangeError(`State sequence step ${index} holdMs must be a non-negative number.`);
  }

  const step = {
    sprite: source.sprite,
    holdMs,
  };
  if (hasOwn(source, "transition")) {
    step.transition = normalizeTransition(
      source.transition,
      `State sequence step ${index} transition`,
    );
  }
  if (hasOwn(source, "options")) {
    step.options = normalizeOptions(
      source.options,
      `State sequence step ${index} options`,
    );
  }
  if (hasOwn(source, "label")) step.label = String(source.label);
  if (hasOwn(source, "metadata")) {
    step.metadata = normalizeOptions(
      source.metadata,
      `State sequence step ${index} metadata`,
    );
  }
  return Object.freeze(step);
}

/**
 * Create a frozen named sequence definition. Definitions that use sprite IDs
 * and data-only metadata/options can be serialized directly as JSON.
 *
 * @example
 * const readyThenDone = defineStateSequence("ready-then-done", [
 *   { sprite: "ai.idle", holdMs: 5000 },
 *   { sprite: "status.success", transition: "path-draw" },
 * ]);
 */
export function defineStateSequence(nameOrDefinition, steps, options = {}) {
  const source =
    nameOrDefinition && typeof nameOrDefinition === "object" && !Array.isArray(nameOrDefinition)
      ? nameOrDefinition
      : { ...options, name: nameOrDefinition, steps };
  const name = String(source.name || "").trim();
  if (!name) throw new TypeError("A state sequence requires a non-empty name.");
  if (
    source.schemaVersion !== undefined &&
    Number(source.schemaVersion) !== STATE_SEQUENCE_SCHEMA_VERSION
  ) {
    throw new RangeError(
      `Unsupported state sequence schema version "${String(source.schemaVersion)}".`,
    );
  }
  if (!Array.isArray(source.steps) || source.steps.length === 0) {
    throw new TypeError(`State sequence "${name}" requires at least one step.`);
  }

  const sequence = {
    schemaVersion: STATE_SEQUENCE_SCHEMA_VERSION,
    name,
    steps: Object.freeze(source.steps.map(normalizeStep)),
    transitionFirst: source.transitionFirst === true,
  };
  if (hasOwn(source, "transition")) {
    sequence.transition = normalizeTransition(
      source.transition,
      `State sequence "${name}" transition`,
    );
  }
  if (hasOwn(source, "metadata")) {
    sequence.metadata = normalizeOptions(
      source.metadata,
      `State sequence "${name}" metadata`,
    );
  }
  return Object.freeze(sequence);
}

function createClock(clock) {
  if (
    clock &&
    (typeof clock.now !== "function" ||
      typeof clock.setTimeout !== "function" ||
      typeof clock.clearTimeout !== "function")
  ) {
    throw new TypeError(
      "A custom sequence clock requires now(), setTimeout(), and clearTimeout().",
    );
  }
  const now =
    clock
      ? () => clock.now()
      : () =>
          typeof performance !== "undefined" && typeof performance.now === "function"
            ? performance.now()
            : Date.now();
  const schedule =
    clock
      ? (callback, delay) => clock.setTimeout(callback, delay)
      : (callback, delay) => setTimeout(callback, delay);
  const cancel =
    clock
      ? (timer) => clock.clearTimeout(timer)
      : (timer) => clearTimeout(timer);
  return Object.freeze({ now, setTimeout: schedule, clearTimeout: cancel });
}

function transitionOptions(specification, preservePhase, stepOptions, signal) {
  const options = { preservePhase };
  if (typeof specification === "string") {
    options.transition = specification;
  } else if (specification && typeof specification === "object") {
    Object.assign(options, specification);
    if (!options.transition && typeof options.name === "string") {
      options.transition = options.name;
      delete options.name;
    }
  }
  Object.assign(options, stepOptions || {});
  options.signal = signal;
  return options;
}

/**
 * Plays serial state definitions against any target implementing setSprite().
 * transitionTo() is awaited when available; otherwise setSprite() is used.
 */
export class StateSequencePlayer extends EventTarget {
  constructor(target, options = {}) {
    super();
    if (!target || typeof target.setSprite !== "function") {
      throw new TypeError("StateSequencePlayer requires a setSprite-capable target.");
    }
    this.target = target;
    this.clock = createClock(options.clock);
    this.options = Object.freeze({
      transition: hasOwn(options, "transition")
        ? normalizeTransition(options.transition, "Default transition")
        : "field-morph",
      transitionFirst: options.transitionFirst === true,
      preservePhase: options.preservePhase !== false,
    });
    this.sequences = new Map();
    this.status = "idle";
    this.state = target.currentRecipe?.id || null;
    this.destroyed = false;
    this.finished = Promise.resolve(null);
    this._active = null;
    this._serial = 0;

    if (Array.isArray(options.sequences)) {
      for (const sequence of options.sequences) this.register(sequence);
    } else if (options.sequences && typeof options.sequences === "object") {
      for (const [name, sequence] of Object.entries(options.sequences)) {
        this.register(
          Array.isArray(sequence)
            ? defineStateSequence(name, sequence)
            : defineStateSequence({ ...sequence, name: sequence.name || name }),
        );
      }
    }
  }

  get currentSequence() {
    return this._active?.sequence || null;
  }

  get currentStepIndex() {
    return this._active?.index ?? -1;
  }

  get isPlaying() {
    return this.status === "running" || this.status === "paused";
  }

  get isPaused() {
    return this.status === "paused";
  }

  register(nameOrDefinition, steps, options) {
    if (this.destroyed) throw new Error("StateSequencePlayer has been destroyed.");
    const sequence = defineStateSequence(nameOrDefinition, steps, options);
    this.sequences.set(sequence.name, sequence);
    return sequence;
  }

  unregister(name) {
    return this.sequences.delete(String(name));
  }

  getSequence(name) {
    return this.sequences.get(String(name)) || null;
  }

  listSequences() {
    return Object.freeze([...this.sequences.values()]);
  }

  _resolveSequence(input, options) {
    if (typeof input === "string") {
      const sequence = this.getSequence(input);
      if (!sequence) throw new RangeError(`Unknown state sequence "${input}".`);
      return sequence;
    }
    if (Array.isArray(input)) {
      return defineStateSequence(options.name || `sequence-${this._serial + 1}`, input);
    }
    return defineStateSequence(input);
  }

  _normalizePlayOptions(options) {
    const normalized = {
      signal: options.signal,
      preservePhase: options.preservePhase ?? this.options.preservePhase,
      transitionFirst: options.transitionFirst,
    };
    if (hasOwn(options, "transition")) {
      normalized.transition = normalizeTransition(options.transition, "Play transition");
    }
    return normalized;
  }

  play(sequenceOrName, options = {}) {
    if (this.destroyed) {
      return Promise.reject(new Error("StateSequencePlayer has been destroyed."));
    }

    let sequence;
    let playOptions;
    try {
      sequence = this._resolveSequence(sequenceOrName, options);
      playOptions = this._normalizePlayOptions(options);
      if (playOptions.signal?.aborted) throw abortError(playOptions.signal);
    } catch (error) {
      return Promise.reject(error);
    }

    if (this._active) this._stopRun(this._active, "superseded");

    const run = {
      id: ++this._serial,
      sequence,
      options: playOptions,
      controller: new AbortController(),
      index: -1,
      completedSteps: 0,
      paused: false,
      delay: null,
      gates: new Set(),
      terminal: null,
      removeExternalAbort: () => {},
    };
    this._active = run;
    this.status = "running";

    if (playOptions.signal) {
      const onAbort = () => this._cancelRun(run, playOptions.signal);
      playOptions.signal.addEventListener("abort", onAbort, { once: true });
      run.removeExternalAbort = () =>
        playOptions.signal.removeEventListener("abort", onAbort);
      if (playOptions.signal.aborted) this._cancelRun(run, playOptions.signal);
    }

    this._emit("sequencestart", run, { totalSteps: sequence.steps.length });
    const finished = this._execute(run).then(
      () => {
        if (!run.terminal) this._completeRun(run);
        return this._result(run);
      },
      (error) => {
        if (run.terminal?.status === "stopped") return this._result(run);
        if (run.terminal?.status === "cancelled") throw run.terminal.error;
        this._errorRun(run, error);
        throw error;
      },
    ).finally(() => this._finalizeRun(run));
    run.finished = finished;
    this.finished = finished;
    return finished;
  }

  run(sequenceOrName, options = {}) {
    return this.play(sequenceOrName, options);
  }

  async _execute(run) {
    for (let index = 0; index < run.sequence.steps.length; index += 1) {
      if (run.controller.signal.aborted) throw abortError(run.controller.signal);
      const step = run.sequence.steps[index];
      run.index = index;
      this._emit("stepstart", run, {
        step,
        sprite: stateId(step.sprite),
      });

      await this._applyStep(run, step, index);
      await this._pauseGate(run);
      if (run.controller.signal.aborted) throw abortError(run.controller.signal);

      const from = this.state;
      const to = stateId(step.sprite);
      this.state = to;
      this._emit("statechange", run, { from, to, state: to, step });
      this._emit("stepenter", run, { from, to, step, sprite: to });

      if (step.holdMs > 0) await this._delay(run, step.holdMs);
      await this._pauseGate(run);
      if (run.controller.signal.aborted) throw abortError(run.controller.signal);

      run.completedSteps = index + 1;
      this._emit("stepcomplete", run, { step, sprite: to });
    }
  }

  _transitionFor(run, step, index) {
    if (hasOwn(step, "transition")) return step.transition;
    const transitionFirst =
      run.options.transitionFirst ??
      (run.sequence.transitionFirst || this.options.transitionFirst);
    if (index === 0 && !transitionFirst) return false;
    if (hasOwn(run.options, "transition")) return run.options.transition;
    if (hasOwn(run.sequence, "transition")) return run.sequence.transition;
    return this.options.transition;
  }

  async _applyStep(run, step, index) {
    const specification = this._transitionFor(run, step, index);
    if (specification === false) {
      const options = { immediate: true, ...(step.options || {}) };
      this.target.setSprite(step.sprite, options);
      return;
    }

    if (typeof this.target.transitionTo !== "function") {
      const options = transitionOptions(
        specification,
        run.options.preservePhase,
        step.options,
        run.controller.signal,
      );
      delete options.signal;
      this.target.setSprite(step.sprite, options);
      return;
    }

    const options = transitionOptions(
      specification,
      run.options.preservePhase,
      step.options,
      run.controller.signal,
    );
    let transition;
    try {
      transition = this.target.transitionTo(step.sprite, options);
    } catch (error) {
      throw error;
    }
    await this._raceAbort(transition, run);
  }

  _raceAbort(value, run) {
    if (run.controller.signal.aborted) {
      return Promise.reject(abortError(run.controller.signal));
    }
    return new Promise((resolve, reject) => {
      let settled = false;
      const finish = (callback, result) => {
        if (settled) return;
        settled = true;
        run.controller.signal.removeEventListener("abort", onAbort);
        callback(result);
      };
      const onAbort = () => finish(reject, abortError(run.controller.signal));
      run.controller.signal.addEventListener("abort", onAbort, { once: true });
      Promise.resolve(value).then(
        (result) => finish(resolve, result),
        (error) => finish(reject, error),
      );
    });
  }

  _pauseGate(run) {
    if (!run.paused) return Promise.resolve();
    if (run.controller.signal.aborted) {
      return Promise.reject(abortError(run.controller.signal));
    }
    return new Promise((resolve, reject) => {
      let settled = false;
      const gate = {
        resolve: () => finish(resolve),
      };
      const finish = (callback, value) => {
        if (settled) return;
        settled = true;
        run.gates.delete(gate);
        run.controller.signal.removeEventListener("abort", onAbort);
        callback(value);
      };
      const onAbort = () => finish(reject, abortError(run.controller.signal));
      run.gates.add(gate);
      run.controller.signal.addEventListener("abort", onAbort, { once: true });
    });
  }

  _delay(run, durationMs) {
    return new Promise((resolve, reject) => {
      let settled = false;
      const delay = {
        timer: null,
        remainingMs: durationMs,
        startedAt: 0,
        pause: () => {
          if (delay.timer === null) return;
          const elapsed = Math.max(0, this.clock.now() - delay.startedAt);
          delay.remainingMs = Math.max(0, delay.remainingMs - elapsed);
          this.clock.clearTimeout(delay.timer);
          delay.timer = null;
        },
        resume: () => {
          if (settled || run.paused || delay.timer !== null) return;
          delay.startedAt = this.clock.now();
          delay.timer = this.clock.setTimeout(finish, delay.remainingMs);
        },
      };
      const cleanup = () => {
        if (delay.timer !== null) this.clock.clearTimeout(delay.timer);
        delay.timer = null;
        if (run.delay === delay) run.delay = null;
        run.controller.signal.removeEventListener("abort", onAbort);
      };
      const finish = () => {
        if (settled) return;
        settled = true;
        cleanup();
        resolve();
      };
      const onAbort = () => {
        if (settled) return;
        settled = true;
        cleanup();
        reject(abortError(run.controller.signal));
      };
      run.delay = delay;
      run.controller.signal.addEventListener("abort", onAbort, { once: true });
      delay.resume();
    });
  }

  /**
   * Pause sequence progression and the active hold clock. A visual transition
   * already running in the target may finish, but no step is entered or
   * advanced until resume() is called.
   */
  pause() {
    const run = this._active;
    if (!run || run.terminal || run.paused) return this;
    run.paused = true;
    run.delay?.pause();
    this.status = "paused";
    this._emit("sequencepause", run);
    return this;
  }

  resume() {
    const run = this._active;
    if (!run || run.terminal || !run.paused) return this;
    run.paused = false;
    this.status = "running";
    this._emit("sequenceresume", run);
    run.delay?.resume();
    for (const gate of [...run.gates]) gate.resolve();
    return this;
  }

  stop(reason = "stopped") {
    if (this._active) this._stopRun(this._active, reason);
    return this;
  }

  _stopRun(run, reason) {
    if (run.terminal) return;
    run.terminal = { status: "stopped", reason: String(reason || "stopped") };
    run.controller.abort(abortError(reason));
    if (this._active === run) this.status = "idle";
    this._emit("sequencestop", run, { reason: run.terminal.reason });
  }

  _cancelRun(run, signal) {
    if (run.terminal) return;
    const error = abortError(signal);
    run.terminal = { status: "cancelled", reason: signal?.reason, error };
    run.controller.abort(error);
    if (this._active === run) this.status = "idle";
    this._emit("sequencecancel", run, { reason: signal?.reason, error });
  }

  _completeRun(run) {
    run.terminal = { status: "completed", reason: "completed" };
    if (this._active === run) this.status = "idle";
    this._emit("sequencecomplete", run, {
      completedSteps: run.completedSteps,
    });
  }

  _errorRun(run, error) {
    if (run.terminal) return;
    run.terminal = { status: "error", reason: "error", error };
    if (this._active === run) this.status = "idle";
    this._emit("sequenceerror", run, { error });
  }

  _result(run) {
    return Object.freeze({
      runId: run.id,
      name: run.sequence.name,
      sequence: run.sequence,
      status: run.terminal?.status || "completed",
      reason: run.terminal?.reason,
      completedSteps: run.completedSteps,
    });
  }

  _emit(type, run, extra = {}) {
    const detail = Object.freeze({
      runId: run.id,
      name: run.sequence.name,
      sequence: run.sequence,
      index: run.index,
      ...extra,
    });
    this.dispatchEvent(detailEvent(type, detail));
    return detail;
  }

  _finalizeRun(run) {
    run.removeExternalAbort();
    run.delay?.pause();
    run.delay = null;
    for (const gate of [...run.gates]) gate.resolve();
    run.gates.clear();
    if (this._active === run) {
      this._active = null;
      if (!this.destroyed) this.status = "idle";
    }
  }

  destroy() {
    if (this.destroyed) return;
    if (this._active) this._stopRun(this._active, "destroyed");
    this.destroyed = true;
    this.status = "destroyed";
    this.sequences.clear();
  }
}

/**
 * Ergonomic one-off playback. The returned player is also the pause/resume/stop
 * handle; await player.finished for completion.
 *
 * @example
 * const playback = playStateSequence(engine, [
 *   { sprite: "ai.ambient-idle", holdMs: 1200 },
 *   { sprite: "ai.progress", transition: "contour-trace" },
 * ], { signal });
 * await playback.finished;
 */
export function playStateSequence(target, steps, options = {}) {
  const player = new StateSequencePlayer(target, {
    clock: options.clock,
    transition: hasOwn(options, "transition") ? options.transition : "field-morph",
    transitionFirst: options.transitionFirst,
    preservePhase: options.preservePhase,
  });
  const sequence = defineStateSequence(options.name || "one-off", steps, {
    metadata: options.metadata,
  });
  player.finished = player.play(sequence, { signal: options.signal });
  return player;
}

export default StateSequencePlayer;
