import { BaseService, type BaseServiceContext } from '@learnitive/base-utils';
import { SERVICE_NAME } from '@learnitive/constants';
import {
  classifyScientificArtifact,
  collectScientificBrowserDiagnostics,
  diffRuntimeFiles,
  hashRuntimeBytes,
  RuntimePackageRegistry,
  RuntimeWorkerManager,
  type RuntimeExecutionRequest,
  type RuntimeExecutionResult,
  type RuntimeFileFingerprint,
  type RuntimeWorkspacePort,
  type RuntimePackageDefinition,
  type ScientificBrowserDiagnostics,
  type ScientificRuntimeFactory,
  type ScientificRuntimeId,
  type ScientificRuntimeInstanceState,
  type ScientificRuntimeSession,
} from '@learnitive/scientific-runtime';
import { projectFilePath } from '@learnitive/project-storage';
import type { ProjectStorageService } from './project-storage-service';
import type { FileSystemService } from './file-system-service';
import type { LatexSourceService } from './latex-source-service';
import type { ProjectCollaborationService } from './project-collaboration-service';
import type { WorkspaceOpsService } from './workspace-ops-service';
import {
  getGeneratedResearchArtifacts,
  writeGeneratedResearchArtifact,
  type GeneratedResearchArtifact,
} from './research-artifact-files';

export type RuntimeWorkspaceLimits = {
  maxInputFileBytes: number;
  maxTotalInputBytes: number;
};

export type RuntimeWorkspaceSkippedFile = {
  path: string;
  size: number;
  reason: 'file-too-large' | 'workspace-limit';
};

export type RuntimeWorkspaceSnapshot = {
  fingerprints: readonly RuntimeFileFingerprint[];
  skipped: readonly RuntimeWorkspaceSkippedFile[];
  mirroredBytes: number;
};

export type RuntimeArtifactSyncResult = {
  createdFiles: readonly string[];
  modifiedFiles: readonly string[];
  blockedModifiedFiles: readonly string[];
  blockedCreatedFiles: readonly string[];
  deletedFiles: readonly string[];
  artifacts: readonly GeneratedResearchArtifact[];
};

export type ScientificExecutionOptions = {
  signal?: AbortSignal;
  commandLabel?: string;
  sourcePath?: string;
  allowOverwritePaths?: readonly string[];
  allowCreatePaths?: readonly string[];
  workspaceLimits?: Partial<RuntimeWorkspaceLimits>;
  inputPaths?: readonly string[];
  executionArgs?: readonly string[];
};

export type ProjectScientificRuntimeDiagnostics = Omit<ScientificBrowserDiagnostics, 'runtimes'> & {
  workspaceLimits: RuntimeWorkspaceLimits;
  runtimes: readonly (ScientificBrowserDiagnostics['runtimes'][number] & {
    registered: boolean;
    label: string;
    state: ScientificRuntimeInstanceState['status'] | 'not-loaded';
    error?: string;
  })[];
};

export type ScientificExecutionResult = RuntimeExecutionResult & {
  workspace: {
    skippedInputFiles: readonly RuntimeWorkspaceSkippedFile[];
    mirroredBytes: number;
    createdFiles: readonly string[];
    modifiedFiles: readonly string[];
    blockedModifiedFiles: readonly string[];
    blockedCreatedFiles: readonly string[];
    deletedFiles: readonly string[];
  };
  artifacts: readonly GeneratedResearchArtifact[];
};

export const DEFAULT_RUNTIME_WORKSPACE_LIMITS: RuntimeWorkspaceLimits = {
  maxInputFileBytes: 128 * 1024 * 1024,
  maxTotalInputBytes: 512 * 1024 * 1024,
};

function normalizeProjectRelativePath(input: string): string {
  const stripped = input.startsWith('/workspace/')
    ? input.slice('/workspace/'.length)
    : input === '/workspace'
      ? ''
      : input.replace(/^\/+/, '');
  if (!stripped) return '';
  return String(projectFilePath(stripped));
}

function parentDirectories(path: string): string[] {
  const parts = path.split('/');
  const directories: string[] = [];
  for (let index = 1; index < parts.length; index += 1) {
    directories.push(parts.slice(0, index).join('/'));
  }
  return directories;
}

function isRuntimeNoisePath(path: string): boolean {
  const normalized = path.replace(/^\/+/, '');
  const segments = normalized.split('/');
  return (
    segments.includes('.git') ||
    segments.includes('__pycache__') ||
    segments.includes('.pytest_cache') ||
    segments.includes('.Rproj.user') ||
    normalized === '.Rhistory' ||
    normalized === '.RData'
  );
}

async function fingerprintBytes(path: string, bytes: Uint8Array): Promise<RuntimeFileFingerprint> {
  return { path, size: bytes.byteLength, hash: hashRuntimeBytes(bytes) };
}

/**
 * Mirrors canonical Learnitive project files into a scientific runtime's isolated
 * /workspace. Whole-file reads are bounded until ProjectStorage gains a
 * streaming read contract; skipped files are reported to callers rather than
 * silently disappearing.
 */
export class RuntimeWorkspaceBridge {
  constructor(private readonly projectStorage: ProjectStorageService) {}

  async mirrorProject(
    wsName: string,
    runtimeWorkspace: RuntimeWorkspacePort,
    options: {
      limits?: Partial<RuntimeWorkspaceLimits>;
      signal?: AbortSignal;
    } = {},
  ): Promise<RuntimeWorkspaceSnapshot> {
    const signal = options.signal;
    const limits = { ...DEFAULT_RUNTIME_WORKSPACE_LIMITS, ...options.limits };
    signal?.throwIfAborted();
    await runtimeWorkspace.reset(signal);

    const provider = await this.projectStorage.getProvider(wsName);
    const entries = await provider.listEntries(signal);
    const skipped: RuntimeWorkspaceSkippedFile[] = [];
    const fingerprints: RuntimeFileFingerprint[] = [];
    const ensuredDirectories = new Set<string>();
    let mirroredBytes = 0;

    const ensureDirectory = async (path: string) => {
      if (!path || ensuredDirectories.has(path)) return;
      await runtimeWorkspace.ensureDirectory(path, signal);
      ensuredDirectories.add(path);
    };

    for (const entry of entries) {
      signal?.throwIfAborted();
      const path = String(entry.path).replace(/\/$/u, '');
      if (!path || isRuntimeNoisePath(path)) continue;
      if (entry.kind === 'directory') {
        await ensureDirectory(path);
        continue;
      }
      if (entry.size > limits.maxInputFileBytes) {
        skipped.push({ path, size: entry.size, reason: 'file-too-large' });
        continue;
      }
      if (mirroredBytes + entry.size > limits.maxTotalInputBytes) {
        skipped.push({ path, size: entry.size, reason: 'workspace-limit' });
        continue;
      }
      for (const directory of parentDirectories(path)) await ensureDirectory(directory);
      const bytes = await provider.readFile(entry.path, signal);
      if (!bytes) continue;
      await runtimeWorkspace.writeFile(path, bytes, signal);
      fingerprints.push(await fingerprintBytes(path, bytes));
      mirroredBytes += bytes.byteLength;
    }

    return { fingerprints, skipped, mirroredBytes };
  }

  async snapshotRuntime(
    runtimeWorkspace: RuntimeWorkspacePort,
    signal?: AbortSignal,
  ): Promise<readonly RuntimeFileFingerprint[]> {
    const result: RuntimeFileFingerprint[] = [];
    for (const entry of await runtimeWorkspace.listFiles(signal)) {
      signal?.throwIfAborted();
      const path = normalizeProjectRelativePath(entry.path);
      if (!path || isRuntimeNoisePath(path)) continue;
      const bytes = await runtimeWorkspace.readFile(path, signal);
      if (!bytes) continue;
      result.push(await fingerprintBytes(path, bytes));
    }
    return result;
  }
}

/**
 * Pulls runtime-created research artifacts back into canonical project
 * storage. Existing ordinary project files are never silently overwritten.
 * Previously generated artifacts may be refreshed, and callers may explicitly
 * allow additional overwrite paths after a review/confirmation step.
 */
export class RuntimeArtifactCollector {
  constructor(
    private readonly projectStorage: ProjectStorageService,
    private readonly fileSystem: FileSystemService,
    private readonly latexSource: LatexSourceService,
    private readonly workspaceOps: WorkspaceOpsService,
    private readonly projectCollaboration: ProjectCollaborationService,
  ) {}

  async collect(
    wsName: string,
    session: ScientificRuntimeSession,
    before: RuntimeWorkspaceSnapshot,
    execution: RuntimeExecutionResult,
    options: {
      sourcePath?: string;
      commandLabel?: string;
      allowOverwritePaths?: readonly string[];
      allowCreatePaths?: readonly string[];
      signal?: AbortSignal;
      inputPaths?: readonly string[];
      executionArgs?: readonly string[];
    } = {},
  ): Promise<RuntimeArtifactSyncResult> {
    const signal = options.signal;
    if (this.projectCollaboration.isActive(wsName)) {
      throw new Error('Generated project artifacts cannot be synchronized while live collaboration is active.');
    }
    const bridge = new RuntimeWorkspaceBridge(this.projectStorage);
    const after = await bridge.snapshotRuntime(session.workspace, signal);
    const delta = diffRuntimeFiles(before.fingerprints, after);
    const existingArtifacts = await getGeneratedResearchArtifacts(this.workspaceOps, wsName);
    const generatedPaths = new Set(existingArtifacts.map((item) => item.path));
    const allowOverwrite = new Set(
      (options.allowOverwritePaths ?? []).map(normalizeProjectRelativePath).filter(Boolean),
    );
    const allowCreate = options.allowCreatePaths === undefined
      ? undefined
      : new Set(options.allowCreatePaths.map(normalizeProjectRelativePath).filter(Boolean));
    const plotPaths = new Set(
      (execution.plots ?? [])
        .map((plot) => (plot.path ? normalizeProjectRelativePath(plot.path) : ''))
        .filter(Boolean),
    );
    const beforeFingerprints = new Map(before.fingerprints.map((item) => [item.path, item.hash] as const));
    const normalizedSourcePath = options.sourcePath ? normalizeProjectRelativePath(options.sourcePath) : undefined;
    const normalizedInputPaths = (options.inputPaths ?? []).map(normalizeProjectRelativePath).filter(Boolean);
    const inputFingerprints = Object.fromEntries(
      normalizedInputPaths.flatMap((path) => {
        const hash = beforeFingerprints.get(path);
        return hash ? [[path, hash] as const] : [];
      }),
    );

    const artifacts: GeneratedResearchArtifact[] = [];
    const createdFiles: string[] = [];
    const modifiedFiles: string[] = [];
    const blockedModifiedFiles: string[] = [];
    const blockedCreatedFiles: string[] = [];

    const sync = async (path: string, modified: boolean) => {
      if (!modified && allowCreate && !allowCreate.has(path)) {
        blockedCreatedFiles.push(path);
        return;
      }
      if (modified && !generatedPaths.has(path) && !allowOverwrite.has(path)) {
        blockedModifiedFiles.push(path);
        return;
      }
      const bytes = await session.workspace.readFile(path, signal);
      if (!bytes) return;
      const artifact = await writeGeneratedResearchArtifact(
        { fileSystem: this.fileSystem, latexSource: this.latexSource, workspaceOps: this.workspaceOps },
        wsName,
        path,
        bytes,
        {
          kind: classifyScientificArtifact(path, plotPaths),
          generatedBy: {
            runtime: execution.runtime,
            runId: `${execution.runtime}:${execution.startedAt}`,
            ...(options.commandLabel ? { command: options.commandLabel } : {}),
            ...(normalizedSourcePath ? {
              sourcePath: normalizedSourcePath,
              ...(beforeFingerprints.get(normalizedSourcePath) ? { sourceFingerprint: beforeFingerprints.get(normalizedSourcePath)! } : {}),
            } : {}),
            ...(options.executionArgs?.length ? { args: [...options.executionArgs] } : {}),
            ...(normalizedInputPaths.length ? {
              inputPaths: normalizedInputPaths,
              ...(Object.keys(inputFingerprints).length ? { inputFingerprints } : {}),
            } : {}),
          },
        },
      );
      artifacts.push(artifact);
      (modified ? modifiedFiles : createdFiles).push(path);
    };

    for (const item of delta.created) await sync(item.path, false);
    for (const item of delta.modified) await sync(item.path, true);

    return {
      createdFiles,
      modifiedFiles,
      blockedModifiedFiles,
      blockedCreatedFiles,
      deletedFiles: delta.deleted.map((item) => item.path),
      artifacts,
    };
  }
}

type Config = {
  createRuntimeFactories?: () => readonly ScientificRuntimeFactory[];
  packages?: readonly RuntimePackageDefinition[];
};

/**
 * Runtime-neutral scientific execution service. Python/Pyodide and R/webR
 * register worker-backed factories with this service in later releases. The
 * service owns lazy lifecycle, workspace mirroring, cancellation and safe
 * output synchronization so each runtime does not reimplement those rules.
 */
export class ProjectScientificRuntimeService extends BaseService {
  static deps = ['projectStorage', 'fileSystem', 'latexSource', 'workspaceOps', 'projectCollaboration'] as const;
  readonly packages = new RuntimePackageRegistry();
  readonly runtimes = new RuntimeWorkerManager();
  readonly workspaceBridge: RuntimeWorkspaceBridge;
  readonly artifactCollector: RuntimeArtifactCollector;
  private readonly activeExecutions = new Set<string>();

  constructor(
    context: BaseServiceContext,
    private readonly dependencies: {
      projectStorage: ProjectStorageService;
      fileSystem: FileSystemService;
      latexSource: LatexSourceService;
      workspaceOps: WorkspaceOpsService;
      projectCollaboration: ProjectCollaborationService;
    },
    private readonly config: Config,
  ) {
    super(SERVICE_NAME.projectScientificRuntimeService, context, dependencies);
    this.workspaceBridge = new RuntimeWorkspaceBridge(dependencies.projectStorage);
    this.artifactCollector = new RuntimeArtifactCollector(
      dependencies.projectStorage,
      dependencies.fileSystem,
      dependencies.latexSource,
      dependencies.workspaceOps,
      dependencies.projectCollaboration,
    );
    for (const definition of config.packages ?? []) this.packages.upsert(definition);
  }

  hookMount(): void {
    const unregister = (this.config.createRuntimeFactories?.() ?? []).map((factory) =>
      this.registerRuntime(factory),
    );
    this.addCleanup(() => {
      for (const remove of unregister) remove();
      void this.runtimes.disposeAll();
    });
  }

  registerRuntime(factory: ScientificRuntimeFactory): () => void {
    return this.runtimes.register(factory);
  }

  isRuntimeAvailable(runtimeId: ScientificRuntimeId): boolean {
    return this.runtimes.has(runtimeId);
  }

  listAvailableRuntimes(): readonly { id: ScientificRuntimeId; label: string }[] {
    return this.runtimes.listAvailable();
  }

  getRuntimeState(
    wsName: string,
    runtimeId: ScientificRuntimeId,
  ): ScientificRuntimeInstanceState | undefined {
    return this.runtimes.getState(wsName, runtimeId);
  }

  subscribeRuntimeState(listener: () => void): () => void {
    return this.runtimes.subscribe(listener);
  }

  /**
   * Zero-download browser/runtime preflight. This reports browser prerequisites
   * and current lazy runtime state without initializing Pyodide, webR or Wasmer.
   */
  getDiagnostics(wsName: string): ProjectScientificRuntimeDiagnostics {
    const browser = collectScientificBrowserDiagnostics();
    const labels = new Map(this.listAvailableRuntimes().map((runtime) => [runtime.id, runtime.label] as const));
    return {
      ...browser,
      workspaceLimits: { ...DEFAULT_RUNTIME_WORKSPACE_LIMITS },
      runtimes: browser.runtimes.map((runtime) => {
        const current = this.getRuntimeState(wsName, runtime.runtimeId);
        return {
          ...runtime,
          registered: this.isRuntimeAvailable(runtime.runtimeId),
          label: labels.get(runtime.runtimeId) ?? runtime.runtimeId,
          state: current?.status ?? 'not-loaded',
          ...(current?.error ? { error: current.error } : {}),
        };
      }),
    };
  }

  async resetRuntime(wsName: string, runtimeId: ScientificRuntimeId | 'all'): Promise<void> {
    if (runtimeId === 'all') {
      await this.disposeWorkspace(wsName);
      return;
    }
    if (!this.isRuntimeAvailable(runtimeId)) throw new Error(`Scientific runtime is not registered: ${runtimeId}`);
    await this.disposeRuntime(wsName, runtimeId);
  }

  async execute(
    wsName: string,
    runtimeId: ScientificRuntimeId,
    request: RuntimeExecutionRequest,
    options: ScientificExecutionOptions = {},
  ): Promise<ScientificExecutionResult> {
    const executionKey = `${wsName}::${runtimeId}`;
    if (this.activeExecutions.has(executionKey)) {
      throw new Error(`${runtimeId} is already preparing, running, or synchronizing for this workspace`);
    }
    this.activeExecutions.add(executionKey);
    try {
      const signal = options.signal;
      signal?.throwIfAborted();
      const session = await this.runtimes.ensureReady(wsName, runtimeId, signal);
      const before = await this.workspaceBridge.mirrorProject(wsName, session.workspace, {
        limits: options.workspaceLimits,
        signal,
      });

      let sourcePath = options.sourcePath ? normalizeProjectRelativePath(options.sourcePath) : undefined;
      let executionRequest: RuntimeExecutionRequest;
      if (request.mode === 'file') {
        sourcePath = normalizeProjectRelativePath(request.path);
        if (!sourcePath) throw new Error('Scientific runtime file path must be below /workspace');
        const skipped = before.skipped.find((item) => item.path === sourcePath);
        if (skipped) {
          throw new Error(
            `Cannot run ${sourcePath}: it was not mirrored into the runtime (${skipped.reason}, ${skipped.size} bytes)`,
          );
        }
        const present = before.fingerprints.some((item) => item.path === sourcePath);
        if (!present) throw new Error(`Scientific runtime source file not found: ${sourcePath}`);
        executionRequest = {
          ...request,
          path: sourcePath,
          cwd: normalizeProjectRelativePath(request.cwd ?? ''),
        };
      } else {
        executionRequest = {
          ...request,
          cwd: normalizeProjectRelativePath(request.cwd ?? ''),
        };
      }

      const execution = await this.runtimes.execute(wsName, runtimeId, executionRequest, signal);
      if ((runtimeId === 'python' || runtimeId === 'r') && typeof execution.metadata?.loadedPackages === 'string') {
        const loaded = new Set(execution.metadata.loadedPackages.split(',').map((item) => item.trim().toLowerCase()).filter(Boolean));
        for (const definition of this.packages.list(runtimeId)) {
          if (loaded.has(definition.name.toLowerCase())) this.packages.setState(runtimeId, definition.name, 'installed');
        }
      }
      const sync = await this.artifactCollector.collect(wsName, session, before, execution, {
        ...(sourcePath ? { sourcePath } : {}),
        ...(options.commandLabel ? { commandLabel: options.commandLabel } : {}),
        ...(options.allowOverwritePaths ? { allowOverwritePaths: options.allowOverwritePaths } : {}),
        ...(options.allowCreatePaths !== undefined ? { allowCreatePaths: options.allowCreatePaths } : {}),
        ...(options.inputPaths?.length ? { inputPaths: options.inputPaths } : {}),
        ...(options.executionArgs?.length ? { executionArgs: options.executionArgs } : {}),
        signal,
      });

      return {
        ...execution,
        workspace: {
          skippedInputFiles: before.skipped,
          mirroredBytes: before.mirroredBytes,
          createdFiles: sync.createdFiles,
          modifiedFiles: sync.modifiedFiles,
          blockedModifiedFiles: sync.blockedModifiedFiles,
          blockedCreatedFiles: sync.blockedCreatedFiles,
          deletedFiles: sync.deletedFiles,
        },
        artifacts: sync.artifacts,
      };
    } finally {
      this.activeExecutions.delete(executionKey);
    }
  }

  async installPackages(
    wsName: string,
    runtimeId: ScientificRuntimeId,
    names: readonly string[],
    signal?: AbortSignal,
  ): Promise<readonly string[]> {
    const requested = [...new Set(names.map((name) => name.trim()).filter(Boolean))];
    if (!requested.length) return [];
    const canonical: string[] = [];
    for (const name of requested) {
      const definition = this.packages.get(runtimeId, name);
      if (!definition) throw new Error(`${runtimeId} package is not in the Learnitive certified catalogue: ${name}`);
      if (definition.state === 'unsupported') throw new Error(`${runtimeId} package is not supported in this browser runtime: ${name}`);
      canonical.push(definition.name);
    }
    const session = await this.runtimes.ensureReady(wsName, runtimeId, signal);
    if (!session.installPackages) throw new Error(`${session.label} does not support package installation`);
    const installed = await session.installPackages(canonical, signal) ?? canonical;
    for (const name of installed) {
      if (this.packages.get(runtimeId, name)) this.packages.setState(runtimeId, name, 'installed');
    }
    return installed;
  }

  async interrupt(wsName: string, runtimeId: ScientificRuntimeId): Promise<void> {
    const capabilities = this.runtimes.getCapabilities(wsName, runtimeId);
    if (capabilities && !capabilities.interrupt) {
      throw new Error(`${runtimeId} runtime does not support in-process interruption; reset the runtime after completion if recovery is required`);
    }
    await this.runtimes.interrupt(wsName, runtimeId);
  }

  async disposeRuntime(wsName: string, runtimeId: ScientificRuntimeId): Promise<void> {
    await this.runtimes.dispose(wsName, runtimeId);
  }

  async disposeWorkspace(wsName: string): Promise<void> {
    await this.runtimes.disposeWorkspace(wsName);
  }
}
