import type { LatexCodeMirrorPersistedState } from '@learnitive/latex-codemirror';
import type { SourceMutationOrigin, SourceTextEdit } from '@learnitive/latex-source';
import type { LatexSourceDocumentSnapshot, LatexSourceMutationOptions } from '@learnitive/service-core';

export type LatexDocumentStatus = 'idle' | 'loading' | 'ready' | 'error';

export type LatexDocumentControllerSnapshot = {
  requestedPath?: string;
  loadingPath?: string;
  activePath?: string;
  content: string;
  revision: number;
  lastOrigin?: SourceMutationOrigin;
  status: LatexDocumentStatus;
  requestId: number;
  error?: unknown;
};

export type LatexSourceViewState = {
  revision: number;
  codeMirror: LatexCodeMirrorPersistedState;
};

type LatexDocumentSource = {
  load(wsPath: string): Promise<string>;
  getSnapshot(wsPath: string): LatexSourceDocumentSnapshot | undefined;
  subscribe(wsPath: string, listener: () => void): () => void;
  applyEdit(
    wsPath: string,
    edit: SourceTextEdit,
    origin: SourceMutationOrigin,
    options?: LatexSourceMutationOptions,
  ): void;
  applyEdits(
    wsPath: string,
    edits: readonly SourceTextEdit[],
    origin: SourceMutationOrigin,
    options?: LatexSourceMutationOptions,
  ): void;
};

export type LatexDocumentExactEdit = {
  expectedRevision: number;
  from: number;
  to: number;
  insert: string;
  origin: SourceMutationOrigin;
  history?: LatexSourceMutationOptions['history'];
};

export type LatexDocumentExactEdits = {
  expectedRevision: number;
  edits: readonly { from: number; to: number; insert: string }[];
  origin: SourceMutationOrigin;
  history?: LatexSourceMutationOptions['history'];
};

export type LatexDocumentMutationResult =
  | { applied: true; snapshot: LatexSourceDocumentSnapshot }
  | {
      applied: false;
      reason: 'inactive-document' | 'revision-mismatch';
      snapshot?: LatexSourceDocumentSnapshot;
    };

const EMPTY_SNAPSHOT: LatexDocumentControllerSnapshot = {
  content: '',
  revision: 0,
  status: 'idle',
  requestId: 0,
};

function createAbortError(): Error {
  const error = new Error('Latex document request superseded');
  error.name = 'AbortError';
  return error;
}

function waitForRequest<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
  if (signal.aborted) return Promise.reject(createAbortError());
  return new Promise<T>((resolve, reject) => {
    const abort = () => reject(createAbortError());
    signal.addEventListener('abort', abort, { once: true });
    promise.then(
      (value) => {
        signal.removeEventListener('abort', abort);
        resolve(value);
      },
      (error) => {
        signal.removeEventListener('abort', abort);
        reject(error);
      },
    );
  });
}

/**
 * Atomic route -> canonical LaTeX document session coordinator.
 *
 * The route-selected wsPath is the only input. A request token plus an
 * abortable activation wait prevents slower, older loads from ever becoming
 * active after a newer route selection. LatexSourceService remains the
 * canonical text owner; this controller owns only which canonical document is
 * currently allowed to back the editor surfaces and the per-file view state
 * used when a CodeMirror surface is reconstructed.
 */
export class LatexDocumentController {
  private snapshot: LatexDocumentControllerSnapshot = EMPTY_SNAPSHOT;
  private readonly listeners = new Set<() => void>();
  private readonly sourceViewStates = new Map<string, LatexSourceViewState>();
  private requestSequence = 0;
  private activationController?: AbortController;
  private activeSourceUnsubscribe?: () => void;
  private disposed = false;

  constructor(private readonly source: LatexDocumentSource) {}

  subscribe = (listener: () => void): (() => void) => {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  };

  getSnapshot = (): LatexDocumentControllerSnapshot => this.snapshot;

  async requestPath(wsPath: string): Promise<void> {
    if (this.disposed) return;
    if (
      this.snapshot.status === 'ready' &&
      this.snapshot.activePath === wsPath &&
      this.snapshot.requestedPath === wsPath
    ) {
      return;
    }

    const requestId = ++this.requestSequence;
    this.activationController?.abort();
    const activationController = new AbortController();
    this.activationController = activationController;
    this.detachActiveSource();
    this.publish({
      requestedPath: wsPath,
      loadingPath: wsPath,
      content: '',
      revision: 0,
      status: 'loading',
      requestId,
    });

    try {
      // Do not cancel LatexSourceService's cache-populating read itself. The
      // controller cancels/obsoletes only activation of an old request, so a
      // later visit can reuse a load that already completed in the background.
      await waitForRequest(this.source.load(wsPath), activationController.signal);
      if (!this.isCurrent(requestId, wsPath, activationController)) return;

      const sourceSnapshot = this.source.getSnapshot(wsPath);
      if (!sourceSnapshot) {
        throw new Error(`LaTeX source loaded without a canonical snapshot: ${wsPath}`);
      }

      this.attachActiveSource(wsPath, requestId);
      this.publishReady(wsPath, requestId, sourceSnapshot);
    } catch (error) {
      if (
        activationController.signal.aborted ||
        !this.isCurrent(requestId, wsPath, activationController)
      ) {
        return;
      }
      this.publish({
        requestedPath: wsPath,
        content: '',
        revision: 0,
        status: 'error',
        requestId,
        error,
      });
    }
  }

  clear(): void {
    if (this.disposed) return;
    this.requestSequence += 1;
    this.activationController?.abort();
    this.activationController = undefined;
    this.detachActiveSource();
    this.publish({ ...EMPTY_SNAPSHOT, requestId: this.requestSequence });
  }

  retry(): Promise<void> {
    const requestedPath = this.snapshot.requestedPath;
    if (!requestedPath) return Promise.resolve();
    return this.requestPath(requestedPath);
  }

  /**
   * Re-publish the active canonical source snapshot immediately. Normal source
   * mutations already arrive through the service subscription; callers that
   * perform an external Visual-side mutation (for example TikZ insertion) can
   * use this as an explicit same-tick reconciliation barrier so the persistent
   * Source/Visual surfaces never wait for a later mode switch to see it.
   */
  refreshActiveSource(wsPath: string): LatexSourceDocumentSnapshot | undefined {
    if (
      this.disposed ||
      this.snapshot.status !== 'ready' ||
      this.snapshot.activePath !== wsPath ||
      this.snapshot.requestedPath !== wsPath
    ) {
      return undefined;
    }
    const next = this.source.getSnapshot(wsPath);
    if (!next) return undefined;
    this.publishReady(wsPath, this.snapshot.requestId, next);
    return next;
  }

  /**
   * Applies one exact source splice only when the caller is editing the active
   * canonical revision. Source/Visual/MathLive all use this contract, so a
   * stale projection can never overwrite a newer document revision.
   */
  applyExactEdit(wsPath: string, edit: LatexDocumentExactEdit): LatexDocumentMutationResult {
    const current = this.source.getSnapshot(wsPath);
    if (
      this.snapshot.status !== 'ready' ||
      this.snapshot.activePath !== wsPath ||
      this.snapshot.requestedPath !== wsPath ||
      !current
    ) {
      return { applied: false, reason: 'inactive-document', ...(current ? { snapshot: current } : {}) };
    }
    if (current.revision !== edit.expectedRevision) {
      return { applied: false, reason: 'revision-mismatch', snapshot: current };
    }
    this.source.applyEdit(
      wsPath,
      { from: edit.from, to: edit.to, text: edit.insert },
      edit.origin,
      edit.history ? { history: edit.history } : {},
    );
    const next = this.source.getSnapshot(wsPath);
    if (!next) return { applied: false, reason: 'inactive-document' };
    return { applied: true, snapshot: next };
  }

  applyExactEdits(wsPath: string, request: LatexDocumentExactEdits): LatexDocumentMutationResult {
    const current = this.source.getSnapshot(wsPath);
    if (
      this.snapshot.status !== 'ready' ||
      this.snapshot.activePath !== wsPath ||
      this.snapshot.requestedPath !== wsPath ||
      !current
    ) {
      return { applied: false, reason: 'inactive-document', ...(current ? { snapshot: current } : {}) };
    }
    if (current.revision !== request.expectedRevision) {
      return { applied: false, reason: 'revision-mismatch', snapshot: current };
    }
    this.source.applyEdits(
      wsPath,
      request.edits.map((edit) => ({ from: edit.from, to: edit.to, text: edit.insert })),
      request.origin,
      request.history ? { history: request.history } : {},
    );
    const next = this.source.getSnapshot(wsPath);
    if (!next) return { applied: false, reason: 'inactive-document' };
    return { applied: true, snapshot: next };
  }

  saveSourceViewState(wsPath: string, state: LatexSourceViewState): void {
    this.sourceViewStates.set(wsPath, state);
  }

  getSourceViewState(
    wsPath: string,
    canonicalRevision: number,
  ): LatexCodeMirrorPersistedState | undefined {
    const saved = this.sourceViewStates.get(wsPath);
    if (!saved) return undefined;
    if (saved.revision === canonicalRevision) return saved.codeMirror;

    // The file changed while its CodeMirror view was inactive. History deltas
    // are no longer safe to replay against that new document, but restoring a
    // clamped selection/scroll position remains useful and deterministic.
    return {
      selection: saved.codeMirror.selection,
      scrollTop: saved.codeMirror.scrollTop,
      scrollLeft: saved.codeMirror.scrollLeft,
    };
  }

  dispose(): void {
    if (this.disposed) return;
    this.disposed = true;
    this.activationController?.abort();
    this.activationController = undefined;
    this.detachActiveSource();
    this.listeners.clear();
    this.sourceViewStates.clear();
  }

  private isCurrent(
    requestId: number,
    wsPath: string,
    controller: AbortController,
  ): boolean {
    return (
      !this.disposed &&
      !controller.signal.aborted &&
      requestId === this.requestSequence &&
      this.snapshot.requestedPath === wsPath
    );
  }

  private attachActiveSource(wsPath: string, requestId: number): void {
    this.detachActiveSource();
    this.activeSourceUnsubscribe = this.source.subscribe(wsPath, () => {
      if (
        this.disposed ||
        requestId !== this.requestSequence ||
        this.snapshot.activePath !== wsPath
      ) {
        return;
      }
      const next = this.source.getSnapshot(wsPath);
      if (next) this.publishReady(wsPath, requestId, next);
    });
  }

  private detachActiveSource(): void {
    this.activeSourceUnsubscribe?.();
    this.activeSourceUnsubscribe = undefined;
  }

  private publishReady(
    wsPath: string,
    requestId: number,
    sourceSnapshot: LatexSourceDocumentSnapshot,
  ): void {
    this.publish({
      requestedPath: wsPath,
      activePath: wsPath,
      content: sourceSnapshot.text,
      revision: sourceSnapshot.revision,
      ...(sourceSnapshot.lastOrigin === undefined
        ? {}
        : { lastOrigin: sourceSnapshot.lastOrigin }),
      status: 'ready',
      requestId,
    });
  }

  private publish(snapshot: LatexDocumentControllerSnapshot): void {
    this.snapshot = snapshot;
    for (const listener of this.listeners) listener();
  }
}
