import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { callCodecontext } from '../codecontext_client.js';

// ---- fixtures ---------------------------------------------------------------

let workDir: string;
let projectDir: string;
let outsideDir: string;

beforeEach(async () => {
  // Shared workspace so projectDir and outsideDir are siblings but the
  // realpath escape check still treats outsideDir as outside the project.
  workDir = await mkdtemp(join(tmpdir(), 'codecontext-test-'));
  projectDir = join(workDir, 'project');
  outsideDir = join(workDir, 'outside');
  await mkdir(projectDir);
  await mkdir(outsideDir);
});

afterEach(async () => {
  await rm(workDir, { recursive: true, force: true });
  vi.restoreAllMocks();
});

function mockJSONResponse(body: unknown, status = 200): Response {
  return new Response(JSON.stringify(body), {
    status,
    headers: { 'content-type': 'application/json' },
  });
}

// ---- tests ------------------------------------------------------------------

describe('callCodecontext — target_dir validation', () => {
  it('rejects when target_dir does not exist', async () => {
    const fetcher = vi.fn();
    await expect(
      callCodecontext(
        {
          toolName: 'get_codebase_overview',
          args: { target_dir: '/nonexistent/path/deliberately/missing' },
          projectPath: projectDir,
        },
        fetcher as unknown as typeof fetch,
      ),
    ).rejects.toThrow(/target_dir does not exist/);
    expect(fetcher).not.toHaveBeenCalled();
  });

  it('rejects when target_dir is outside the project root', async () => {
    const fetcher = vi.fn();
    await expect(
      callCodecontext(
        {
          toolName: 'get_codebase_overview',
          args: { target_dir: outsideDir },
          projectPath: projectDir,
        },
        fetcher as unknown as typeof fetch,
      ),
    ).rejects.toThrow(/escapes project root/);
    expect(fetcher).not.toHaveBeenCalled();
  });

  it('injects projectPath as target_dir when args.target_dir is undefined', async () => {
    const fetcher = vi.fn().mockResolvedValue(
      mockJSONResponse({ result: 'overview text', error: null }),
    );
    await callCodecontext(
      {
        toolName: 'get_codebase_overview',
        args: { include_stats: true },
        projectPath: projectDir,
      },
      fetcher as unknown as typeof fetch,
    );
    expect(fetcher).toHaveBeenCalledTimes(1);
    const body = JSON.parse(fetcher.mock.calls[0]![1]!.body as string);
    expect(body.target_dir).toBe(projectDir);
    expect(body.include_stats).toBe(true);
  });
});

describe('callCodecontext — HTTP request shape', () => {
  it('POSTs to /v1/<toolName> with JSON content-type', async () => {
    const fetcher = vi.fn().mockResolvedValue(
      mockJSONResponse({ result: 'ok', error: null }),
    );
    await callCodecontext(
      {
        toolName: 'search_symbols',
        args: { query: 'User', limit: 5 },
        projectPath: projectDir,
      },
      fetcher as unknown as typeof fetch,
    );
    expect(fetcher).toHaveBeenCalledTimes(1);
    const [url, init] = fetcher.mock.calls[0]!;
    expect(url).toMatch(/\/v1\/search_symbols$/);
    expect(init.method).toBe('POST');
    expect(init.headers['Content-Type']).toBe('application/json');
    const body = JSON.parse(init.body);
    expect(body).toMatchObject({ query: 'User', limit: 5, target_dir: projectDir });
  });
});

describe('callCodecontext — result handling', () => {
  it('returns { result, truncated: false } when codecontext result is under the 32 kB limit', async () => {
    const fetcher = vi.fn().mockResolvedValue(
      mockJSONResponse({ result: 'a short markdown report', error: null }),
    );
    const out = await callCodecontext(
      {
        toolName: 'get_codebase_overview',
        args: {},
        projectPath: projectDir,
      },
      fetcher as unknown as typeof fetch,
    );
    expect(out.truncated).toBe(false);
    expect(out.result).toBe('a short markdown report');
  });

  it('truncates and marks truncated: true when result exceeds 32 kB', async () => {
    const bigResult = 'x'.repeat(40_000);
    const fetcher = vi.fn().mockResolvedValue(
      mockJSONResponse({ result: bigResult, error: null }),
    );
    const out = await callCodecontext(
      {
        toolName: 'get_codebase_overview',
        args: {},
        projectPath: projectDir,
      },
      fetcher as unknown as typeof fetch,
    );
    expect(out.truncated).toBe(true);
    expect(out.result).toMatch(/\[truncated, 8000 chars omitted; narrow with file_path/);
    expect(out.result.length).toBeLessThan(bigResult.length);
  });
});

describe('callCodecontext — error paths', () => {
  it('throws an actionable error when codecontext reports an empty-file parser failure', async () => {
    const fetcher = vi.fn().mockResolvedValue(
      mockJSONResponse({
        result: null,
        error:
          'failed to refresh analysis: failed to analyze directory: ' +
          'failed to parse file /opt/boolab/.opencode/node_modules/foo/index.js: content is empty',
      }),
    );
    await expect(
      callCodecontext(
        { toolName: 'get_codebase_overview', args: {}, projectPath: projectDir },
        fetcher as unknown as typeof fetch,
      ),
    ).rejects.toThrow(/codecontext parse failure.*\.codecontextignore/);
  });

  it('throws a generic error when codecontext reports other errors', async () => {
    const fetcher = vi.fn().mockResolvedValue(
      mockJSONResponse({ result: null, error: 'symbol_name is required' }),
    );
    await expect(
      callCodecontext(
        { toolName: 'get_symbol_info', args: {}, projectPath: projectDir },
        fetcher as unknown as typeof fetch,
      ),
    ).rejects.toThrow(/codecontext error: symbol_name is required/);
  });

  it('throws on HTTP non-2xx response', async () => {
    const fetcher = vi.fn().mockResolvedValue(
      new Response('upstream gateway boom', { status: 502 }),
    );
    await expect(
      callCodecontext(
        { toolName: 'get_codebase_overview', args: {}, projectPath: projectDir },
        fetcher as unknown as typeof fetch,
      ),
    ).rejects.toThrow(/codecontext HTTP 502/);
  });

  it('translates a fetcher AbortError to a "timed out" error', async () => {
    // The catch branch in callCodecontext maps any AbortError (whether it
    // came from our internal 30s setTimeout or from the fetcher itself) to a
    // "timed out" message. Exercising the catch directly is cleaner than
    // wrangling vi.useFakeTimers with realpath's microtask scheduling.
    const abortingFetcher = vi.fn().mockImplementation(() => {
      const err = new Error('The user aborted a request.');
      err.name = 'AbortError';
      return Promise.reject(err);
    });
    await expect(
      callCodecontext(
        { toolName: 'get_codebase_overview', args: {}, projectPath: projectDir },
        abortingFetcher as unknown as typeof fetch,
      ),
    ).rejects.toThrow(/timed out after 30000ms/);
  });
});

// ---- v1.13.18: file_path resolution tests -----------------------------------

describe('callCodecontext — file_path resolution', () => {
  // Case 1: relative path resolves to absolute under project root
  it('resolves a relative file_path to an absolute path inside project root', async () => {
    // Create a real file so realpath can canonicalise it
    const fileName = 'src_module.ts';
    await writeFile(join(projectDir, fileName), '// hello');
    const fetcher = vi.fn().mockResolvedValue(
      mockJSONResponse({ result: 'file analysis', error: null }),
    );
    await callCodecontext(
      {
        toolName: 'get_file_analysis',
        args: { file_path: fileName },
        projectPath: projectDir,
      },
      fetcher as unknown as typeof fetch,
    );
    expect(fetcher).toHaveBeenCalledTimes(1);
    const body = JSON.parse(fetcher.mock.calls[0]![1]!.body as string);
    // Should be the resolved absolute path
    expect(body.file_path).toBe(join(projectDir, fileName));
  });

  // Case 2: absolute path inside project root → realpathed → forwarded
  it('passes through an absolute file_path inside project root', async () => {
    const fileName = 'absolute_target.ts';
    const absPath = join(projectDir, fileName);
    await writeFile(absPath, '// absolute');
    const fetcher = vi.fn().mockResolvedValue(
      mockJSONResponse({ result: 'analysis', error: null }),
    );
    await callCodecontext(
      {
        toolName: 'get_file_analysis',
        args: { file_path: absPath },
        projectPath: projectDir,
      },
      fetcher as unknown as typeof fetch,
    );
    const body = JSON.parse(fetcher.mock.calls[0]![1]!.body as string);
    expect(body.file_path).toBe(absPath);
  });

  // Case 3: relative escape path → rejected with same error shape as target_dir escape
  it('rejects a relative file_path that escapes the project root', async () => {
    const fetcher = vi.fn();
    await expect(
      callCodecontext(
        {
          toolName: 'get_file_analysis',
          args: { file_path: '../../etc/passwd' },
          projectPath: projectDir,
        },
        fetcher as unknown as typeof fetch,
      ),
    ).rejects.toThrow(/escapes project root/);
    expect(fetcher).not.toHaveBeenCalled();
  });

  // Case 4: absolute path outside project root → rejected
  it('rejects an absolute file_path outside the project root', async () => {
    const fetcher = vi.fn();
    await expect(
      callCodecontext(
        {
          toolName: 'get_file_analysis',
          // /etc/passwd is outside any tmpdir project root
          args: { file_path: '/etc/passwd' },
          projectPath: projectDir,
        },
        fetcher as unknown as typeof fetch,
      ),
    ).rejects.toThrow(/escapes project root/);
    expect(fetcher).not.toHaveBeenCalled();
  });

  // Case 5: nonexistent file (ENOENT) → forwarded as un-realpath'd absolute
  it('forwards a nonexistent file_path as absolute without throwing', async () => {
    const missingPath = join(projectDir, 'does_not_exist.ts');
    const fetcher = vi.fn().mockResolvedValue(
      mockJSONResponse({ result: null, error: 'File not found in graph: ' + missingPath }),
    );
    // The resolver should NOT throw; the error comes back from the sidecar
    await expect(
      callCodecontext(
        {
          toolName: 'get_file_analysis',
          args: { file_path: 'does_not_exist.ts' },
          projectPath: projectDir,
        },
        fetcher as unknown as typeof fetch,
      ),
    ).rejects.toThrow(/File not found in graph/);
    // Wire was still called — resolver forwarded the path
    expect(fetcher).toHaveBeenCalledTimes(1);
    const body = JSON.parse(fetcher.mock.calls[0]![1]!.body as string);
    // Should receive the absolute (non-realpathed) path
    expect(body.file_path).toBe(missingPath);
  });

  // Case 6: empty string → skipped by guard, reaches wire unmodified
  // Note: Zod .trim().min(1) in get_file_analysis rejects empty before the
  // shim is reached in production. At the shim layer, the guard
  // `file_path.trim() !== ''` skips the resolver for empty strings so that
  // optional-file_path wrappers treat '' as "not provided". This is a
  // deliberate design; callers that require file_path validate at the Zod layer.
  it('skips resolver for empty string file_path (treated as not provided)', async () => {
    const fetcher = vi.fn().mockResolvedValue(
      mockJSONResponse({ result: 'analysis', error: null }),
    );
    // Should succeed — empty string is treated as "no file_path"
    await callCodecontext(
      {
        toolName: 'get_file_analysis',
        args: { file_path: '' },
        projectPath: projectDir,
      },
      fetcher as unknown as typeof fetch,
    );
    expect(fetcher).toHaveBeenCalledTimes(1);
    const body = JSON.parse(fetcher.mock.calls[0]![1]!.body as string);
    // Empty string passes through unchanged (resolver not invoked)
    expect(body.file_path).toBe('');
  });

  // Case 7: wrapper without file_path (e.g. get_codebase_overview) → resolver not invoked
  it('does not invoke file_path resolver when file_path is absent from args', async () => {
    const fetcher = vi.fn().mockResolvedValue(
      mockJSONResponse({ result: 'overview', error: null }),
    );
    await callCodecontext(
      {
        toolName: 'get_codebase_overview',
        args: { include_stats: true },
        projectPath: projectDir,
      },
      fetcher as unknown as typeof fetch,
    );
    expect(fetcher).toHaveBeenCalledTimes(1);
    const body = JSON.parse(fetcher.mock.calls[0]![1]!.body as string);
    // No file_path in the wire body
    expect('file_path' in body).toBe(false);
  });

  // Case 8: absolute path with `..` that resolves outside project root, even
  // when the literal path is ENOENT. Without resolve() in the absolute branch
  // the prefix check false-positives because the raw `<projectDir>/../etc/x`
  // literal starts with `<projectDir>/`.
  it('rejects absolute file_path with `..` resolving outside project root (ENOENT branch)', async () => {
    const fetcher = vi.fn();
    const escapingAbsolute = `${projectDir}/../etc/non_existent_passwd`;
    await expect(
      callCodecontext(
        {
          toolName: 'get_file_analysis',
          args: { file_path: escapingAbsolute },
          projectPath: projectDir,
        },
        fetcher as unknown as typeof fetch,
      ),
    ).rejects.toThrow(/escapes project root/);
    expect(fetcher).not.toHaveBeenCalled();
  });

  // Case 9: in-project symlink targeting outside the project root. This is the
  // canonical realpath defense — realpath must canonicalise the symlink and
  // the escape check must reject. Without this test, a symlink-out hole could
  // regress silently.
  it('rejects file_path that resolves through a symlink leaving project root', async () => {
    const outsideDir = await mkdtemp(join(tmpdir(), 'codecontext-outside-'));
    try {
      const evilTarget = join(outsideDir, 'secrets.txt');
      await writeFile(evilTarget, 'top secret');
      await symlink(evilTarget, join(projectDir, 'evil-link'));
      const fetcher = vi.fn();
      await expect(
        callCodecontext(
          {
            toolName: 'get_file_analysis',
            args: { file_path: 'evil-link' },
            projectPath: projectDir,
          },
          fetcher as unknown as typeof fetch,
        ),
      ).rejects.toThrow(/escapes project root/);
      expect(fetcher).not.toHaveBeenCalled();
    } finally {
      await rm(outsideDir, { recursive: true, force: true });
    }
  });
});
