const REMOTE_CACHE_NAME_PREFIX = 'interactive-busytex-remote-v3-';
const REMOTE_CACHE_PREFIX = '/__learnitive/busytex-remote/';
const MISSES_CACHE_KEY = `${REMOTE_CACHE_PREFIX}.misses.json`;

export type BusyProjectFile = {
  path: string;
  content: Uint8Array | string;
};

export type TexliveRemoteFile = {
  name: string;
  format?: number;
  content: Uint8Array;
};

export type RemoteCacheRunner = {
  readProjectFiles?(path?: string): Promise<BusyProjectFile[]>;
  writeTexliveRemoteFiles?(files: TexliveRemoteFile[]): Promise<void>;
  writeTexliveRemoteMisses?(misses: string[]): Promise<void>;
};

export type RemoteCacheRestoreResult = {
  restoredNames: ReadonlySet<string>;
  rejectedNames: readonly string[];
};

export type RemoteCachePersistResult = {
  persistedNames: readonly string[];
  invalidNames: readonly string[];
  scopeLeakDetected: boolean;
};

export type RemoteCachePersistOptions = {
  /** Project-local names must never be persisted as TeX Live remote files. */
  projectFileNames?: ReadonlySet<string>;
};

function toBytes(content: Uint8Array | string): Uint8Array {
  return typeof content === 'string' ? new TextEncoder().encode(content) : content;
}

const TEXLIVE_TEXT_EXTENSIONS = new Set([
  'aux',
  'bbl',
  'bcf',
  'blg',
  'cfg',
  'clo',
  'cls',
  'def',
  'fd',
  'idx',
  'ilg',
  'ind',
  'ldf',
  'lof',
  'log',
  'lot',
  'lua',
  'nav',
  'out',
  'snm',
  'sty',
  'tex',
  'toc',
  'txt',
  'vrb',
  'xml',
]);

const PROJECT_GENERATED_SUFFIXES = [
  '.aux',
  '.bbl',
  '.bcf',
  '.blg',
  '.fdb_latexmk',
  '.fls',
  '.idx',
  '.ilg',
  '.ind',
  '.lof',
  '.log',
  '.lot',
  '.nav',
  '.out',
  '.run.xml',
  '.snm',
  '.synctex',
  '.synctex.gz',
  '.toc',
  '.vrb',
] as const;

export function isProjectGeneratedTexArtifactName(name: string): boolean {
  const normalized = name.replace(/\\/gu, '/');
  const base = normalized.slice(normalized.lastIndexOf('/') + 1).toLowerCase();
  return PROJECT_GENERATED_SUFFIXES.some((suffix) => base.endsWith(suffix));
}

function looksLikeHtml(content: Uint8Array): boolean {
  const prefix = new TextDecoder()
    .decode(content.slice(0, 1024))
    .replace(/^\uFEFF/u, '')
    .trimStart()
    .toLowerCase();
  return (
    prefix.startsWith('<!doctype html') ||
    prefix.startsWith('<html') ||
    prefix.startsWith('<head') ||
    prefix.startsWith('<body') ||
    prefix.startsWith('<?xml') && prefix.includes('<html') ||
    prefix.includes('<html ')
  );
}

/**
 * Validate TeX Live package bytes before they are ever mounted into a compiler.
 * Text-like TeX inputs that contain an HTML error page (or are empty) are
 * rejected. Binary formats are accepted when non-empty.
 */
export function isSafeTexliveRemoteFile(file: TexliveRemoteFile): boolean {
  if (file.content.byteLength === 0) return false;
  if (isProjectGeneratedTexArtifactName(file.name)) return false;
  // HTML is never a valid TeX Live payload, regardless of the requested
  // extension. The TeX Live on-demand endpoint may return a branded HTML page
  // with HTTP 200 for a missing name; allowing that into the remote cache is
  // what poisoned main.aux and produced the false `Missing \begin{document}`.
  if (looksLikeHtml(file.content)) return false;
  const extension = file.name.includes('.')
    ? file.name.slice(file.name.lastIndexOf('.') + 1).toLowerCase()
    : '';
  return !TEXLIVE_TEXT_EXTENSIONS.has(extension) || file.content.byteLength > 0;
}

function parseRemoteFiles(files: readonly BusyProjectFile[]): TexliveRemoteFile[] {
  const result: TexliveRemoteFile[] = [];
  for (const file of files) {
    const base = file.path.slice(file.path.lastIndexOf('/') + 1);
    if (!base || base === '.misses.json') continue;
    const match = base.match(/^(\d+)_(.+)$/u);
    const content = toBytes(file.content).slice();
    if (match?.[1] && match[2]) {
      result.push({
        name: match[2],
        format: Number.parseInt(match[1], 10),
        content,
      });
    } else {
      result.push({ name: base, content });
    }
  }
  return result;
}

function remoteCacheKey(file: TexliveRemoteFile): string {
  const format = file.format == null ? 'none' : String(file.format);
  return `${REMOTE_CACHE_PREFIX}${format}/${encodeURIComponent(file.name)}`;
}

function remoteCacheName(assetVersion?: string): string {
  const safeVersion = (assetVersion || 'unknown').replace(/[^a-z0-9._-]+/giu, '-');
  return `${REMOTE_CACHE_NAME_PREFIX}${safeVersion}`;
}

async function openRemoteCache(assetVersion?: string): Promise<Cache | undefined> {
  if (typeof caches === 'undefined') return undefined;
  try {
    return await caches.open(remoteCacheName(assetVersion));
  } catch {
    return undefined;
  }
}

/**
 * Restore validated package bytes only. Poisoned entries are deleted before
 * the runner receives any files, so an HTML error document can never be sent
 * to TeX as a cached .sty/.cls/.tex input.
 */
export async function restoreRemoteCache(
  runner: RemoteCacheRunner,
  assetVersion?: string,
): Promise<RemoteCacheRestoreResult> {
  const restoredNames = new Set<string>();
  const rejectedNames: string[] = [];
  if (!runner.writeTexliveRemoteFiles && !runner.writeTexliveRemoteMisses) {
    return { restoredNames, rejectedNames };
  }
  const cache = await openRemoteCache(assetVersion);
  if (!cache) return { restoredNames, rejectedNames };

  if (runner.writeTexliveRemoteFiles) {
    const restored: TexliveRemoteFile[] = [];
    for (const request of await cache.keys()) {
      const url = new URL(request.url);
      if (!url.pathname.startsWith(REMOTE_CACHE_PREFIX)) continue;
      if (url.pathname === MISSES_CACHE_KEY) continue;
      const relative = url.pathname.slice(REMOTE_CACHE_PREFIX.length);
      const slash = relative.indexOf('/');
      if (slash < 0) continue;
      const formatPart = relative.slice(0, slash);
      const name = decodeURIComponent(relative.slice(slash + 1));
      const response = await cache.match(request);
      if (!response) continue;
      const content = new Uint8Array(await response.arrayBuffer());
      const file: TexliveRemoteFile = {
        name,
        ...(formatPart === 'none'
          ? {}
          : { format: Number.parseInt(formatPart, 10) }),
        content,
      };
      if (!isSafeTexliveRemoteFile(file)) {
        rejectedNames.push(name);
        await cache.delete(request);
        continue;
      }
      restored.push(file);
      restoredNames.add(name);
    }
    if (restored.length > 0) {
      await runner.writeTexliveRemoteFiles(restored);
    }
  }

  if (runner.writeTexliveRemoteMisses) {
    // Negative remote misses are intentionally not persisted across sessions.
    // A missing name may be a project-local file on the next compile (for
    // example main.tex), and restoring that stale miss can suppress a valid
    // lookup. Positive validated package bytes are safe to persist; misses are
    // cheap to rediscover and correctness is more important than this cache.
    await cache.delete(MISSES_CACHE_KEY);
    await runner.writeTexliveRemoteMisses([]);
  }

  return { restoredNames, rejectedNames };
}

function basename(path: string): string {
  const normalized = path.replace(/\\/gu, '/').replace(/\/+$/gu, '');
  return normalized.slice(normalized.lastIndexOf('/') + 1);
}

export async function persistRemoteCache(
  runner: RemoteCacheRunner,
  assetVersion?: string,
  options: RemoteCachePersistOptions = {},
): Promise<RemoteCachePersistResult> {
  if (!runner.readProjectFiles) {
    return { persistedNames: [], invalidNames: [], scopeLeakDetected: false };
  }
  const cache = await openRemoteCache(assetVersion);
  if (!cache) {
    return { persistedNames: [], invalidNames: [], scopeLeakDetected: false };
  }

  try {
    const workFiles = await runner.readProjectFiles('/tmp/texlive_remote');
    const projectFileNames = options.projectFileNames ?? new Set<string>();

    // Some older BusyTeX pipelines ignore the directory argument passed to
    // read_project_files() and return the project working tree instead. If a
    // project-local filename appears in this supposedly remote-only readback,
    // the scope is untrustworthy. Never persist any of those bytes as TeX Live
    // cache entries: package persistence is optional, compilation is not.
    const scopeLeakDetected = workFiles.some((file) =>
      projectFileNames.has(basename(file.path)),
    );
    if (scopeLeakDetected) {
      return { persistedNames: [], invalidNames: [], scopeLeakDetected: true };
    }

    const remoteFiles = parseRemoteFiles(workFiles).filter(
      (file) => !projectFileNames.has(file.name),
    );
    const invalid = remoteFiles.filter((file) => !isSafeTexliveRemoteFile(file));
    for (const file of invalid) {
      await cache.delete(remoteCacheKey(file));
    }
    const valid = remoteFiles.filter(isSafeTexliveRemoteFile);
    for (const file of valid) {
      await cache.put(
        remoteCacheKey(file),
        new Response(file.content.slice(), {
          headers: { 'content-type': 'application/octet-stream' },
        }),
      );
    }
    // Do not persist negative misses. They are runtime-local hints, not package
    // artifacts, and can become false for project files or after endpoint
    // updates. Keep an empty compatibility entry only.
    await cache.put(
      MISSES_CACHE_KEY,
      new Response('[]', {
        headers: { 'content-type': 'application/json' },
      }),
    );
    return {
      persistedNames: valid.map((file) => file.name),
      invalidNames: invalid.map((file) => file.name),
      scopeLeakDetected: false,
    };
  } catch {
    // Package persistence is an optimization. Compilation results remain valid
    // even when browser storage is unavailable or quota-constrained.
    return { persistedNames: [], invalidNames: [], scopeLeakDetected: false };
  }
}
