import { BaseService, type BaseServiceContext } from '@learnitive/base-utils';
import { SERVICE_NAME } from '@learnitive/constants';
import type { LatexCompileResult } from '@learnitive/latex-compiler';
import {
  parseSyncTex,
  type SyncTexFileMapping,
  type SyncTexForwardResult,
  type SyncTexIndex,
  type SyncTexReverseResult,
} from '@learnitive/latex-synctex';
import type { ProjectEntry, ProjectFilePath } from '@learnitive/project-storage';
import { projectFilePath } from '@learnitive/project-storage';
import { atom } from 'jotai';
import { WsPath } from '@learnitive/ws-path';
import type { ProjectCompileService } from './project-compile-service';
import type { ProjectStorageService } from './project-storage-service';
import type { LatexSourceService } from './latex-source-service';
import type { WorkspaceOpsService } from './workspace-ops-service';

export type PdfSyncPreferences = {
  pdfFollowsSource: boolean;
  sourceFollowsPdf: boolean;
};

export type PdfScrollSurface = 'source' | 'visual' | 'pdf';
export type PdfScrollOwnership =
  | 'source-user'
  | 'visual-user'
  | 'pdf-user'
  | 'source-programmatic'
  | 'visual-programmatic'
  | 'pdf-programmatic';

type PdfScrollOwnershipState = {
  owner: PdfScrollOwnership;
  upstreamUser?: Extract<PdfScrollOwnership, `${string}-user`>;
};

export type ProjectPdfViewState = {
  page: number;
  scrollTop: number;
  zoom: number;
  fitMode: 'width' | 'custom';
  outputTab: 'pdf' | 'log';
};

const PDF_VIEW_STATE_STORAGE_PREFIX = 'learnitive:pdf-view:v1:';
const DEFAULT_PDF_VIEW_STATE: ProjectPdfViewState = {
  page: 1,
  scrollTop: 0,
  zoom: 1,
  fitMode: 'width',
  outputTab: 'pdf',
};

function isPdfViewState(value: unknown): value is ProjectPdfViewState {
  if (!value || typeof value !== 'object') return false;
  const candidate = value as Partial<ProjectPdfViewState>;
  return Number.isFinite(candidate.page) && Number.isFinite(candidate.scrollTop) &&
    Number.isFinite(candidate.zoom) && (candidate.fitMode === 'width' || candidate.fitMode === 'custom') &&
    (candidate.outputTab === 'pdf' || candidate.outputTab === 'log');
}

function pdfViewStateStorageKey(wsName: string): string {
  return `${PDF_VIEW_STATE_STORAGE_PREFIX}${encodeURIComponent(wsName)}`;
}

function readPersistedPdfViewState(wsName: string): ProjectPdfViewState | undefined {
  if (typeof localStorage === 'undefined') return undefined;
  try {
    const raw = localStorage.getItem(pdfViewStateStorageKey(wsName));
    if (!raw) return undefined;
    const parsed: unknown = JSON.parse(raw);
    return isPdfViewState(parsed) ? parsed : undefined;
  } catch {
    return undefined;
  }
}

function persistPdfViewState(wsName: string, state: ProjectPdfViewState): void {
  if (typeof localStorage === 'undefined') return;
  try {
    localStorage.setItem(pdfViewStateStorageKey(wsName), JSON.stringify(state));
  } catch {
    // View-state persistence is a convenience only; a storage quota/privacy
    // failure must never make the PDF viewer unusable.
  }
}

export type PdfSourceLocation = {
  projectPath: ProjectFilePath;
  line: number;
  column?: number;
};

export type ProjectPdfWorkspaceState = {
  wsName: string;
  rootFile: string;
  pdf: Uint8Array;
  compileResult?: LatexCompileResult;
  origin: 'manual' | 'live';
  generation: number;
  synctexAvailable: boolean;
  synctexError?: string;
  synctexUnavailableReason?: string;
  highlight?: SyncTexForwardResult;
  sourceLocation?: PdfSourceLocation;
  activeSourcePath?: ProjectFilePath;
  activeSourcePages: number[];
};

type WorkspaceRuntime = {
  state: ProjectPdfWorkspaceState;
  index?: SyncTexIndex;
};

function normalizePath(path: string): string {
  return path.replaceAll('\\', '/').replace(/^\.\/+/, '').replace(/^\/+/, '');
}

function pathMatchScore(candidate: string, raw: string): number {
  const normalizedCandidate = normalizePath(candidate);
  const normalizedRaw = normalizePath(raw);
  if (normalizedCandidate === normalizedRaw) return 1000;
  if (normalizedRaw.endsWith(`/${normalizedCandidate}`)) return 900;
  if (normalizedCandidate.endsWith(`/${normalizedRaw}`)) return 850;
  const candidateBase = normalizedCandidate.split('/').at(-1);
  const rawBase = normalizedRaw.split('/').at(-1);
  return candidateBase && candidateBase === rawBase ? 100 : 0;
}

/**
 * Owns the last-known-good PDF and the SyncTeX index produced by the exact same
 * successful compile. A failed compile never replaces either half of that pair.
 */
export class ProjectPdfService extends BaseService {
  static deps = ['projectCompile', 'projectStorage', 'workspaceOps', 'latexSource'] as const;

  readonly $version = atom(0);
  readonly $preferences = atom<PdfSyncPreferences>({
    pdfFollowsSource: true,
    sourceFollowsPdf: false,
  });

  /** Last successful explicit/manual compile for each workspace. */
  private readonly workspaces = new Map<string, WorkspaceRuntime>();
  /** Last successful Live PDF generation. When present it is the displayed/sync authority. */
  private readonly liveWorkspaces = new Map<string, WorkspaceRuntime>();
  private readonly liveParseGeneration = new Map<string, number>();
  private readonly activeSources = new Map<string, ProjectFilePath>();
  private readonly parseGeneration = new Map<string, number>();
  private readonly viewStates = new Map<string, ProjectPdfViewState>();
  private readonly scrollOwnership = new Map<string, PdfScrollOwnershipState>();
  private readonly viewStateWrites = new Map<string, ReturnType<typeof setTimeout>>();

  constructor(
    context: BaseServiceContext,
    private readonly dependencies: {
      projectCompile: ProjectCompileService;
      projectStorage: ProjectStorageService;
      workspaceOps: WorkspaceOpsService;
      latexSource: LatexSourceService;
    },
  ) {
    super(SERVICE_NAME.projectPdfService, context, dependencies);
  }

  hookMount(): void {
    const unsubscribe = this.store.sub(this.dependencies.projectCompile.$state, () => {
      const compileState = this.store.get(this.dependencies.projectCompile.$state);
      const result = compileState.result;
      if (
        compileState.status !== 'success' ||
        !compileState.wsName ||
        !compileState.rootFile ||
        !result?.success ||
        !result.pdf
      ) {
        return;
      }
      void this.acceptSuccessfulCompile(
        compileState.wsName,
        compileState.rootFile,
        result,
      );
    });
    this.addCleanup(unsubscribe);
    const unsubscribeWorkspace = this.store.sub(
      this.dependencies.workspaceOps.$workspaceInfoChange,
      () => {
        this.workspaces.clear();
        this.liveWorkspaces.clear();
        this.liveParseGeneration.clear();
        this.activeSources.clear();
        this.parseGeneration.clear();
        this.viewStates.clear();
        this.scrollOwnership.clear();
        for (const timer of this.viewStateWrites.values()) clearTimeout(timer);
        this.viewStateWrites.clear();
        this.bump();
      },
    );
    this.addCleanup(unsubscribeWorkspace);
    this.addCleanup(() => {
      this.workspaces.clear();
      this.liveWorkspaces.clear();
      this.liveParseGeneration.clear();
      this.activeSources.clear();
      this.parseGeneration.clear();
      this.viewStates.clear();
      this.scrollOwnership.clear();
      for (const timer of this.viewStateWrites.values()) clearTimeout(timer);
      this.viewStateWrites.clear();
    });
  }

  getWorkspaceState(wsName: string): ProjectPdfWorkspaceState | undefined {
    return this.activeRuntime(wsName)?.state;
  }

  private activeRuntime(wsName: string): WorkspaceRuntime | undefined {
    return this.liveWorkspaces.get(wsName) ?? this.workspaces.get(wsName);
  }

  isLivePreviewActive(wsName: string): boolean {
    return this.liveWorkspaces.has(wsName);
  }

  async getViewState(wsName: string): Promise<ProjectPdfViewState> {
    const cached = this.viewStates.get(wsName);
    if (cached) return cached;
    const raw = readPersistedPdfViewState(wsName);
    const state = isPdfViewState(raw)
      ? {
          page: Math.max(1, Math.trunc(raw.page)),
          scrollTop: Math.max(0, raw.scrollTop),
          zoom: Math.max(0.25, Math.min(4, raw.zoom)),
          fitMode: raw.fitMode,
          outputTab: raw.outputTab,
        }
      : { ...DEFAULT_PDF_VIEW_STATE };
    this.viewStates.set(wsName, state);
    return state;
  }

  updateViewState(wsName: string, update: Partial<ProjectPdfViewState>): void {
    const current = this.viewStates.get(wsName) ?? { ...DEFAULT_PDF_VIEW_STATE };
    const next: ProjectPdfViewState = {
      page: Math.max(1, Math.trunc(update.page ?? current.page)),
      scrollTop: Math.max(0, update.scrollTop ?? current.scrollTop),
      zoom: Math.max(0.25, Math.min(4, update.zoom ?? current.zoom)),
      fitMode: update.fitMode ?? current.fitMode,
      outputTab: update.outputTab ?? current.outputTab,
    };
    this.viewStates.set(wsName, next);
    const previous = this.viewStateWrites.get(wsName);
    if (previous) clearTimeout(previous);
    this.viewStateWrites.set(wsName, setTimeout(() => {
      this.viewStateWrites.delete(wsName);
      persistPdfViewState(wsName, next);
    }, 180));
  }

  getPreferences(): PdfSyncPreferences {
    return this.store.get(this.$preferences);
  }

  setPreferences(update: Partial<PdfSyncPreferences>): void {
    this.store.set(this.$preferences, {
      ...this.store.get(this.$preferences),
      ...update,
    });
  }

  claimScrollOwnership(wsName: string, owner: PdfScrollOwnership): void {
    const current = this.scrollOwnership.get(wsName);
    const upstreamUser = owner.endsWith('-user')
      ? owner as Extract<PdfScrollOwnership, `${string}-user`>
      : current?.upstreamUser;
    this.scrollOwnership.set(wsName, {
      owner,
      ...(upstreamUser ? { upstreamUser } : {}),
    });
  }

  getScrollOwnership(wsName: string): PdfScrollOwnership | undefined {
    return this.scrollOwnership.get(wsName)?.owner;
  }

  isScrollOwnedByUser(wsName: string, surface: PdfScrollSurface): boolean {
    return this.scrollOwnership.get(wsName)?.upstreamUser === `${surface}-user`;
  }

  setActiveSourceFile(wsName: string, projectPath: ProjectFilePath): void {
    this.activeSources.set(wsName, projectPath);
    let changed = false;
    for (const runtime of [this.workspaces.get(wsName), this.liveWorkspaces.get(wsName)]) {
      if (!runtime) continue;
      const pages = runtime.index?.pagesForFile(projectPath) ?? [];
      runtime.state = {
        ...runtime.state,
        activeSourcePath: projectPath,
        activeSourcePages: pages,
      };
      changed = true;
    }
    if (changed) this.bump();
  }

  getMappingsForFile(wsName: string, projectPath: ProjectFilePath): SyncTexFileMapping[] {
    return this.activeRuntime(wsName)?.index?.mappingsForFile(projectPath) ?? [];
  }

  forwardSync(
    wsName: string,
    projectPath: ProjectFilePath,
    line: number,
    column?: number,
    options: { force?: boolean } = {},
  ): SyncTexForwardResult | null {
    if (!options.force && !this.getPreferences().pdfFollowsSource) return null;
    const runtime = this.activeRuntime(wsName);
    const mapped = runtime?.index?.forward(projectPath, line, column) ?? null;
    if (!runtime || !mapped) return null;
    runtime.state = {
      ...runtime.state,
      highlight: mapped,
      sourceLocation: {
        projectPath,
        line: mapped.line,
        ...(column === undefined ? {} : { column }),
      },
      activeSourcePath: projectPath,
      activeSourcePages: runtime.index?.pagesForFile(projectPath) ?? [],
    };
    this.bump();
    return mapped;
  }

  async reverseSync(
    wsName: string,
    page: number,
    x: number,
    y: number,
    options: { force?: boolean } = {},
  ): Promise<PdfSourceLocation | null> {
    if (!options.force && !this.getPreferences().sourceFollowsPdf) return null;
    const runtime = this.activeRuntime(wsName);
    const mapped = runtime?.index?.reverse(page, x, y) ?? null;
    if (!runtime || !mapped) return null;
    const projectPath = await this.resolveProjectPath(wsName, mapped);
    if (!projectPath) return null;
    const location: PdfSourceLocation = {
      projectPath,
      line: mapped.line,
      ...(mapped.column === undefined ? {} : { column: mapped.column }),
    };
    runtime.state = {
      ...runtime.state,
      sourceLocation: location,
      activeSourcePath: projectPath,
      activeSourcePages: runtime.index?.pagesForFile(projectPath) ?? [],
    };
    this.bump();
    return location;
  }

  clearHighlight(wsName: string): void {
    const runtime = this.activeRuntime(wsName);
    if (!runtime?.state.highlight) return;
    const next = { ...runtime.state };
    delete next.highlight;
    runtime.state = next;
    this.bump();
  }

  /**
   * Atomically accept one Live PDF generation. The PDF and SyncTeX bytes
   * originate from the same browser or remembered-cloud compile and are never mixed with the
   * manual runtime or another live generation. PDF bytes publish immediately;
   * the same-generation SyncTeX index is attached asynchronously when ready.
   */
  async acceptLivePreview(
    wsName: string,
    rootFile: string,
    generation: number,
    pdf: Uint8Array,
    synctex?: Uint8Array,
  ): Promise<void> {
    if (!pdf.byteLength) return;
    const currentGeneration = this.liveParseGeneration.get(wsName) ?? 0;
    if (generation < currentGeneration) return;
    this.liveParseGeneration.set(wsName, generation);
    const activeSourcePath = this.activeSources.get(wsName);
    const baseState: ProjectPdfWorkspaceState = {
      wsName,
      rootFile,
      pdf,
      origin: 'live',
      generation,
      synctexAvailable: false,
      ...(activeSourcePath ? { activeSourcePath } : {}),
      activeSourcePages: [],
    };

    if (generation !== this.liveParseGeneration.get(wsName)) return;
    // Publish the same-generation PDF immediately. SyncTeX decompression/indexing
    // can be expensive on large papers and must not block PDF.js from starting
    // to paint the new preview. Navigation stays disabled until this generation's
    // map is ready, so PDF and SyncTeX authority are still never mixed.
    this.liveWorkspaces.set(wsName, {
      state: {
        ...baseState,
        synctexUnavailableReason: synctex?.byteLength
          ? 'Preparing the Live PDF SyncTeX map.'
          : 'The Live PDF compile did not emit a SyncTeX map.',
      },
    });
    this.bump();
    if (!synctex?.byteLength) return;

    // Yield one browser task so React/PDF.js can observe and begin loading the
    // fresh PDF before CPU-heavy SyncTeX indexing resumes on large documents.
    await new Promise<void>((resolve) => setTimeout(resolve, 0));
    if (generation !== this.liveParseGeneration.get(wsName)) return;

    try {
      const index = await parseSyncTex(synctex);
      if (generation !== this.liveParseGeneration.get(wsName)) return;
      const cursor = activeSourcePath
        ? this.dependencies.latexSource.getCursorLocation(
            WsPath.fromParts(wsName, activeSourcePath).wsPath,
          )
        : undefined;
      const cursorHighlight = activeSourcePath && cursor
        ? index.forward(activeSourcePath, cursor.line, cursor.column)
        : null;
      this.liveWorkspaces.set(wsName, {
        index,
        state: {
          ...baseState,
          synctexAvailable: true,
          ...(activeSourcePath
            ? {
                activeSourcePath,
                activeSourcePages: index.pagesForFile(activeSourcePath),
              }
            : {}),
          ...(cursorHighlight
            ? {
                highlight: cursorHighlight,
                sourceLocation: {
                  projectPath: activeSourcePath!,
                  line: cursorHighlight.line,
                  ...(cursor?.column === undefined ? {} : { column: cursor.column }),
                },
              }
            : {}),
        },
      });
      this.bump();
    } catch (error) {
      if (generation !== this.liveParseGeneration.get(wsName)) return;
      this.liveWorkspaces.set(wsName, {
        state: {
          ...baseState,
          synctexError: error instanceof Error ? error.message : 'Unable to parse Live SyncTeX output',
        },
      });
      this.bump();
    }
  }

  clearLivePreview(wsName: string): void {
    this.liveParseGeneration.set(wsName, (this.liveParseGeneration.get(wsName) ?? 0) + 1);
    if (!this.liveWorkspaces.delete(wsName)) return;
    this.bump();
  }

  private async acceptSuccessfulCompile(
    wsName: string,
    rootFile: string,
    result: LatexCompileResult,
  ): Promise<void> {
    if (!result.pdf) return;
    const generation = (this.parseGeneration.get(wsName) ?? 0) + 1;
    this.parseGeneration.set(wsName, generation);
    const activeSourcePath = this.activeSources.get(wsName);
    const baseState: ProjectPdfWorkspaceState = {
      wsName,
      rootFile,
      pdf: result.pdf,
      compileResult: result,
      origin: 'manual',
      generation,
      synctexAvailable: false,
      ...(activeSourcePath ? { activeSourcePath } : {}),
      activeSourcePages: [],
    };

    if (!result.synctex) {
      const synctexUnavailableReason =
        result.compiler === 'pdflatex' && result.compileMode === 'fast'
          ? 'Fast Preview uses SwiftLaTeX and does not emit SyncTeX. Compile in Full / SyncTeX mode to enable source mapping.'
          : 'The selected browser compiler completed successfully but did not emit a SyncTeX map.';
      this.workspaces.set(wsName, {
        state: { ...baseState, synctexUnavailableReason },
      });
      this.bump();
      return;
    }

    try {
      const index = await parseSyncTex(result.synctex);
      if (generation !== this.parseGeneration.get(wsName)) return;
      const previousActive = activeSourcePath;
      const cursor = previousActive
        ? this.dependencies.latexSource.getCursorLocation(
            // LatexSourceService stores cursor locations by canonical workspace path.
            WsPath.fromParts(wsName, previousActive).wsPath,
          )
        : undefined;
      const cursorHighlight = previousActive && cursor
        ? index.forward(previousActive, cursor.line, cursor.column)
        : null;
      const state: ProjectPdfWorkspaceState = {
        ...baseState,
        synctexAvailable: true,
        ...(previousActive
          ? {
              activeSourcePath: previousActive,
              activeSourcePages: index.pagesForFile(previousActive),
            }
          : {}),
        ...(cursorHighlight
          ? {
              highlight: cursorHighlight,
              sourceLocation: {
                projectPath: previousActive!,
                line: cursorHighlight.line,
                ...(cursor?.column === undefined ? {} : { column: cursor.column }),
              },
            }
          : {}),
      };
      this.workspaces.set(wsName, { state, index });
      this.bump();
    } catch (error) {
      if (generation !== this.parseGeneration.get(wsName)) return;
      this.workspaces.set(wsName, {
        state: {
          ...baseState,
          synctexError:
            error instanceof Error ? error.message : 'Unable to parse SyncTeX output',
        },
      });
      this.bump();
    }
  }

  private async resolveProjectPath(
    wsName: string,
    mapped: SyncTexReverseResult,
  ): Promise<ProjectFilePath | undefined> {
    const storage = await this.dependencies.projectStorage.getProvider(wsName);
    const files = (await storage.listEntries()).filter(
      (entry): entry is Extract<ProjectEntry, { kind: 'file' }> => entry.kind === 'file',
    );
    let bestScore = 0;
    let bestPath: ProjectFilePath | undefined;
    let ambiguous = false;
    for (const file of files) {
      const score = pathMatchScore(file.path, mapped.file);
      if (score > bestScore) {
        bestScore = score;
        bestPath = file.path;
        ambiguous = false;
      } else if (score > 0 && score === bestScore && file.path !== bestPath) {
        ambiguous = true;
      }
    }
    if (bestScore === 0 || ambiguous || !bestPath) return undefined;
    return projectFilePath(bestPath);
  }

  private bump(): void {
    this.store.set(this.$version, this.store.get(this.$version) + 1);
  }
}
