import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import type { ToolContext, ToolResult } from './core.js';

// The cache DB path is resolved from process.cwd() at module load, so stub
// cwd BEFORE importing the module to keep the sqlite cache in a temp dir.
const tmpRoot = mkdtempSync(join(tmpdir(), 'ms-learn-test-'));
let executeTool: (n: string, i: Record<string, unknown>, c: ToolContext) => Promise<ToolResult | null>;
let TOOL_DEFS: Record<string, unknown>;

beforeAll(async () => {
  vi.spyOn(process, 'cwd').mockReturnValue(tmpRoot);
  const mod = await import('./ms-learn.js');
  executeTool = mod.executeTool;
  TOOL_DEFS = mod.TOOL_DEFS;
});

afterAll(() => {
  rmSync(tmpRoot, { recursive: true, force: true });
});

afterEach(() => {
  vi.unstubAllGlobals();
});

const ctx = {} as ToolContext;

function htmlResponse(body: string, status = 200): Response {
  return new Response(body, { status, headers: { 'content-type': 'text/html' } });
}

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

const LEARN_HTML = `<!doctype html>
<html><head>
<title>Durable Functions overview | Microsoft Learn</title>
</head><body>
<nav>site nav chrome</nav>
<main>
<article>
<h1>Durable Functions overview</h1>
<p>Durable Functions is an &amp; extension of Azure Functions.</p>
<h2>Patterns</h2>
<ul><li>Function chaining</li><li>Fan-out/fan-in</li></ul>
<pre><code class="lang-csharp">var x = 1;</code></pre>
<p>See <a href="https://learn.microsoft.com/en-us/azure/other">related docs</a>.</p>
</article>
</main>
<footer>footer chrome</footer>
</body></html>`;

describe('TOOL_DEFS', () => {
  it('registers the four Learn tools', () => {
    expect(Object.keys(TOOL_DEFS).sort()).toEqual([
      'FetchMicrosoftLearn',
      'RefreshMicrosoftLearnCache',
      'SearchMicrosoftLearn',
      'SearchMicrosoftLearnCache',
    ]);
  });
});

describe('executeTool', () => {
  it('returns null for unknown tool names', async () => {
    expect(await executeTool('NotALearnTool', {}, ctx)).toBeNull();
  });
});

describe('FetchMicrosoftLearn', () => {
  it('requires a url', async () => {
    const res = await executeTool('FetchMicrosoftLearn', {}, ctx);
    expect(res?.isError).toBe(true);
    expect(res?.output).toContain('url is required');
  });

  it('rejects URLs outside learn.microsoft.com', async () => {
    const fetchMock = vi.fn();
    vi.stubGlobal('fetch', fetchMock);
    const res = await executeTool('FetchMicrosoftLearn', { url: 'https://evil.example/docs' }, ctx);
    expect(res?.isError).toBe(true);
    expect(res?.output).toContain('not on learn.microsoft.com');
    expect(fetchMock).not.toHaveBeenCalled();
  });

  it('fetches a page, converts to markdown, and caches it', async () => {
    vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => htmlResponse(LEARN_HTML)));
    const res = await executeTool(
      'FetchMicrosoftLearn',
      { url: 'https://learn.microsoft.com/en-us/azure/durable?view=latest#anchor' },
      ctx,
    );
    expect(res?.isError).toBe(false);
    expect(res?.output).toContain('Fetched and cached');
    // Title cleaned of the "| Microsoft Learn" suffix.
    expect(res?.output).toContain('# Durable Functions overview');
    // Entity decoding, headings, lists, fenced code, links survive.
    expect(res?.output).toContain('an & extension');
    expect(res?.output).toContain('## Patterns');
    expect(res?.output).toContain('- Function chaining');
    expect(res?.output).toContain('```csharp\nvar x = 1;\n```');
    expect(res?.output).toContain('[related docs](https://learn.microsoft.com/en-us/azure/other)');
    // Chrome outside <article> is dropped.
    expect(res?.output).not.toContain('site nav chrome');
    expect(res?.output).not.toContain('footer chrome');
  });

  it('serves the cached copy (query/hash canonicalized away) without refetching', async () => {
    const fetchMock = vi.fn();
    vi.stubGlobal('fetch', fetchMock);
    const res = await executeTool(
      'FetchMicrosoftLearn',
      { url: 'https://learn.microsoft.com/en-us/azure/durable?other=param' },
      ctx,
    );
    expect(res?.isError).toBe(false);
    expect(res?.output).toContain('Cached (age=');
    expect(res?.output).toContain('Durable Functions overview');
    expect(fetchMock).not.toHaveBeenCalled();
  });

  it('reports HTTP errors as tool errors', async () => {
    vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => htmlResponse('nope', 404)));
    const res = await executeTool(
      'FetchMicrosoftLearn',
      { url: 'https://learn.microsoft.com/en-us/azure/missing' },
      ctx,
    );
    expect(res?.isError).toBe(true);
    expect(res?.output).toContain('HTTP 404');
  });
});

describe('SearchMicrosoftLearnCache', () => {
  it('requires a query', async () => {
    const res = await executeTool('SearchMicrosoftLearnCache', {}, ctx);
    expect(res?.isError).toBe(true);
  });

  it('finds previously cached pages via FTS', async () => {
    const res = await executeTool('SearchMicrosoftLearnCache', { query: 'durable functions' }, ctx);
    expect(res?.isError).toBe(false);
    expect(res?.output).toContain('Cache hits');
    expect(res?.output).toContain('https://learn.microsoft.com/en-us/azure/durable');
  });

  it('reports zero hits without error', async () => {
    const res = await executeTool('SearchMicrosoftLearnCache', { query: 'zzz-no-such-term' }, ctx);
    expect(res?.isError).toBe(false);
    expect(res?.output).toContain('No cache hits');
  });
});

describe('RefreshMicrosoftLearnCache', () => {
  it('force-refetches and overwrites the cache', async () => {
    const updated = LEARN_HTML.replace('an &amp; extension', 'a refreshed extension');
    vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => htmlResponse(updated)));
    const res = await executeTool(
      'RefreshMicrosoftLearnCache',
      { url: 'https://learn.microsoft.com/en-us/azure/durable' },
      ctx,
    );
    expect(res?.isError).toBe(false);
    expect(res?.output).toContain('Refreshed');

    const cached = await executeTool(
      'FetchMicrosoftLearn',
      { url: 'https://learn.microsoft.com/en-us/azure/durable' },
      ctx,
    );
    expect(cached?.output).toContain('a refreshed extension');
  });

  it('surfaces refresh failures', async () => {
    vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('socket hang up')));
    const res = await executeTool(
      'RefreshMicrosoftLearnCache',
      { url: 'https://learn.microsoft.com/en-us/azure/durable' },
      ctx,
    );
    expect(res?.isError).toBe(true);
    expect(res?.output).toContain('socket hang up');
  });
});

describe('SearchMicrosoftLearn', () => {
  it('requires a query', async () => {
    const res = await executeTool('SearchMicrosoftLearn', {}, ctx);
    expect(res?.isError).toBe(true);
  });

  it('merges online results with cache hits and clamps top to 25', async () => {
    const fetchMock = vi.fn().mockImplementation(async () =>
      jsonResponse({
        results: [
          { title: 'Durable Functions overview', url: 'https://learn.microsoft.com/en-us/azure/durable', description: 'desc' },
          { title: 'broken', description: 'no url, filtered out' },
        ],
      }),
    );
    vi.stubGlobal('fetch', fetchMock);
    const res = await executeTool(
      'SearchMicrosoftLearn',
      { query: 'durable functions', top: 100, products: ['azure'] },
      ctx,
    );
    expect(res?.isError).toBe(false);
    expect(res?.output).toContain('## Online results (1)');
    // The cached page is flagged.
    expect(res?.output).toContain('[cached]');
    expect(res?.output).toContain('## Cache hits');
    const calledUrl = String(fetchMock.mock.calls[0]?.[0]);
    expect(calledUrl).toContain('%24top=25');
    expect(calledUrl).toContain('products=azure');
  });

  it('falls back to cache-only results when the online API fails', async () => {
    vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => jsonResponse({}, 503)));
    const res = await executeTool('SearchMicrosoftLearn', { query: 'durable functions' }, ctx);
    expect(res?.isError).toBe(false);
    expect(res?.output).toContain('## Cache hits');
    expect(res?.output).toContain('online search failed');
  });

  it('errors when online fails and the cache has nothing', async () => {
    vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => jsonResponse({}, 503)));
    const res = await executeTool('SearchMicrosoftLearn', { query: 'zzz-no-such-term' }, ctx);
    expect(res?.isError).toBe(true);
    expect(res?.output).toContain('Search failed');
  });

  it('reports no results without error when both sources are empty', async () => {
    vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => jsonResponse({ results: [] })));
    const res = await executeTool('SearchMicrosoftLearn', { query: 'zzz-no-such-term' }, ctx);
    expect(res?.isError).toBe(false);
    expect(res?.output).toContain('No results');
  });
});
