import {
  BaseService,
  type BaseServiceContext,
  throwAppError,
} from '@learnitive/base-utils';
import { SERVICE_NAME } from '@learnitive/constants';
import {
  applySourceTextEdit,
  applySourceTextEdits,
  type SourceMutationOrigin,
  type SourceTextEdit,
} from '@learnitive/latex-source';
import { isLearnitiveEditableTextPath } from '@learnitive/project-storage';
import { WsPath } from '@learnitive/ws-path';
import type { FileSystemService } from './file-system-service';

const SOURCE_SAVE_DEBOUNCE_MS = 650;
const SOURCE_HISTORY_GROUP_MS = 900;
const GROUPABLE_HISTORY_ORIGINS = new Set<SourceMutationOrigin>([
  'source',
  'visual',
]);
const SOURCE_HISTORY_LIMIT = 200;

type HistoryEntry = {
  text: string;
  origin: SourceMutationOrigin;
  createdAt: number;
};

export type LatexSourceMutationOptions = {
  history?: 'record' | 'ignore' | 'reset';
  /** Exact edits for collaboration mirroring; coordinates target the prior revision. */
  mutationEdits?: readonly SourceTextEdit[];
};

export type LatexSourceMutationEvent = {
  wsPath: string;
  wsName: string;
  revision: number;
  origin: SourceMutationOrigin;
  /** Exact edits that produced this revision when the caller supplied them. */
  edits?: readonly SourceTextEdit[];
};

export type LatexSourceRevealRequest = {
  requestId: number;
  line: number;
  column?: number;
  from?: number;
  to?: number;
  select: boolean;
};

export type LatexSourceDocumentSnapshot = {
  wsPath: string;
  text: string;
  revision: number;
  dirty: boolean;
  saving: boolean;
  saveError?: unknown;
  externalConflict: boolean;
  lastOrigin?: SourceMutationOrigin;
  canUndo: boolean;
  canRedo: boolean;
};

type SourceDocumentState = {
  wsPath: string;
  text: string;
  persistedText: string;
  revision: number;
  persistedRevision: number;
  loaded: boolean;
  loading?: Promise<string>;
  saveTimer?: ReturnType<typeof setTimeout>;
  saveLoop?: Promise<void>;
  saveError?: unknown;
  externalConflict: boolean;
  lastOrigin?: SourceMutationOrigin;
  listeners: Set<() => void>;
  undo: HistoryEntry[];
  redo: HistoryEntry[];
  lastHistoryOrigin?: SourceMutationOrigin;
  lastHistoryAt?: number;
};

function isSupportedSourceWsPath(wsPath: string): boolean {
  const filePath = WsPath.safeParseFile(wsPath).data;
  return Boolean(filePath && isLearnitiveEditableTextPath(filePath.path));
}

function fileForText(wsPath: string, text: string): File {
  const filePath = WsPath.assertFile(wsPath);
  return new File([text], filePath.fileName, {
    type: 'text/plain;charset=utf-8',
  });
}

/**
 * Owns raw Learnitive source documents independently from the Markdown/ProseMirror
 * editor engine. It keeps source writes ordered, debounced, recoverable through
 * FileSystemService snapshots, and blocks automatic overwrites after an
 * external-file race until the user explicitly chooses a side.
 */
export class LatexSourceService extends BaseService {
  static deps = ['fileSystem'] as const;

  private readonly documents = new Map<string, SourceDocumentState>();
  private readonly globalListeners = new Set<() => void>();
  private readonly mutationListeners = new Set<(event: LatexSourceMutationEvent) => void>();
  private readonly revealRequests = new Map<string, LatexSourceRevealRequest>();
  private readonly cursorLocations = new Map<string, { offset: number; line: number; column: number }>();
  private nextRevealRequestId = 1;

  constructor(
    context: BaseServiceContext,
    private readonly dependencies: { fileSystem: FileSystemService },
  ) {
    super(SERVICE_NAME.latexSourceService, context, dependencies);
  }

  async hookMount(): Promise<void> {
    const unsubscribeExternal = this.store.sub(
      this.dependencies.fileSystem.$externalFileChangeEvent,
      () => {
        const event = this.store.get(
          this.dependencies.fileSystem.$externalFileChangeEvent,
        );
        if (!event || event.type === 'refresh') {
          return;
        }
        const state = this.documents.get(event.wsPath);
        if (!state?.loaded) {
          return;
        }
        void this.handleExternalChange(state);
      },
    );
    this.addCleanup(unsubscribeExternal);
    this.addCleanup(() => {
      for (const state of this.documents.values()) {
        if (state.saveTimer) {
          clearTimeout(state.saveTimer);
        }
      }
      this.documents.clear();
      this.globalListeners.clear();
      this.mutationListeners.clear();
      this.revealRequests.clear();
      this.cursorLocations.clear();
    });
  }

  private assertSupported(wsPath: string): void {
    if (!isSupportedSourceWsPath(wsPath)) {
      throwAppError(
        'error::file:invalid-operation',
        `File is not a Learnitive editable source file: ${wsPath}`,
        { operation: 'edit-source', oldWsPath: wsPath, newWsPath: wsPath },
      );
    }
  }

  private getOrCreateState(wsPath: string): SourceDocumentState {
    this.assertSupported(wsPath);
    let state = this.documents.get(wsPath);
    if (!state) {
      state = {
        wsPath,
        text: '',
        persistedText: '',
        revision: 0,
        persistedRevision: 0,
        loaded: false,
        externalConflict: false,
        listeners: new Set(),
        undo: [],
        redo: [],
      };
      this.documents.set(wsPath, state);
    }
    return state;
  }

  async load(wsPath: string, signal?: AbortSignal): Promise<string> {
    await this.mountPromise;
    signal?.throwIfAborted();
    const state = this.getOrCreateState(wsPath);
    if (state.loaded) {
      return state.text;
    }
    if (state.loading) {
      return state.loading;
    }

    state.loading = (async () => {
      const text = await this.dependencies.fileSystem.readFileAsText(wsPath, {
        signal,
      });
      if (text === undefined) {
        throwAppError('error::file:invalid-note-path', 'Source file not found', {
          invalidWsPath: wsPath,
        });
      }
      signal?.throwIfAborted();
      state.text = text;
      state.persistedText = text;
      state.loaded = true;
      state.revision = 0;
      state.persistedRevision = 0;
      state.saveError = undefined;
      state.externalConflict = false;
      state.undo = [];
      state.redo = [];
      this.notify(state);
      return text;
    })();

    try {
      return await state.loading;
    } finally {
      state.loading = undefined;
    }
  }

  getSnapshot(wsPath: string): LatexSourceDocumentSnapshot | undefined {
    const state = this.documents.get(wsPath);
    if (!state?.loaded) {
      return undefined;
    }
    return {
      wsPath,
      text: state.text,
      revision: state.revision,
      dirty: state.revision !== state.persistedRevision,
      saving: Boolean(state.saveLoop),
      ...(state.saveError === undefined ? {} : { saveError: state.saveError }),
      externalConflict: state.externalConflict,
      ...(state.lastOrigin === undefined
        ? {}
        : { lastOrigin: state.lastOrigin }),
      canUndo: state.undo.length > 0,
      canRedo: state.redo.length > 0,
    };
  }

  subscribe(wsPath: string, listener: () => void): () => void {
    const state = this.getOrCreateState(wsPath);
    state.listeners.add(listener);
    return () => state.listeners.delete(listener);
  }

  setCursorLocation(
    wsPath: string,
    location: { offset: number; line: number; column: number },
  ): void {
    this.assertSupported(wsPath);
    this.cursorLocations.set(wsPath, location);
  }

  getCursorLocation(
    wsPath: string,
  ): { offset: number; line: number; column: number } | undefined {
    return this.cursorLocations.get(wsPath);
  }

  requestReveal(wsPath: string, line: number, column?: number): LatexSourceRevealRequest {
    return this.requestSourceLocation(wsPath, line, column, false);
  }

  requestSelect(wsPath: string, line: number, column?: number): LatexSourceRevealRequest {
    return this.requestSourceLocation(wsPath, line, column, true);
  }

  requestSelectRange(wsPath: string, from: number, to: number): LatexSourceRevealRequest {
    this.assertSupported(wsPath);
    const source = this.documents.get(wsPath)?.text ?? '';
    const start = Math.max(0, Math.min(source.length, Math.trunc(from)));
    const end = Math.max(start, Math.min(source.length, Math.trunc(to)));
    const before = source.slice(0, start);
    const line = before.split('\n').length;
    const lineStart = before.lastIndexOf('\n') + 1;
    const request: LatexSourceRevealRequest = {
      requestId: this.nextRevealRequestId++,
      line,
      column: start - lineStart + 1,
      from: start,
      to: end,
      select: true,
    };
    this.revealRequests.set(wsPath, request);
    const state = this.documents.get(wsPath);
    if (state) this.notify(state);
    return request;
  }

  private requestSourceLocation(
    wsPath: string,
    line: number,
    column: number | undefined,
    select: boolean,
  ): LatexSourceRevealRequest {
    this.assertSupported(wsPath);
    const request: LatexSourceRevealRequest = {
      requestId: this.nextRevealRequestId++,
      line: Math.max(1, Math.trunc(line)),
      ...(column === undefined ? {} : { column: Math.max(1, Math.trunc(column)) }),
      select,
    };
    this.revealRequests.set(wsPath, request);
    const state = this.documents.get(wsPath);
    if (state) this.notify(state);
    return request;
  }

  getRevealRequest(wsPath: string): LatexSourceRevealRequest | undefined {
    return this.revealRequests.get(wsPath);
  }

  subscribeToMutations(
    listener: (event: LatexSourceMutationEvent) => void,
  ): () => void {
    this.mutationListeners.add(listener);
    return () => this.mutationListeners.delete(listener);
  }

  subscribeToSaveStatus(listener: () => void, wsPath?: string): () => void {
    if (wsPath) {
      return this.subscribe(wsPath, listener);
    }
    this.globalListeners.add(listener);
    return () => this.globalListeners.delete(listener);
  }

  hasPendingOrFailedSave(wsPath?: string): boolean {
    const states = wsPath
      ? [this.documents.get(wsPath)].filter(
          (state): state is SourceDocumentState => Boolean(state),
        )
      : [...this.documents.values()];
    return states.some(
      (state) =>
        state.loaded &&
        (state.revision !== state.persistedRevision ||
          state.saveError !== undefined ||
          state.externalConflict),
    );
  }

  update(
    wsPath: string,
    text: string,
    origin: SourceMutationOrigin = 'source',
    options: LatexSourceMutationOptions = {},
  ): void {
    const state = this.getOrCreateState(wsPath);
    if (!state.loaded) {
      throw new Error(`Source document is not loaded: ${wsPath}`);
    }
    if (state.text === text) {
      return;
    }

    if (options.history === 'reset') {
      state.undo = [];
      state.redo = [];
      state.lastHistoryAt = undefined;
      state.lastHistoryOrigin = undefined;
    } else if (options.history === 'ignore') {
      // CodeMirror owns local Source undo/redo. Preserve older canonical
      // history entries (Visual/MathLive/AI/etc.) but a new Source branch
      // invalidates any canonical redo branch.
      state.redo = [];
      state.lastHistoryAt = undefined;
      state.lastHistoryOrigin = undefined;
    } else {
      this.recordUndo(state, origin);
      state.redo = [];
    }
    state.text = text;
    state.revision += 1;
    state.lastOrigin = origin;
    state.saveError = undefined;
    this.notify(state);
    this.notifyMutation(state, options.mutationEdits);
    this.scheduleSave(state);
  }

  applyEdit(
    wsPath: string,
    edit: SourceTextEdit,
    origin: SourceMutationOrigin,
    options: LatexSourceMutationOptions = {},
  ): void {
    const state = this.getOrCreateState(wsPath);
    if (!state.loaded) {
      throw new Error(`Source document is not loaded: ${wsPath}`);
    }
    this.update(wsPath, applySourceTextEdit(state.text, edit), origin, {
      ...options,
      mutationEdits: [edit],
    });
  }

  applyEdits(
    wsPath: string,
    edits: readonly SourceTextEdit[],
    origin: SourceMutationOrigin,
    options: LatexSourceMutationOptions = {},
  ): void {
    if (edits.length === 0) return;
    const state = this.getOrCreateState(wsPath);
    if (!state.loaded) {
      throw new Error(`Source document is not loaded: ${wsPath}`);
    }
    this.update(wsPath, applySourceTextEdits(state.text, edits), origin, {
      ...options,
      mutationEdits: edits,
    });
  }

  undo(wsPath: string): boolean {
    const state = this.documents.get(wsPath);
    const previous = state?.undo.pop();
    if (!state?.loaded || !previous) {
      return false;
    }
    state.redo.push({
      text: state.text,
      origin: state.lastOrigin ?? 'source',
      createdAt: Date.now(),
    });
    state.text = previous.text;
    state.revision += 1;
    state.lastOrigin = previous.origin;
    state.lastHistoryAt = undefined;
    state.lastHistoryOrigin = undefined;
    state.saveError = undefined;
    this.notify(state);
    this.notifyMutation(state);
    this.scheduleSave(state);
    return true;
  }

  redo(wsPath: string): boolean {
    const state = this.documents.get(wsPath);
    const next = state?.redo.pop();
    if (!state?.loaded || !next) {
      return false;
    }
    state.undo.push({
      text: state.text,
      origin: state.lastOrigin ?? 'source',
      createdAt: Date.now(),
    });
    this.trimHistory(state.undo);
    state.text = next.text;
    state.revision += 1;
    state.lastOrigin = next.origin;
    state.lastHistoryAt = undefined;
    state.lastHistoryOrigin = undefined;
    state.saveError = undefined;
    this.notify(state);
    this.notifyMutation(state);
    this.scheduleSave(state);
    return true;
  }


  /** Retargets an already-loaded source document after a project file rename. */
  retargetLoadedDocument(oldWsPath: string, newWsPath: string): void {
    if (oldWsPath === newWsPath) return;
    const state = this.documents.get(oldWsPath);
    if (!state?.loaded) return;
    this.assertSupported(newWsPath);
    if (this.documents.has(newWsPath)) {
      throw new Error(`Cannot retarget source document because destination is already open: ${newWsPath}`);
    }
    this.documents.delete(oldWsPath);
    const reveal = this.revealRequests.get(oldWsPath);
    const cursor = this.cursorLocations.get(oldWsPath);
    this.revealRequests.delete(oldWsPath);
    this.cursorLocations.delete(oldWsPath);
    state.wsPath = newWsPath;
    this.documents.set(newWsPath, state);
    if (reveal) this.revealRequests.set(newWsPath, reveal);
    if (cursor) this.cursorLocations.set(newWsPath, cursor);
    this.notify(state);
  }

  async flushAll(): Promise<void> {
    await this.mountPromise;
    await Promise.all(
      [...this.documents.keys()].map((wsPath) => this.flush(wsPath)),
    );
  }

  async flushWorkspace(wsName: string): Promise<void> {
    await this.mountPromise;
    await Promise.all(
      [...this.documents.values()]
        .filter((state) => WsPath.assertFile(state.wsPath).wsName === wsName)
        .map((state) => this.flush(state.wsPath)),
    );
  }

  hasPendingOrFailedSaveInWorkspace(wsName: string): boolean {
    return [...this.documents.values()].some(
      (state) =>
        state.loaded &&
        WsPath.assertFile(state.wsPath).wsName === wsName &&
        (state.revision !== state.persistedRevision ||
          state.saveError !== undefined ||
          state.externalConflict),
    );
  }

  /**
   * Refreshes clean, already-loaded source documents after a project-level
   * mutation such as Git checkout/pull. Dirty/conflicted documents are never
   * replaced by this path.
   */
  async refreshLoadedDocuments(wsName?: string): Promise<void> {
    await this.mountPromise;
    for (const state of this.documents.values()) {
      if (
        wsName !== undefined &&
        WsPath.assertFile(state.wsPath).wsName !== wsName
      ) {
        continue;
      }
      if (
        !state.loaded ||
        state.externalConflict ||
        state.saveLoop ||
        state.revision !== state.persistedRevision
      ) {
        continue;
      }
      const text = await this.dependencies.fileSystem.readFileAsText(state.wsPath);
      if (text === undefined) {
        continue;
      }
      if (text === state.text) {
        continue;
      }
      state.text = text;
      state.persistedText = text;
      state.revision += 1;
      state.persistedRevision = state.revision;
      state.lastOrigin = 'git';
      state.undo = [];
      state.redo = [];
      state.lastHistoryAt = undefined;
      state.lastHistoryOrigin = undefined;
      this.notify(state);
      this.notifyMutation(state);
    }
  }

  async flush(wsPath: string): Promise<void> {
    await this.mountPromise;
    const state = this.documents.get(wsPath);
    if (!state?.loaded) {
      return;
    }
    if (state.saveTimer) {
      clearTimeout(state.saveTimer);
      state.saveTimer = undefined;
    }
    if (state.externalConflict) {
      return;
    }
    await this.runSaveLoop(state);
  }

  async reloadFromStorage(
    wsPath: string,
    options: { discardLocalChanges: boolean },
  ): Promise<void> {
    const state = this.getOrCreateState(wsPath);
    if (!state.loaded) {
      await this.load(wsPath);
      return;
    }
    if (
      !options.discardLocalChanges &&
      state.revision !== state.persistedRevision
    ) {
      throw new Error('Refusing to discard unsaved source changes');
    }

    const text = await this.dependencies.fileSystem.readFileAsText(wsPath);
    if (text === undefined) {
      throw new Error(`Source file no longer exists: ${wsPath}`);
    }
    state.text = text;
    state.persistedText = text;
    state.revision += 1;
    state.persistedRevision = state.revision;
    state.externalConflict = false;
    state.saveError = undefined;
    state.undo = [];
    state.redo = [];
    this.notify(state);
    this.notifyMutation(state);
  }

  /**
   * Explicitly chooses the in-editor copy after an external change. The next
   * FileSystemService write snapshots the external disk version before it is
   * replaced, so "keep mine" remains recoverable instead of destructive.
   */
  async keepLocalAfterExternalChange(wsPath: string): Promise<void> {
    const state = this.documents.get(wsPath);
    if (!state?.loaded || !state.externalConflict) {
      return;
    }
    state.externalConflict = false;
    this.notify(state);
    await this.runSaveLoop(state);
  }

  private recordUndo(
    state: SourceDocumentState,
    origin: SourceMutationOrigin,
  ): void {
    const now = Date.now();
    const grouped =
      GROUPABLE_HISTORY_ORIGINS.has(origin) &&
      state.lastHistoryOrigin === origin &&
      state.lastHistoryAt !== undefined &&
      now - state.lastHistoryAt < SOURCE_HISTORY_GROUP_MS;

    if (!grouped) {
      state.undo.push({ text: state.text, origin, createdAt: now });
      this.trimHistory(state.undo);
    }
    state.lastHistoryOrigin = origin;
    state.lastHistoryAt = now;
  }

  private trimHistory(history: HistoryEntry[]): void {
    if (history.length > SOURCE_HISTORY_LIMIT) {
      history.splice(0, history.length - SOURCE_HISTORY_LIMIT);
    }
  }

  private scheduleSave(state: SourceDocumentState): void {
    if (state.externalConflict) {
      return;
    }
    if (state.saveTimer) {
      clearTimeout(state.saveTimer);
    }
    state.saveTimer = setTimeout(() => {
      state.saveTimer = undefined;
      void this.runSaveLoop(state);
    }, SOURCE_SAVE_DEBOUNCE_MS);
  }

  private async runSaveLoop(state: SourceDocumentState): Promise<void> {
    if (state.externalConflict || !state.loaded) {
      return;
    }
    if (state.saveLoop) {
      await state.saveLoop;
      return;
    }

    const saveLoop = (async () => {
      while (
        !state.externalConflict &&
        state.persistedRevision < state.revision
      ) {
        const revision = state.revision;
        const text = state.text;
        try {
          await this.dependencies.fileSystem.writeFile(
            state.wsPath,
            fileForText(state.wsPath, text),
          );
          state.persistedRevision = revision;
          state.persistedText = text;
          state.saveError = undefined;
        } catch (error) {
          state.saveError = error;
          this.logger.error(`Unable to save source ${state.wsPath}`, error);
          break;
        }
        this.notify(state);
      }
    })();
    state.saveLoop = saveLoop;
    this.notify(state);

    try {
      await saveLoop;
    } finally {
      state.saveLoop = undefined;
      this.notify(state);
    }
  }

  private async handleExternalChange(state: SourceDocumentState): Promise<void> {
    try {
      const text = await this.dependencies.fileSystem.readFileAsText(
        state.wsPath,
      );
      if (text === undefined) {
        return;
      }

      // Native-FS watchers also observe this tab's own durable writes. Treat a
      // disk copy equal to either the last known persisted text or the current
      // editor text as an echo, not a conflict.
      if (text === state.persistedText || text === state.text) {
        return;
      }

      if (state.revision !== state.persistedRevision || state.saveLoop) {
        if (state.saveTimer) {
          clearTimeout(state.saveTimer);
          state.saveTimer = undefined;
        }
        state.externalConflict = true;
        this.notify(state);
        return;
      }

      state.text = text;
      state.persistedText = text;
      state.revision += 1;
      state.persistedRevision = state.revision;
      state.lastOrigin = 'system';
      state.undo = [];
      state.redo = [];
      this.notify(state);
      this.notifyMutation(state);
    } catch (error) {
      this.logger.warn(`Unable to reload external source ${state.wsPath}`, error);
    }
  }

  private notifyMutation(
    state: SourceDocumentState,
    edits?: readonly SourceTextEdit[],
  ): void {
    const filePath = WsPath.assertFile(state.wsPath);
    const event: LatexSourceMutationEvent = {
      wsPath: state.wsPath,
      wsName: filePath.wsName,
      revision: state.revision,
      origin: state.lastOrigin ?? 'system',
      ...(edits?.length ? { edits: edits.map((edit) => ({ ...edit })) } : {}),
    };
    for (const listener of this.mutationListeners) {
      listener(event);
    }
  }

  private notify(state: SourceDocumentState): void {
    for (const listener of state.listeners) {
      listener();
    }
    for (const listener of this.globalListeners) {
      listener();
    }
  }
}
