import 'fake-indexeddb/auto';
import { WORKSPACE_STORAGE_TYPE } from '@learnitive/constants';
import { captureSourceAnchor } from '@learnitive/latex-source';
import { createTestEnvironment } from '@learnitive/test-utils';
import { describe, expect, it } from 'vitest';
import type { ReviewThread } from '../project-review-service';

async function setup(
  name = `review-${crypto.randomUUID()}`,
  metadata: Record<string, unknown> = {},
) {
  const env = createTestEnvironment();
  const services = env.instantiateAll();
  await env.mountAll();
  await services.workspaceOps.createWorkspaceInfo({ name, type: WORKSPACE_STORAGE_TYPE.Memory, metadata });
  const wsPath = `${name}:main.tex`;
  const source = '\\documentclass{article}\n\\begin{document}\nA robust local claim.\n\\end{document}\n';
  await services.fileSystem.createTextFile(wsPath, source);
  await services.latexSource.load(wsPath);
  return { env, services, name, wsPath, source };
}

describe('ProjectReviewService', () => {
  it('stores range comments outside canonical LaTeX and recovers anchors after edits', async () => {
    const { env, services, name, wsPath, source } = await setup();
    const from = source.indexOf('robust');
    const thread = await services.projectReview.createComment(name, {
      body: 'Please add evidence for this claim.',
      anchor: captureSourceAnchor({ filePath: 'main.tex', source, from, to: from + 'robust local claim'.length, sourceRevision: services.latexSource.getSnapshot(wsPath)?.revision }),
    });

    const sourceAfterComment = services.latexSource.getSnapshot(wsPath)?.text ?? '';
    expect(sourceAfterComment).toBe(source);
    expect(sourceAfterComment).not.toContain('LEARNITIVE-REVIEW');
    expect(services.projectReview.getThreads(name)).toHaveLength(1);
    expect(thread.anchor?.selectedText).toBe('robust local claim');

    const next = source.replace('\\begin{document}\n', '\\begin{document}\nIntro.\n');
    services.latexSource.update(wsPath, next, 'source');
    await services.latexSource.flush(wsPath);
    await services.projectReview.refresh(name);

    const recovered = services.projectReview.getThread(name, thread.id);
    expect(recovered?.status).toBe('open');
    expect(recovered?.anchor?.from).toBe(next.indexOf('robust'));
    env.controller.abort();
  });

  it('marks a comment outdated rather than reattaching it when its source disappears', async () => {
    const { env, services, name, wsPath, source } = await setup();
    const from = source.indexOf('robust');
    const thread = await services.projectReview.createComment(name, {
      body: 'Keep this attached to the exact sentence.',
      anchor: captureSourceAnchor({ filePath: 'main.tex', source, from, to: from + 'robust local claim'.length, sourceRevision: services.latexSource.getSnapshot(wsPath)?.revision }),
    });

    services.latexSource.update(wsPath, source.replace('A robust local claim.', 'A completely different sentence.'), 'source');
    await services.latexSource.flush(wsPath);
    await services.projectReview.refresh(name);
    expect(services.projectReview.getThread(name, thread.id)?.status).toBe('outdated');
    env.controller.abort();
  });

  it('supports replies, resolve/reopen and local author ownership', async () => {
    const { env, services, name } = await setup();
    services.projectReview.setLocalDisplayName('Ada Reviewer');
    const thread = await services.projectReview.createComment(name, { body: 'Project-level review note.', scope: 'project' });
    await services.projectReview.reply(name, thread.id, 'Follow-up detail.');
    await services.projectReview.resolve(name, thread.id);
    expect(services.projectReview.getThread(name, thread.id)?.status).toBe('resolved');
    expect(services.projectReview.getThread(name, thread.id)?.replies[0]?.body).toBe('Follow-up detail.');
    await services.projectReview.reopen(name, thread.id);
    await services.projectReview.editOwnComment(name, thread.id, 'Updated project-level note.');
    expect(services.projectReview.getThread(name, thread.id)?.body).toBe('Updated project-level note.');
    env.controller.abort();
  });
});

it('R14.27.1 keeps tracked source edits outside canonical LaTeX until accepted', async () => {
  const { env, services, name, wsPath, source } = await setup();
  await services.projectReview.setTrackChangesEnabled(name, true);
  const at = source.indexOf('robust');
  services.projectReview.applyProjectedEdit(name, 'main.tex', source, { from: at, to: at, text: 'highly ' });

  expect(services.latexSource.getSnapshot(wsPath)?.text).toBe(source);
  expect(services.projectReview.getProjectedSource(name, 'main.tex', source)).toContain('A highly robust local claim.');
  const pending = services.projectReview.getChanges(name, { status: 'pending', file: 'main.tex' });
  expect(pending).toHaveLength(1);
  expect(pending[0]?.type).toBe('insert');
  expect(pending[0]?.proposedText).toBe('highly ');

  await services.projectReview.acceptReviewChange(name, pending[0]!.id);
  expect(services.latexSource.getSnapshot(wsPath)?.text).toContain('A highly robust local claim.');
  expect(services.projectReview.getThread(name, pending[0]!.id)?.change?.status).toBe('accepted');
  expect(services.latexSource.getSnapshot(wsPath)?.canUndo).toBe(true);
  env.controller.abort();
});

it('R14.27.1 merges continued typing into the same pending insertion and rejects without source mutation', async () => {
  const { env, services, name, wsPath, source } = await setup();
  await services.projectReview.setTrackChangesEnabled(name, true);
  const at = source.indexOf('robust');
  services.projectReview.applyProjectedEdit(name, 'main.tex', source, { from: at, to: at, text: 'very' });
  const firstProjection = services.projectReview.getProjectedSource(name, 'main.tex', source);
  const afterVery = firstProjection.indexOf('very') + 'very'.length;
  services.projectReview.applyProjectedEdit(name, 'main.tex', source, { from: afterVery, to: afterVery, text: ' ' });

  const pending = services.projectReview.getChanges(name, { status: 'pending', file: 'main.tex' });
  expect(pending).toHaveLength(1);
  expect(pending[0]?.proposedText).toBe('very ');
  await services.projectReview.rejectReviewChange(name, pending[0]!.id);
  expect(services.projectReview.getProjectedSource(name, 'main.tex', source)).toBe(source);
  const rejectedSource = services.latexSource.getSnapshot(wsPath)?.text ?? '';
  expect(rejectedSource).toBe(source);
  expect(rejectedSource).not.toContain('LEARNITIVE-REVIEW');
  expect(services.projectReview.getThread(name, pending[0]!.id)?.change?.status).toBe('rejected');
  env.controller.abort();
});

it('R14.27.1 records deletion and replacement changes without proprietary source markup', async () => {
  const { env, services, name, wsPath, source } = await setup();
  await services.projectReview.setTrackChangesEnabled(name, true);
  const localFrom = source.indexOf('local ');
  services.projectReview.applyProjectedEdit(name, 'main.tex', source, { from: localFrom, to: localFrom + 'local '.length, text: '' });
  let projection = services.projectReview.getProjectedSource(name, 'main.tex', source);
  expect(projection).toContain('A robust claim.');
  expect(services.projectReview.getChanges(name, { status: 'pending' })[0]?.type).toBe('delete');

  const robustFrom = projection.indexOf('robust');
  services.projectReview.applyProjectedEdit(name, 'main.tex', source, { from: robustFrom, to: robustFrom + 'robust'.length, text: 'resilient' });
  const changes = services.projectReview.getChanges(name, { status: 'pending', file: 'main.tex' });
  expect(changes.some((change) => change.type === 'delete')).toBe(true);
  expect(changes.some((change) => change.type === 'replace')).toBe(true);
  const sourceWithTrackedReview = services.latexSource.getSnapshot(wsPath)?.text ?? '';
  expect(sourceWithTrackedReview).toBe(source);
  expect(sourceWithTrackedReview).not.toContain('LEARNITIVE-REVIEW');
  expect(sourceWithTrackedReview).not.toContain('<###');
  env.controller.abort();
});

it('R14.27.2 records durable review activity without logging every continued tracked keystroke', async () => {
  const { env, services, name, source } = await setup();
  await services.projectReview.refresh(name);
  const comment = await services.projectReview.createComment(name, { body: 'History comment', scope: 'project' });
  await services.projectReview.reply(name, comment.id, 'History reply');
  await services.projectReview.resolve(name, comment.id);
  await services.projectReview.reopen(name, comment.id);

  const at = source.indexOf('robust');
  services.projectReview.applyProjectedEdit(name, 'main.tex', source, { from: at, to: at, text: 'very' });
  const projected = services.projectReview.getProjectedSource(name, 'main.tex', source);
  services.projectReview.applyProjectedEdit(name, 'main.tex', source, { from: projected.indexOf('very') + 4, to: projected.indexOf('very') + 4, text: ' ' });
  await new Promise((resolve) => setTimeout(resolve, 0));

  const activity = services.projectReview.getActivity(name);
  expect(activity.map((item) => item.kind)).toEqual(expect.arrayContaining(['comment-created', 'comment-replied', 'comment-resolved', 'comment-reopened', 'change-proposed']));
  expect(activity.filter((item) => item.kind === 'change-proposed')).toHaveLength(1);
  env.controller.abort();
});

it('R14.27.2 creates recovery checkpoints before bulk accepts and retains activity after a comment is deleted', async () => {
  const { env, services, name, source } = await setup();
  const comment = await services.projectReview.createComment(name, { body: 'Delete me later', scope: 'project' });
  await services.projectReview.deleteOwnComment(name, comment.id);

  const robust = source.indexOf('robust');
  const local = source.indexOf('local');
  services.projectReview.applyProjectedEdit(name, 'main.tex', source, { from: robust, to: robust + 'robust'.length, text: 'stable' });
  services.projectReview.applyProjectedEdit(name, 'main.tex', source, { from: local, to: local + 'local'.length, text: 'small' });
  const ids = services.projectReview.getChanges(name, { status: 'pending' }).map((change) => change.id);
  await services.projectReview.acceptChanges(name, ids);

  const snapshots = await services.noteSnapshot.listSnapshots({ wsName: name });
  expect(snapshots.some((snapshot) => snapshot.wsPath.endsWith(':main.tex'))).toBe(true);
  expect(services.projectReview.getActivity(name).some((item) => item.kind === 'snapshot-created')).toBe(true);
  expect(services.projectReview.getActivity(name).some((item) => item.kind === 'comment-deleted' && item.threadId === comment.id)).toBe(true);
  expect(services.projectReview.getActivity(name).filter((item) => item.kind === 'change-accepted')).toHaveLength(2);
  env.controller.abort();
});

it('R14.27.2 snapshots an AI-authored tracked change before individual acceptance', async () => {
  const { env, services, name, source } = await setup();
  const from = source.indexOf('robust');
  services.projectReview.applyProjectedEdit(name, 'main.tex', source, { from, to: from + 'robust'.length, text: 'resilient' }, { author: { id: 'local-gemma', displayName: 'Local Gemma', kind: 'ai' } });
  await new Promise((resolve) => setTimeout(resolve, 0));
  const change = services.projectReview.getChanges(name, { status: 'pending' })[0]!;
  await services.projectReview.acceptReviewChange(name, change.id);
  const snapshots = await services.noteSnapshot.listSnapshots({ wsName: name });
  expect(snapshots.some((snapshot) => snapshot.wsPath.endsWith(':main.tex'))).toBe(true);
  expect(services.projectReview.getActivity(name).some((item) => item.source === 'ai')).toBe(true);
  env.controller.abort();
});


it('BF4.4 keeps comment and review metadata out of canonical LaTeX across refresh cycles', async () => {
  const { env, services, name, wsPath, source } = await setup();
  const from = source.indexOf('robust');
  await services.projectReview.createComment(name, {
    body: 'Local-only review comment',
    anchor: captureSourceAnchor({ filePath: 'main.tex', source, from, to: from + 'robust local claim'.length }),
  });

  for (let cycle = 0; cycle < 4; cycle += 1) {
    await services.projectReview.refresh(name);
    await services.latexSource.flush(wsPath);
  }

  const stable = services.latexSource.getSnapshot(wsPath)?.text ?? '';
  expect(stable).toBe(source);
  expect(stable).not.toContain('LEARNITIVE-REVIEW');
  expect(services.projectReview.getThreads(name, { status: 'all' })).toHaveLength(1);
  env.controller.abort();
});

it('BF4.4 imports a legacy source review ledger once, removes it from LaTeX and never writes it back', async () => {
  const projectId = `legacy-review-${crypto.randomUUID()}`;
  const thread: ReviewThread = {
    id: 'legacy-thread',
    projectId,
    kind: 'comment',
    scope: 'project',
    author: { id: 'reviewer', displayName: 'Reviewer', kind: 'user' },
    body: 'Legacy source-ledger comment',
    status: 'open',
    createdAt: 1,
    updatedAt: 1,
    replies: [],
  };
  const legacyLedger = [
    '% LEARNITIVE-REVIEW-BEGIN v1',
    `% LEARNITIVE-REVIEW ${JSON.stringify(thread)}`,
    '% LEARNITIVE-REVIEW-END',
    '',
  ].join('\n');
  const { env, services, name, wsPath, source } = await setup(
    `review-legacy-source-${crypto.randomUUID()}`,
    { learnitiveReview: { version: 1, id: projectId } },
  );
  services.latexSource.update(wsPath, `${source}${legacyLedger}`, 'source', { history: 'reset' });
  await services.latexSource.flush(wsPath);

  await services.projectReview.refresh(name);
  await services.latexSource.flush(wsPath);

  expect(services.projectReview.getThread(name, thread.id)?.body).toBe(thread.body);
  expect(services.latexSource.getSnapshot(wsPath)?.text).toBe(source);

  await services.projectReview.reply(name, thread.id, 'Stored outside LaTeX.');
  await services.latexSource.flush(wsPath);
  expect(services.latexSource.getSnapshot(wsPath)?.text).toBe(source);
  expect(services.latexSource.getSnapshot(wsPath)?.text).not.toContain('LEARNITIVE-REVIEW');
  env.controller.abort();
});


it('AI source proposals stay pending outside canonical source until Accept and retain provenance', async () => {
  const { env, services, name, wsPath, source } = await setup(`ai-review-${crypto.randomUUID()}`);
  const from = source.indexOf('robust');
  const snapshot = services.latexSource.getSnapshot(wsPath)!;
  const thread = await services.projectReview.proposeSourceChange(name, {
    filePath: 'main.tex',
    from,
    to: from + 'robust'.length,
    originalText: 'robust',
    proposedText: 'well-supported',
    sourceRevision: snapshot.revision,
    sourceAnchor: captureSourceAnchor({ filePath: 'main.tex', source, from, to: from + 'robust'.length, sourceRevision: snapshot.revision }),
    author: { id: 'interactive-ai-test', displayName: 'Learnitive AI', kind: 'ai' },
    metadata: { source: 'ai', category: 'writing', reason: 'Improve precision', confidence: 0.93, provider: 'local', model: 'test-model' },
  });

  expect(services.latexSource.getSnapshot(wsPath)?.text).toBe(source);
  expect(thread.change?.status).toBe('pending');
  expect(thread.change?.metadata).toMatchObject({ source: 'ai', category: 'writing', confidence: 0.93, provider: 'local', model: 'test-model' });
  await services.projectReview.acceptReviewChange(name, thread.id);
  expect(services.latexSource.getSnapshot(wsPath)?.text).toContain('A well-supported local claim.');
  expect(services.latexSource.getSnapshot(wsPath)?.canUndo).toBe(true);
  env.controller.abort();
});

it('rejecting an AI source proposal leaves canonical source byte-for-byte unchanged', async () => {
  const { env, services, name, wsPath, source } = await setup(`ai-reject-${crypto.randomUUID()}`);
  const from = source.indexOf('robust');
  const snapshot = services.latexSource.getSnapshot(wsPath)!;
  const thread = await services.projectReview.proposeSourceChange(name, {
    filePath: 'main.tex', from, to: from + 6, originalText: 'robust', proposedText: 'resilient', sourceRevision: snapshot.revision,
    sourceAnchor: captureSourceAnchor({ filePath: 'main.tex', source, from, to: from + 6, sourceRevision: snapshot.revision }),
    author: { id: 'interactive-ai-test', displayName: 'Learnitive AI', kind: 'ai' },
  });
  await services.projectReview.rejectReviewChange(name, thread.id);
  expect(services.latexSource.getSnapshot(wsPath)?.text).toBe(source);
  expect(services.projectReview.getThread(name, thread.id)?.change?.status).toBe('rejected');
  env.controller.abort();
});

it('safely remaps a stale AI proposal only when its captured source anchor still resolves', async () => {
  const { env, services, name, wsPath, source } = await setup(`ai-remap-${crypto.randomUUID()}`);
  const from = source.indexOf('robust');
  const oldRevision = services.latexSource.getSnapshot(wsPath)!.revision;
  const anchor = captureSourceAnchor({ filePath: 'main.tex', source, from, to: from + 6, sourceRevision: oldRevision });
  const shifted = source.replace('\\begin{document}\n', '\\begin{document}\nIntro paragraph.\n');
  services.latexSource.update(wsPath, shifted, 'source');
  await services.latexSource.flush(wsPath);

  const thread = await services.projectReview.proposeSourceChange(name, {
    filePath: 'main.tex', from, to: from + 6, originalText: 'robust', proposedText: 'resilient', sourceRevision: oldRevision,
    sourceAnchor: anchor, author: { id: 'interactive-ai-test', displayName: 'Learnitive AI', kind: 'ai' },
  });
  expect(thread.change?.anchor.from).toBe(shifted.indexOf('robust'));
  expect(services.latexSource.getSnapshot(wsPath)?.text).toBe(shifted);
  env.controller.abort();
});

it('fails closed when a stale AI proposal can no longer resolve its exact source', async () => {
  const { env, services, name, wsPath, source } = await setup(`ai-stale-${crypto.randomUUID()}`);
  const from = source.indexOf('robust');
  const oldRevision = services.latexSource.getSnapshot(wsPath)!.revision;
  const anchor = captureSourceAnchor({ filePath: 'main.tex', source, from, to: from + 6, sourceRevision: oldRevision });
  services.latexSource.update(wsPath, source.replace('robust', 'entirely different'), 'source');
  await services.latexSource.flush(wsPath);

  await expect(services.projectReview.proposeSourceChange(name, {
    filePath: 'main.tex', from, to: from + 6, originalText: 'robust', proposedText: 'resilient', sourceRevision: oldRevision,
    sourceAnchor: anchor, author: { id: 'interactive-ai-test', displayName: 'Learnitive AI', kind: 'ai' },
  })).rejects.toThrow(/outdated|no longer matches/u);
  expect(services.projectReview.getChanges(name, { status: 'pending' })).toHaveLength(0);
  env.controller.abort();
});


it('AI Source suggestion projects as the same pending Review change in Visual and can be accepted there', async () => {
  const { env, services, name, wsPath, source } = await setup(`ai-source-visual-parity-${crypto.randomUUID()}`);
  const from = source.indexOf('robust');
  const snapshot = services.latexSource.getSnapshot(wsPath)!;
  const thread = await services.projectReview.proposeSourceChange(name, {
    filePath: 'main.tex',
    from,
    to: from + 'robust'.length,
    originalText: 'robust',
    proposedText: 'well-supported',
    sourceRevision: snapshot.revision,
    sourceAnchor: captureSourceAnchor({ filePath: 'main.tex', source, from, to: from + 'robust'.length, sourceRevision: snapshot.revision }),
    author: { id: 'interactive-ai-source', displayName: 'Learnitive AI', kind: 'ai' },
    metadata: { source: 'ai', category: 'writing', reason: 'Source-mode suggestion' },
  });

  const visualDecorations = services.projectReview.getEditorDecorations(name, 'main.tex', source);
  const projectedInVisual = visualDecorations.find((item) => item.id === thread.id);
  expect(projectedInVisual).toMatchObject({
    kind: 'change',
    changeType: 'replace',
    originalText: 'robust',
    proposedText: 'well-supported',
  });
  expect(services.latexSource.getSnapshot(wsPath)?.text).toBe(source);

  // Visual Accept calls the same canonical Review service/change id.
  await services.projectReview.acceptReviewChange(name, thread.id);
  expect(services.latexSource.getSnapshot(wsPath)?.text).toContain('A well-supported local claim.');
  expect(services.projectReview.getChanges(name, { status: 'pending', file: 'main.tex' })).toHaveLength(0);
  expect(services.projectReview.getThread(name, thread.id)?.change?.status).toBe('accepted');
  env.controller.abort();
});

it('AI Visual suggestion projects as the same pending Review change in Source and can be rejected there', async () => {
  const { env, services, name, wsPath, source } = await setup(`ai-visual-source-parity-${crypto.randomUUID()}`);
  const from = source.indexOf('robust');
  const snapshot = services.latexSource.getSnapshot(wsPath)!;
  // Visual mode has already resolved its selection back to this canonical source range.
  const thread = await services.projectReview.proposeSourceChange(name, {
    filePath: 'main.tex',
    from,
    to: from + 'robust'.length,
    originalText: 'robust',
    proposedText: 'resilient',
    sourceRevision: snapshot.revision,
    sourceAnchor: captureSourceAnchor({ filePath: 'main.tex', source, from, to: from + 'robust'.length, sourceRevision: snapshot.revision }),
    author: { id: 'interactive-ai-visual', displayName: 'Learnitive AI', kind: 'ai' },
    metadata: { source: 'ai', category: 'writing', reason: 'Visual-mode suggestion' },
  });

  const sourceDecorations = services.projectReview.getEditorDecorations(name, 'main.tex', source);
  const projectedInSource = sourceDecorations.find((item) => item.id === thread.id);
  expect(projectedInSource).toMatchObject({
    kind: 'change',
    changeType: 'replace',
    originalText: 'robust',
    proposedText: 'resilient',
  });

  // Source Reject resolves the same Review record and leaves canonical LaTeX untouched.
  await services.projectReview.rejectReviewChange(name, thread.id);
  expect(services.latexSource.getSnapshot(wsPath)?.text).toBe(source);
  expect(services.projectReview.getChanges(name, { status: 'pending', file: 'main.tex' })).toHaveLength(0);
  expect(services.projectReview.getThread(name, thread.id)?.change?.status).toBe('rejected');
  env.controller.abort();
});
