import { BaseService, type BaseServiceContext } from '@learnitive/base-utils';
import { SERVICE_NAME } from '@learnitive/constants';
import { hashRuntimeBytes } from '@learnitive/scientific-runtime';
import { projectFilePath, type ProjectFilePath } from '@learnitive/project-storage';
import { WsPath } from '@learnitive/ws-path';
import type { LatexSourceService } from './latex-source-service';
import type { ProjectCollaborationService } from './project-collaboration-service';
import type { ProjectGitService } from './project-git-service';
import type { ProjectReviewService } from './project-review-service';
import type { ProjectStorageService } from './project-storage-service';
import type { ProjectTerminalService } from './project-terminal-service';
import type { GeneratedResearchArtifact } from './research-artifact-files';

const MAX_GRAPH_TEXT_BYTES = 2 * 1024 * 1024;
const MAX_FRESHNESS_HASH_BYTES = 64 * 1024 * 1024;
const MAX_MANUSCRIPT_SNIPPET = 320;

export type ResearchWorkflowNodeKind = 'dataset' | 'analysis' | 'artifact' | 'manuscript' | 'other';
export type ResearchWorkflowEdgeKind = 'generated-by' | 'uses-input' | 'referenced-by';

export type ResearchWorkflowNode = {
  id: string;
  path: ProjectFilePath;
  kind: ResearchWorkflowNodeKind;
  artifactKind?: GeneratedResearchArtifact['kind'];
};

export type ResearchWorkflowEdge = {
  from: string;
  to: string;
  kind: ResearchWorkflowEdgeKind;
  detail?: string;
};

export type ResearchWorkflowGraph = {
  nodes: readonly ResearchWorkflowNode[];
  edges: readonly ResearchWorkflowEdge[];
  artifactCount: number;
  generatedRunCount: number;
};

export type ResearchArtifactFreshness = {
  path: ProjectFilePath;
  status: 'current' | 'stale' | 'unknown';
  reasons: readonly string[];
  sourcePath?: ProjectFilePath;
  inputPaths: readonly ProjectFilePath[];
};

export type ResearchDependencyReference = {
  manuscript: ProjectFilePath;
  artifact: ProjectFilePath;
  snippet: string;
};

export type ResearchDependencyTrace = {
  path: ProjectFilePath;
  artifacts: readonly ProjectFilePath[];
  generators: readonly ProjectFilePath[];
  inputs: readonly ProjectFilePath[];
  manuscriptReferences: readonly ResearchDependencyReference[];
};

export type ResearchArtifactRerunPlan = {
  requestedArtifact: ProjectFilePath;
  runId?: string;
  runtime: 'python' | 'r' | 'wasi';
  sourcePath: ProjectFilePath;
  command: string;
  expectedOutputs: readonly ProjectFilePath[];
  inputPaths: readonly ProjectFilePath[];
};

export type ResearchArtifactChange = {
  path: ProjectFilePath;
  change: 'changed' | 'unchanged' | 'created' | 'missing';
  beforeHash?: string;
  afterHash?: string;
};

export type ResearchResultsReview = {
  filePath: ProjectFilePath;
  changeId?: string;
  insertedText: string;
  createdResultsSection: boolean;
};

function isManuscriptPath(path: string): boolean {
  return /\.(?:tex|ltx)$/iu.test(path);
}

function isAnalysisPath(path: string): boolean {
  return /\.(?:py|r|c|cc|cpp|cxx|wasm)$/iu.test(path) || path.startsWith('analysis/');
}

function isDatasetPath(path: string): boolean {
  return /\.(?:csv|tsv|jsonl?|parquet|arrow|feather|rds|sqlite3?|db)$/iu.test(path) || path.startsWith('data/');
}

function workflowNodeKind(path: string, artifact?: GeneratedResearchArtifact): ResearchWorkflowNodeKind {
  if (artifact) return 'artifact';
  if (isManuscriptPath(path)) return 'manuscript';
  if (isAnalysisPath(path)) return 'analysis';
  if (isDatasetPath(path)) return 'dataset';
  return 'other';
}

function terminalQuote(value: string): string {
  return `"${value.replace(/\\/gu, '\\\\').replace(/"/gu, '\\"')}"`;
}

function compactSnippet(source: string, at: number): string {
  const from = Math.max(0, at - Math.floor(MAX_MANUSCRIPT_SNIPPET / 2));
  const to = Math.min(source.length, at + Math.floor(MAX_MANUSCRIPT_SNIPPET / 2));
  return source.slice(from, to).replace(/\s+/gu, ' ').trim();
}

function normalizeResultsBody(value: string): string {
  let text = value.trim();
  text = text.replace(/^\\section\*?\{\s*results?\s*\}\s*/iu, '').trim();
  if (!text) throw new Error('Results proposal is empty.');
  if (text.length > 16_000) throw new Error('Results proposal is too large for a review change.');
  return text;
}

function resultsInsertion(projected: string, body: string): { from: number; text: string; createdResultsSection: boolean } {
  const results = /\\section\*?\{[^}]*results?[^}]*\}/igu.exec(projected);
  if (results?.index !== undefined) {
    const afterHeading = results.index + results[0].length;
    const tail = projected.slice(afterHeading);
    const nextSection = /\\section\*?\{/u.exec(tail);
    const endDocument = /\\end\{document\}/u.exec(tail);
    const candidates = [nextSection?.index, endDocument?.index].filter((item): item is number => typeof item === 'number');
    const from = afterHeading + (candidates.length ? Math.min(...candidates) : tail.length);
    return { from, text: `\n\n${body}\n`, createdResultsSection: false };
  }

  const discussion = /\\section\*?\{[^}]*(?:discussion|conclusion)[^}]*\}/iu.exec(projected);
  const endDocument = /\\end\{document\}/u.exec(projected);
  const from = discussion?.index ?? endDocument?.index ?? projected.length;
  return { from, text: `\n\n\\section{Results}\n\n${body}\n`, createdResultsSection: true };
}

export class ProjectResearchWorkflowService extends BaseService {
  static deps = ['latexSource', 'projectCollaboration', 'projectGit', 'projectReview', 'projectStorage', 'projectTerminal'] as const;

  constructor(
    context: BaseServiceContext,
    private readonly dependencies: {
      latexSource: LatexSourceService;
      projectCollaboration: ProjectCollaborationService;
      projectGit: ProjectGitService;
      projectReview: ProjectReviewService;
      projectStorage: ProjectStorageService;
      projectTerminal: ProjectTerminalService;
    },
  ) {
    super(SERVICE_NAME.projectResearchWorkflowService, context, dependencies);
  }

  hookMount(): void {}

  async buildGraph(wsName: string, signal?: AbortSignal): Promise<ResearchWorkflowGraph> {
    await this.mountPromise;
    signal?.throwIfAborted();
    const artifacts = await this.dependencies.projectTerminal.getGeneratedArtifacts(wsName);
    const provider = await this.dependencies.projectStorage.getProvider(wsName);
    const entries = await provider.listEntries(signal);
    const files = entries.filter((entry) => entry.kind === 'file');
    const nodes = new Map<string, ResearchWorkflowNode>();
    const edges: ResearchWorkflowEdge[] = [];

    const addNode = (pathLike: string, artifact?: GeneratedResearchArtifact) => {
      let path: ProjectFilePath;
      try { path = projectFilePath(pathLike); } catch { return; }
      if (!nodes.has(String(path))) {
        nodes.set(String(path), {
          id: String(path),
          path,
          kind: workflowNodeKind(String(path), artifact),
          ...(artifact ? { artifactKind: artifact.kind } : {}),
        });
      }
    };

    for (const artifact of artifacts) {
      addNode(artifact.path, artifact);
      const generatedBy = artifact.generatedBy;
      if (generatedBy?.sourcePath) {
        addNode(generatedBy.sourcePath);
        edges.push({ from: generatedBy.sourcePath, to: artifact.path, kind: 'generated-by', ...(generatedBy.runtime ? { detail: generatedBy.runtime } : {}) });
      }
      for (const input of generatedBy?.inputPaths ?? []) {
        addNode(input);
        edges.push({ from: input, to: artifact.path, kind: 'uses-input' });
      }
    }

    for (const entry of files.filter((item) => isManuscriptPath(String(item.path)) && item.size <= MAX_GRAPH_TEXT_BYTES)) {
      signal?.throwIfAborted();
      const bytes = await provider.readFile(entry.path, signal);
      if (!bytes) continue;
      const text = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
      addNode(String(entry.path));
      for (const artifact of artifacts) {
        if (!text.includes(artifact.path)) continue;
        edges.push({ from: artifact.path, to: String(entry.path), kind: 'referenced-by' });
      }
    }

    return {
      nodes: [...nodes.values()],
      edges,
      artifactCount: artifacts.length,
      generatedRunCount: new Set(artifacts.map((item) => item.generatedBy?.runId).filter(Boolean)).size,
    };
  }

  async trace(wsName: string, pathLike: string, signal?: AbortSignal): Promise<ResearchDependencyTrace> {
    await this.mountPromise;
    const path = projectFilePath(pathLike.replace(/^\/workspace\//u, '').replace(/^\.\//u, ''));
    const artifacts = await this.dependencies.projectTerminal.getGeneratedArtifacts(wsName);
    const related = artifacts.filter((artifact) =>
      artifact.path === path
      || artifact.generatedBy?.sourcePath === path
      || artifact.generatedBy?.inputPaths?.includes(path),
    );
    const artifactPaths = [...new Set(related.map((item) => projectFilePath(item.path)))];
    const generators = [...new Set(related.flatMap((item) => item.generatedBy?.sourcePath ? [projectFilePath(item.generatedBy.sourcePath)] : []))];
    const inputs = [...new Set(related.flatMap((item) => (item.generatedBy?.inputPaths ?? []).map((input) => projectFilePath(input))))];

    const provider = await this.dependencies.projectStorage.getProvider(wsName);
    const entries = await provider.listEntries(signal);
    const references: ResearchDependencyReference[] = [];
    for (const entry of entries) {
      if (entry.kind !== 'file' || !isManuscriptPath(String(entry.path)) || entry.size > MAX_GRAPH_TEXT_BYTES) continue;
      const bytes = await provider.readFile(entry.path, signal);
      if (!bytes) continue;
      const text = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
      for (const artifact of artifactPaths) {
        let offset = text.indexOf(String(artifact));
        if (offset < 0) continue;
        references.push({ manuscript: entry.path, artifact, snippet: compactSnippet(text, offset) });
      }
    }
    return { path, artifacts: artifactPaths, generators, inputs, manuscriptReferences: references };
  }

  async freshness(wsName: string, signal?: AbortSignal): Promise<readonly ResearchArtifactFreshness[]> {
    await this.mountPromise;
    const artifacts = await this.dependencies.projectTerminal.getGeneratedArtifacts(wsName);
    const provider = await this.dependencies.projectStorage.getProvider(wsName);
    const entries = await provider.listEntries(signal);
    const byPath = new Map(entries.filter((item) => item.kind === 'file').map((item) => [String(item.path), item] as const));
    const dirty = new Set<string>();
    try {
      if (await this.dependencies.projectGit.isRepository(wsName)) {
        const status = await this.dependencies.projectGit.status(wsName);
        for (const item of [...status.staged, ...status.unstaged]) dirty.add(String(item.path));
      }
    } catch {
      // Git is optional. Fingerprint checks below remain useful without it.
    }

    const currentHash = async (path: string): Promise<string | undefined> => {
      const entry = byPath.get(path);
      if (!entry || entry.size > MAX_FRESHNESS_HASH_BYTES) return undefined;
      const bytes = await provider.readFile(projectFilePath(path), signal);
      return bytes ? hashRuntimeBytes(bytes) : undefined;
    };

    const result: ResearchArtifactFreshness[] = [];
    for (const artifact of artifacts) {
      signal?.throwIfAborted();
      const reasons: string[] = [];
      let exactChecks = 0;
      let exactMatches = 0;
      const source = artifact.generatedBy?.sourcePath;
      const inputs = artifact.generatedBy?.inputPaths ?? [];
      if (dirty.has(artifact.path)) reasons.push('Generated artifact has uncommitted Git changes.');
      if (source && dirty.has(source)) reasons.push(`Generator changed in Git: ${source}`);
      for (const input of inputs) if (dirty.has(input)) reasons.push(`Input changed in Git: ${input}`);

      if (source && artifact.generatedBy?.sourceFingerprint) {
        exactChecks += 1;
        const hash = await currentHash(source);
        if (hash === artifact.generatedBy.sourceFingerprint) exactMatches += 1;
        else if (hash) reasons.push(`Generator content changed since generation: ${source}`);
      }
      for (const [input, expected] of Object.entries(artifact.generatedBy?.inputFingerprints ?? {})) {
        exactChecks += 1;
        const hash = await currentHash(input);
        if (hash === expected) exactMatches += 1;
        else if (hash) reasons.push(`Input content changed since generation: ${input}`);
      }

      const status: ResearchArtifactFreshness['status'] = reasons.length
        ? 'stale'
        : exactChecks > 0 && exactMatches === exactChecks
          ? 'current'
          : 'unknown';
      result.push({
        path: projectFilePath(artifact.path),
        status,
        reasons: reasons.length ? reasons : status === 'current' ? ['Recorded generator/input fingerprints still match.'] : ['No complete generation fingerprints are available yet.'],
        ...(source ? { sourcePath: projectFilePath(source) } : {}),
        inputPaths: inputs.map((input) => projectFilePath(input)),
      });
    }
    return result;
  }

  async prepareRerun(wsName: string, artifactPathLike: string): Promise<ResearchArtifactRerunPlan> {
    await this.mountPromise;
    const requestedArtifact = projectFilePath(artifactPathLike.replace(/^\/workspace\//u, '').replace(/^\.\//u, ''));
    const artifacts = await this.dependencies.projectTerminal.getGeneratedArtifacts(wsName);
    const artifact = artifacts.find((item) => item.path === requestedArtifact);
    if (!artifact) throw new Error(`Research artifact not found: ${requestedArtifact}`);
    const generatedBy = artifact.generatedBy;
    if (!generatedBy?.sourcePath) throw new Error(`Artifact does not record a reproducible generator: ${requestedArtifact}`);
    const runtime = generatedBy.runtime;
    if (runtime !== 'python' && runtime !== 'r' && runtime !== 'wasi') {
      throw new Error(`Artifact runtime is not directly rerunnable by the scientific workflow: ${runtime ?? 'unknown'}`);
    }
    const sourcePath = projectFilePath(generatedBy.sourcePath);
    const group = generatedBy.runId
      ? artifacts.filter((item) => item.generatedBy?.runId === generatedBy.runId)
      : artifacts.filter((item) => item.generatedBy?.sourcePath === generatedBy.sourcePath && item.generatedBy?.runtime === generatedBy.runtime);
    const expectedOutputs = [...new Set(group.map((item) => projectFilePath(item.path)))];
    const inputPaths = [...new Set(group.flatMap((item) => (item.generatedBy?.inputPaths ?? []).map((input) => projectFilePath(input))))];
    const recordedArgs = (generatedBy.args ?? []).map((item) => ` ${terminalQuote(item)}`).join('');
    const command = runtime === 'python'
      ? `python ${terminalQuote(String(sourcePath))}${recordedArgs}`
      : runtime === 'r'
        ? `Rscript ${terminalQuote(String(sourcePath))}${recordedArgs}`
        : /\.c$/iu.test(sourcePath)
          ? `clang ${terminalQuote(String(sourcePath))} -o ${terminalQuote(String(requestedArtifact))}`
          : /\.(?:cc|cpp|cxx)$/iu.test(sourcePath)
            ? `clang++ ${terminalQuote(String(sourcePath))} -o ${terminalQuote(String(requestedArtifact))}`
            : `wasm-run ${terminalQuote(String(sourcePath))}${recordedArgs}`;
    return {
      requestedArtifact,
      ...(generatedBy.runId ? { runId: generatedBy.runId } : {}),
      runtime,
      sourcePath,
      command,
      expectedOutputs,
      inputPaths,
    };
  }

  async snapshotArtifactHashes(wsName: string, paths: readonly ProjectFilePath[], signal?: AbortSignal): Promise<Readonly<Record<string, string>>> {
    const provider = await this.dependencies.projectStorage.getProvider(wsName);
    const entries = await provider.listEntries(signal);
    const byPath = new Map(entries.filter((item) => item.kind === 'file').map((item) => [String(item.path), item] as const));
    const hashes: Record<string, string> = {};
    for (const path of paths) {
      signal?.throwIfAborted();
      const entry = byPath.get(String(path));
      if (!entry) continue;
      const bytes = await provider.readFile(path, signal);
      if (bytes) hashes[String(path)] = hashRuntimeBytes(bytes);
    }
    return hashes;
  }

  async compareArtifactHashes(
    wsName: string,
    paths: readonly ProjectFilePath[],
    before: Readonly<Record<string, string>>,
    signal?: AbortSignal,
  ): Promise<readonly ResearchArtifactChange[]> {
    const after = await this.snapshotArtifactHashes(wsName, paths, signal);
    return paths.map((path) => {
      const previous = before[String(path)];
      const current = after[String(path)];
      const change: ResearchArtifactChange['change'] = previous === undefined
        ? current === undefined ? 'missing' : 'created'
        : current === undefined ? 'missing'
          : previous === current ? 'unchanged' : 'changed';
      return { path, change, ...(previous ? { beforeHash: previous } : {}), ...(current ? { afterHash: current } : {}) };
    });
  }

  async proposeResultsReview(
    wsName: string,
    bodyValue: string,
    options: { targetFile?: string } = {},
  ): Promise<ResearchResultsReview> {
    await this.mountPromise;
    const body = normalizeResultsBody(bodyValue);
    const config = await this.dependencies.projectStorage.getProjectConfig(wsName);
    const filePath = projectFilePath(options.targetFile ?? config.rootFile);
    if (!isManuscriptPath(String(filePath))) throw new Error('Results review target must be a .tex or .ltx manuscript file.');
    await this.dependencies.projectReview.refresh(wsName);
    const wsPath = WsPath.fromParts(wsName, filePath).wsPath;
    const localCanonical = this.dependencies.latexSource.getSnapshot(wsPath)?.text ?? await this.dependencies.latexSource.load(wsPath);
    const canonical = this.dependencies.projectCollaboration.getCanonicalSourceText(wsName, filePath) ?? localCanonical;
    const projected = this.dependencies.projectReview.getProjectedSource(wsName, filePath, canonical);
    const insertion = resultsInsertion(projected, body);
    const before = new Set(this.dependencies.projectReview.getChanges(wsName, { status: 'pending', file: filePath }).map((item) => item.id));
    this.dependencies.projectReview.applyProjectedEdit(
      wsName,
      filePath,
      canonical,
      { from: insertion.from, to: insertion.from, text: insertion.text },
      { semanticKind: 'section', author: { id: 'interactive-ai', displayName: 'Learnitive AI', kind: 'ai' }, source: 'research' },
    );
    const changeId = this.dependencies.projectReview.getChanges(wsName, { status: 'pending', file: filePath }).find((item) => !before.has(item.id))?.id;
    return { filePath, ...(changeId ? { changeId } : {}), insertedText: insertion.text, createdResultsSection: insertion.createdResultsSection };
  }
}
