/**
 * #2667 — a `\\?\`-prefixed path must still find its registry entry.
 *
 * This is the Linux-runnable guard for the `canonicalizePath` wiring. The
 * companion assertions in `repo-manager.test.ts` exercise the real
 * `realpathSync.native` and are therefore `it.skipIf(win32)`, so they only run on
 * the windows-latest matrix leg — leaving the Ubuntu gate with no coverage of the
 * behaviour at all. This file closes that hole by injecting the two platform
 * primitives and nothing else:
 *
 *   - `path` → `path.win32`, which is Node's real Windows path implementation,
 *     not a stand-in for it;
 *   - `realpathSync.native` → its two actual Windows behaviours: for a path that
 *     is on disk libuv's `fs__realpath_handle` strips `\\?\` before returning,
 *     and for a path that is not it throws ENOENT;
 *   - `stripWindowsLongPathPrefix` → the real implementation, pinned to `'win32'`
 *     instead of defaulting to `process.platform`.
 *
 * That last one is deliberately a module mock rather than an
 * `Object.defineProperty(process, 'platform', …)`: module mocks are scoped to this
 * file, whereas `process` is shared by every test file in the same worker, so
 * overriding it risks a sibling that branches on the host platform.
 *
 * `canonicalizePath` and `registryPathEquals` themselves run unmodified. Run
 * against the pre-fix tree (89bbdcf5) the two `catch`-fallback cases below fail
 * and the realpath case passes, which is exactly the asymmetry the fix targets:
 * the realpath branch never leaked, because libuv strips the prefix itself.
 *
 * Deliberately NOT registered in `scripts/cross-platform-tests.ts`: it simulates
 * Windows rather than needing it, so its home is the Ubuntu suite.
 */
import { describe, it, expect, vi } from 'vitest';

// `vi.mock` factories are hoisted above imports, so the set of "paths that exist
// on disk" has to be hoisted with them rather than captured from module scope.
const onDisk = vi.hoisted(() => new Set<string>());

vi.mock('path', async () => {
  const real = await vi.importActual<typeof import('path')>('path');
  return { ...real.win32, default: real.win32 };
});

vi.mock('fs', async (importOriginal) => {
  const actual = await importOriginal<typeof import('fs')>();
  const realpath = (target: string): string => {
    const bare = String(target).replace(/^\\\\\?\\(UNC\\)?/, (_m, unc) => (unc ? '\\\\' : ''));
    if (!onDisk.has(bare)) {
      const err: NodeJS.ErrnoException = new Error(
        `ENOENT: no such file or directory, realpath '${target}'`,
      );
      err.code = 'ENOENT';
      throw err;
    }
    return bare;
  };
  const realpathSync = Object.assign(realpath, { native: realpath });
  return { ...actual, realpathSync, default: { ...actual, realpathSync } };
});

// The real helper, pinned to win32 — `canonicalizePath` calls it without a
// platform argument, so it would otherwise default to the host's.
vi.mock('../../src/lib/utils.js', async (importOriginal) => {
  const actual = await importOriginal<typeof import('../../src/lib/utils.js')>();
  return {
    ...actual,
    stripWindowsLongPathPrefix: (p: string) => actual.stripWindowsLongPathPrefix(p, 'win32'),
  };
});

import {
  assertSafeStoragePath,
  canonicalizePath,
  registryPathEquals,
  type RegistryEntry,
} from '../../src/storage/repo-manager.js';
import { resolveRegisteredRepoEntry } from '../../src/server/api.js';

/**
 * The lookup every registry consumer performs — `resolveRegistryEntry`,
 * `registerRepo`, `unregisterRepo`, `isRepoRegistered`, `cloneDirBelongsToEntry`,
 * the MCP handle match and the server repo routes all canonicalise both sides and
 * compare with `registryPathEquals`.
 */
const registryLookupMatches = (stored: string, supplied: string): boolean =>
  registryPathEquals(canonicalizePath(stored), canonicalizePath(supplied));

describe('canonicalizePath vs the `\\\\?\\` long-path prefix (#2667)', () => {
  it('matches a prefixed drive path against its stored entry when the repo is gone from disk', () => {
    const stored = 'D:\\Projects\\moved-away';

    // The `catch` fallback: realpath throws, so pre-fix this returned
    // `path.resolve(p)` with the prefix still attached and matched nothing.
    expect(canonicalizePath(`\\\\?\\${stored}`)).toBe(stored);
    expect(registryLookupMatches(stored, `\\\\?\\${stored}`)).toBe(true);
  });

  it('matches a prefixed UNC path against its stored entry when the share is unreachable', () => {
    const stored = '\\\\server\\share\\moved-away';

    expect(canonicalizePath('\\\\?\\UNC\\server\\share\\moved-away')).toBe(stored);
    expect(registryLookupMatches(stored, '\\\\?\\UNC\\server\\share\\moved-away')).toBe(true);
  });

  it('matches whatever case the UNC token is spelled in', () => {
    const stored = '\\\\server\\share\\moved-away';

    expect(registryLookupMatches(stored, '\\\\?\\unc\\server\\share\\moved-away')).toBe(true);
    expect(registryLookupMatches(stored, '\\\\?\\Unc\\server\\share\\moved-away')).toBe(true);
  });

  it('still matches through the realpath branch when the repo is present on disk', () => {
    const stored = 'D:\\Projects\\present';
    onDisk.add(stored);

    // This branch never leaked — libuv strips the prefix inside fs__realpath — so
    // it passes on the pre-fix tree too. It is here so a future change that moves
    // the normalisation cannot silently break the path that always worked.
    expect(canonicalizePath(`\\\\?\\${stored}`)).toBe(stored);
    expect(registryLookupMatches(stored, `\\\\?\\${stored}`)).toBe(true);
  });

  it('leaves an un-prefixed path byte-identical, on both branches', () => {
    const present = 'D:\\Projects\\present';
    onDisk.add(present);

    expect(canonicalizePath(present)).toBe(present);
    expect(canonicalizePath('D:\\Projects\\absent')).toBe('D:\\Projects\\absent');
  });

  // The spellings the helper deliberately does not strip must stay unmatched
  // rather than be half-normalized. Asserted through canonicalizePath, not just
  // the helper, so the deliberate branch asymmetry is pinned where it is used.
  it('leaves volume-GUID and device-namespace spellings unmatched', () => {
    expect(canonicalizePath('\\\\?\\Volume{1a2b}\\repo')).toBe('\\\\?\\Volume{1a2b}\\repo');
    expect(canonicalizePath('\\\\.\\D:\\repo')).toBe('\\\\.\\D:\\repo');

    expect(registryLookupMatches('D:\\repo', '\\\\?\\Volume{1a2b}\\repo')).toBe(false);
    expect(registryLookupMatches('D:\\repo', '\\\\.\\D:\\repo')).toBe(false);
  });
});

// The guard in front of `fs.rm(recursive)` in remove.ts / clean.ts. It compares
// `path.resolve` forms on both sides and deliberately does NOT canonicalize, so a
// prefixed entry stays self-consistent while a mixed-form entry fails closed.
// Pinned here because "complete the fix by stripping here too" is the tempting
// follow-up refactor, and it would widen what the recursive delete accepts.
describe('assertSafeStoragePath vs the `\\\\?\\` prefix (#2667)', () => {
  const base: Omit<RegistryEntry, 'storagePath'> = {
    name: 'repo',
    path: '\\\\?\\D:\\Projects\\repo',
    indexedAt: '2026-07-26T00:00:00.000Z',
    lastCommit: 'deadbee',
  };

  it('accepts an entry whose path and storagePath share the prefix', () => {
    expect(() =>
      assertSafeStoragePath({ ...base, storagePath: '\\\\?\\D:\\Projects\\repo\\.gitnexus' }),
    ).not.toThrow();
  });

  it('rejects a mixed-form entry instead of deleting through it', () => {
    expect(() =>
      assertSafeStoragePath({ ...base, storagePath: 'D:\\Projects\\repo\\.gitnexus' }),
    ).toThrow();
  });
});

// The consumer surface the fix exists for: an MCP `repo` argument or an
// `?repo=` query value arriving in the prefixed spelling must resolve the
// un-prefixed registry entry it names.
describe('resolveRegisteredRepoEntry with a prefixed path claim (#2667)', () => {
  const registered: RegistryEntry = {
    name: 'repo',
    path: 'D:\\Projects\\repo',
    storagePath: 'D:\\Projects\\repo\\.gitnexus',
    indexedAt: '2026-07-26T00:00:00.000Z',
    lastCommit: 'deadbee',
  };

  it('resolves the entry when the caller supplies the extended-length spelling', () => {
    expect(resolveRegisteredRepoEntry([registered], '\\\\?\\D:\\Projects\\repo')).toBe(registered);
  });

  it('still fails closed for a prefixed path that names no entry', () => {
    expect(resolveRegisteredRepoEntry([registered], '\\\\?\\D:\\Projects\\other')).toBeNull();
  });
});
