// fileVerbs: pure planners behind the checkout/add/delete/revert toolbar
// verbs (Task 3). Table-driven against the engine's raw status strings
// (statusChips.js / cambium-core's FileStatus, see fileVerbs.js's header
// comment) rather than any React state, so these pin the exact
// classification rules the hook now delegates to.
import { describe, expect, it } from 'vitest';
import {
  classifyAddTargets,
  classifyDeleteTargets,
  planRevert,
  planSubmit,
  planPull,
  seedPullingPaths,
  submitDialogs,
} from './fileVerbs';

function statusMap(entries) {
  return new Map(entries);
}

describe('classifyAddTargets', () => {
  const cases = [
    {
      name: 'a single notAdded path is addable',
      scope: ['New/thing.txt'],
      statuses: [['New/thing.txt', 'notAdded']],
      want: { add: ['New/thing.txt'], ignoredOnly: false },
    },
    {
      name: 'mixed notAdded + already-markedForAdd: only notAdded is addable, not ignoredOnly',
      scope: ['a.txt', 'b.txt'],
      statuses: [
        ['a.txt', 'notAdded'],
        ['b.txt', 'markedForAdd'],
      ],
      want: { add: ['a.txt'], ignoredOnly: false },
    },
    {
      name: 'scope has local-only files but every one is ignored (no status entry at all): ignoredOnly',
      scope: ['ignored.tmp', 'also-ignored.tmp'],
      statuses: [],
      want: { add: [], ignoredOnly: true },
    },
    {
      name: 'all already markedForAdd (unadd territory): not ignoredOnly, nothing addable',
      scope: ['a.txt'],
      statuses: [['a.txt', 'markedForAdd']],
      want: { add: [], ignoredOnly: false },
    },
    {
      name: 'empty scope: nothing addable, not ignoredOnly (nothing was selected)',
      scope: [],
      statuses: [],
      want: { add: [], ignoredOnly: false },
    },
  ];

  for (const { name, scope, statuses, want } of cases) {
    it(name, () => {
      expect(classifyAddTargets(scope, statusMap(statuses))).toEqual(want);
    });
  }
});

describe('classifyDeleteTargets', () => {
  it('splits a server-backed file from a never-added local-only file', () => {
    const serverFiles = new Map([['Art/rig.blend', { hash: 'h1' }]]);
    const localFiles = new Map([
      ['Art/rig.blend', { hash: 'h1' }],
      ['scratch.tmp', { hash: 'h2' }],
    ]);
    const statuses = statusMap([]);
    const result = classifyDeleteTargets(
      ['Art/rig.blend', 'scratch.tmp'],
      [],
      serverFiles,
      localFiles,
      statuses
    );
    expect(result.engineFiles).toEqual(['Art/rig.blend']);
    expect(result.localOnlyFiles).toEqual(['scratch.tmp']);
    expect(result.engineFolders).toEqual([]);
    expect(result.localOnlyFolders).toEqual([]);
    expect(result.needsConfirm).toBe(true); // never-added content involved
  });

  it('excludes a server file already marked for delete from engineFiles', () => {
    const serverFiles = new Map([['a.txt', {}]]);
    const localFiles = new Map();
    const statuses = statusMap([['a.txt', 'markedForDelete']]);
    const result = classifyDeleteTargets(['a.txt'], [], serverFiles, localFiles, statuses);
    expect(result.engineFiles).toEqual([]);
  });

  it('excludes a server file locked by someone else from engineFiles', () => {
    const serverFiles = new Map([['a.txt', {}]]);
    const localFiles = new Map();
    const statuses = statusMap([['a.txt', 'lockedByOther']]);
    const result = classifyDeleteTargets(['a.txt'], [], serverFiles, localFiles, statuses);
    expect(result.engineFiles).toEqual([]);
  });

  it('excludes a server file locked by me on another device from engineFiles', () => {
    const serverFiles = new Map([['a.txt', {}]]);
    const localFiles = new Map();
    const statuses = statusMap([['a.txt', 'lockedByMeElsewhere']]);
    const result = classifyDeleteTargets(['a.txt'], [], serverFiles, localFiles, statuses);
    expect(result.engineFiles).toEqual([]);
  });

  it('includes a server file checked out by me in engineFiles', () => {
    const serverFiles = new Map([['a.txt', {}]]);
    const localFiles = new Map([['a.txt', {}]]);
    const statuses = statusMap([['a.txt', 'checkedOutByMe']]);
    const result = classifyDeleteTargets(['a.txt'], [], serverFiles, localFiles, statuses);
    expect(result.engineFiles).toEqual(['a.txt']);
  });

  it('a selected folder with server content becomes an engine folder', () => {
    const serverFiles = new Map([
      ['Assets/a.txt', {}],
      ['Assets/b.txt', {}],
    ]);
    const localFiles = new Map();
    const statuses = statusMap([]);
    const result = classifyDeleteTargets([], ['Assets'], serverFiles, localFiles, statuses);
    expect(result.engineFolders).toEqual(['Assets']);
    expect(result.localOnlyFolders).toEqual([]);
    expect(result.needsConfirm).toBe(false);
  });

  it('a selected folder blocked by someone else\'s lock lands in neither folder list', () => {
    const serverFiles = new Map([['Assets/a.txt', {}]]);
    const localFiles = new Map();
    const statuses = statusMap([['Assets/a.txt', 'lockedByOther']]);
    const result = classifyDeleteTargets([], ['Assets'], serverFiles, localFiles, statuses);
    expect(result.engineFolders).toEqual([]);
    expect(result.localOnlyFolders).toEqual([]);
  });

  it('a purely local folder with files inside is a local-only folder that needs confirm', () => {
    const serverFiles = new Map();
    const localFiles = new Map([['Junk/a.txt', {}], ['Junk/b.txt', {}]]);
    const statuses = statusMap([]);
    const result = classifyDeleteTargets([], ['Junk'], serverFiles, localFiles, statuses);
    expect(result.localOnlyFolders).toEqual(['Junk']);
    expect(result.engineFolders).toEqual([]);
    expect(result.needsConfirm).toBe(true);
  });

  it('an empty purely local folder is local-only but skips the confirm warning', () => {
    const serverFiles = new Map();
    const localFiles = new Map();
    const statuses = statusMap([]);
    const result = classifyDeleteTargets([], ['Empty'], serverFiles, localFiles, statuses);
    expect(result.localOnlyFolders).toEqual(['Empty']);
    expect(result.needsConfirm).toBe(false);
  });

  it('a mixed selection (server file + never-added file + local-only folder) splits every bucket', () => {
    const serverFiles = new Map([['Assets/tracked.txt', {}]]);
    const localFiles = new Map([
      ['Assets/tracked.txt', {}],
      ['loose.tmp', {}],
      ['Junk/x.txt', {}],
    ]);
    const statuses = statusMap([]);
    const result = classifyDeleteTargets(
      ['Assets/tracked.txt', 'loose.tmp'],
      ['Junk'],
      serverFiles,
      localFiles,
      statuses
    );
    expect(result.engineFiles).toEqual(['Assets/tracked.txt']);
    expect(result.localOnlyFiles).toEqual(['loose.tmp']);
    expect(result.localOnlyFolders).toEqual(['Junk']);
    expect(result.needsConfirm).toBe(true);
  });
});

describe('planRevert', () => {
  it('builds an unforced revert line and flags no confirm when nothing is modified', () => {
    const result = planRevert(['a.txt', 'b.txt'], () => false);
    expect(result.line).toBe('revert //a.txt //b.txt');
    expect(result.needsConfirm).toBe(false);
  });

  it('flags needsConfirm when any path has real local changes, line still unforced', () => {
    const isModifiedFn = (path) => path === 'b.txt';
    const result = planRevert(['a.txt', 'b.txt'], isModifiedFn);
    expect(result.line).toBe('revert //a.txt //b.txt');
    expect(result.needsConfirm).toBe(true);
  });

  it('does not need confirm for a pure un-mark-add revert (no modified paths)', () => {
    const result = planRevert(['New/thing.txt'], () => false);
    expect(result.needsConfirm).toBe(false);
  });

  it('handles an empty path list', () => {
    const result = planRevert([], () => true);
    expect(result.line).toBe('revert');
    expect(result.needsConfirm).toBe(false);
  });
});

describe('planSubmit', () => {
  it('builds the submit line from item paths + folders, message quoted', () => {
    const items = [{ path: 'a.txt' }, { path: 'b b.txt' }];
    const folders = ['Old'];
    const { line } = planSubmit(items, folders, 'fix things');
    expect(line).toBe('submit //a.txt "//b b.txt" //Old -m "fix things"');
  });

  it('quotes a message containing an apostrophe without escaping it', () => {
    const { line } = planSubmit([{ path: 'a.txt' }], [], "user's fix");
    expect(line).toBe('submit //a.txt -m "user\'s fix"');
  });

  it('handles an empty items+folders list with an empty description', () => {
    const { line } = planSubmit([], [], '');
    expect(line).toBe('submit -m ""');
  });
});

describe('planPull', () => {
  it('builds a bare pull for a root-scope selection, ignoring files/folders', () => {
    const { line } = planPull({ hasRoot: true, files: ['ignored.txt'], folders: ['ignored'] });
    expect(line).toBe('pull');
  });

  it('builds a scoped pull line from selected files and folders', () => {
    const { line } = planPull({ hasRoot: false, files: ['a.txt'], folders: ['Art'] });
    expect(line).toBe('pull //a.txt //Art');
  });

  it('handles an empty (non-root) scope', () => {
    const { line } = planPull({ hasRoot: false, files: [], folders: [] });
    expect(line).toBe('pull');
  });
});

describe('seedPullingPaths', () => {
  const serverFiles = new Map([
    ['Art/rig.blend', {}],
    ['Art/Sub/tex.png', {}],
    ['top.txt', {}],
  ]);
  const localFiles = new Map([
    ['Art/rig.blend', {}], // already on disk — present-locally
    ['loose.tmp', {}], // local-only — not on the server at all
  ]);

  const cases = [
    {
      name: 'a server file in scope not yet on disk is seeded',
      scopePaths: ['top.txt'],
      want: ['top.txt'],
    },
    {
      name: 'a server file already present locally is not seeded (no flicker risk)',
      scopePaths: ['Art/rig.blend'],
      want: [],
    },
    {
      name: 'a genuinely local-only file is never seeded (not a server path)',
      scopePaths: ['loose.tmp'],
      want: [],
    },
    {
      name: 'folder-scope prefix matching pulls in every not-yet-local server file under it',
      scopePaths: ['Art'],
      want: ['Art/Sub/tex.png'], // Art/rig.blend excluded: already local
    },
    {
      name: 'root scope (empty scopePaths) seeds every not-yet-local server file',
      scopePaths: [],
      want: ['Art/Sub/tex.png', 'top.txt'],
    },
  ];

  for (const { name, scopePaths, want } of cases) {
    it(name, () => {
      const result = seedPullingPaths(scopePaths, serverFiles, localFiles);
      expect(result).toBeInstanceOf(Set);
      expect([...result].sort()).toEqual([...want].sort());
    });
  }
});

describe('submitDialogs', () => {
  it('returns null for a clean success with no conflicts', () => {
    const result = { ok: true, data: { conflicts: [] } };
    expect(submitDialogs(result, {}, () => undefined)).toBeNull();
  });

  it('splits unsafe-name conflicts into the Naming Issues warning dialog', () => {
    const result = {
      ok: true,
      data: {
        conflicts: [
          { path: 'CON.txt', reason: 'unsafe name: reserved device name' },
          { path: 'b.txt', reason: 'changed on the server' },
        ],
      },
    };
    const dialog = submitDialogs(result, {}, () => undefined);
    expect(dialog.type).toBe('filelist');
    expect(dialog.variant).toBe('warning');
    expect(dialog.color).toBe('orange');
    expect(dialog.title).toBe('Naming Issues');
    expect(dialog.rows).toEqual([{ path: 'CON.txt', note: 'reserved device name' }]);
    // the naming split takes priority: the non-naming conflict is not shown here
    expect(dialog.rows).not.toContainEqual(expect.objectContaining({ path: 'b.txt' }));
  });

  it('resolves a lock-conflict holder to a display name via locks + getUser', () => {
    const result = { ok: true, data: { conflicts: [{ path: 'b.txt', reason: 'changed on the server' }] } };
    const locks = { 'b.txt': { username: '@robin' } };
    const getUser = (username) => (username === '@robin' ? { displayName: 'Robin' } : undefined);
    const dialog = submitDialogs(result, locks, getUser);
    expect(dialog.title).toBe('Some files were not submitted');
    expect(dialog.color).toBe('grey');
    expect(dialog.rows).toEqual([{ path: 'b.txt', note: 'Robin' }]);
  });

  it('falls back to the raw reason when the conflicting path has no lock entry', () => {
    const result = { ok: true, data: { conflicts: [{ path: 'b.txt', reason: 'changed on the server' }] } };
    const dialog = submitDialogs(result, {}, () => undefined);
    expect(dialog.rows).toEqual([{ path: 'b.txt', note: 'changed on the server' }]);
  });

  it('maps data.failures to a red Submit failed filelist dialog', () => {
    const result = { ok: false, human: 'submit failed: could not upload a.txt: timed out', data: { failures: [{ path: 'a.txt', error: 'timed out' }] } };
    const dialog = submitDialogs(result, {}, () => undefined);
    expect(dialog.type).toBe('filelist');
    expect(dialog.variant).toBe('danger');
    expect(dialog.color).toBe('red');
    expect(dialog.title).toBe('Submit failed');
    expect(dialog.rows).toEqual([{ path: 'a.txt', note: 'timed out' }]);
  });

  it('falls back to a plain dialog with the human message when there are no failures rows', () => {
    const result = { ok: false, human: 'cannot submit: a.txt has a naming issue; rename it first.' };
    const dialog = submitDialogs(result, {}, () => undefined);
    expect(dialog.type).toBe('info');
    expect(dialog.title).toBe('Submit failed');
    expect(dialog.text).toBe(result.human);
  });
});
