import * as fs from 'fs';
import * as path from 'path';
import { tmpdir } from 'os';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { executeTool } from './maps.js';
import type { ToolContext } from './core.js';

function makeWorkspace(): string {
  return fs.mkdtempSync(path.join(tmpdir(), 'maestro-maps-'));
}

function makeContext(workspacePath: string, opts: { apiKey?: string } = {}): ToolContext {
  return {
    workspacePath,
    editAllowed: false,
    toolsConfig: {
      ...(opts.apiKey ? { googleMapsApiKey: opts.apiKey } : {}),
      mapsTimeout: 5,
    },
  };
}

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

const NOMINATIM_PLACE = {
  place_id: 1,
  lat: '35.658584',
  lon: '139.745433',
  display_name: '東京タワー, 4, 芝公園, 港区, 東京都, 105-0011, 日本',
  address: { country: '日本' },
  type: 'attraction',
  class: 'tourism',
  importance: 0.7,
};

const OSRM_ROUTE = {
  code: 'Ok',
  routes: [
    {
      distance: 3456.7,
      duration: 754,
      legs: [
        {
          steps: [
            { maneuver: { type: 'depart' }, distance: 100.4, duration: 30, name: '桜田通り' },
            { maneuver: { type: 'turn', modifier: 'left' }, distance: 200, duration: 60, name: '' },
          ],
        },
      ],
    },
  ],
};

describe('maps tools', () => {
  let workspacePath = '';

  afterEach(() => {
    if (workspacePath) {
      fs.rmSync(workspacePath, { recursive: true, force: true });
      workspacePath = '';
    }
    vi.unstubAllGlobals();
    vi.restoreAllMocks();
  });

  it('returns null for unknown tool name', async () => {
    workspacePath = makeWorkspace();
    const result = await executeTool('NotAMapsTool', {}, makeContext(workspacePath));
    expect(result).toBeNull();
  });

  // ---------------------------------------------------------------------
  // SearchPlaces
  // ---------------------------------------------------------------------

  describe('SearchPlaces', () => {
    it('rejects missing query', async () => {
      workspacePath = makeWorkspace();
      const result = await executeTool('SearchPlaces', {}, makeContext(workspacePath));
      expect(result?.isError).toBe(true);
      expect(result?.output).toContain('query は必須です');
    });

    it('rejects whitespace-only query', async () => {
      workspacePath = makeWorkspace();
      const result = await executeTool('SearchPlaces', { query: '   ' }, makeContext(workspacePath));
      expect(result?.isError).toBe(true);
    });

    it('searches via Nominatim when no API key is configured', async () => {
      workspacePath = makeWorkspace();
      const fetchMock = vi.fn().mockResolvedValue(jsonResponse([NOMINATIM_PLACE]));
      vi.stubGlobal('fetch', fetchMock);

      const result = await executeTool('SearchPlaces', { query: '東京タワー' }, makeContext(workspacePath));

      expect(result?.isError).toBe(false);
      expect(result?.output).toContain('## 地図検索結果: 東京タワー');
      expect(result?.output).toContain('1件の場所が見つかりました');
      expect(result?.output).toContain('### 1. 東京タワー');
      expect(result?.output).toContain('**座標**: 35.658584, 139.745433');
      expect(result?.output).toContain('**種別**: attraction');
      expect(result?.output).toContain('https://www.openstreetmap.org/?mlat=35.658584&mlon=139.745433&zoom=17');
      // embed marker + structured block
      expect(result?.output).toMatch(/\[\[embed:map-\d+\]\]/);
      expect(result?.structuredBlocks).toHaveLength(1);
      const block = result?.structuredBlocks?.[0];
      expect(block?.type).toBe('map_places');
      expect(block?.data).toMatchObject({
        query: '東京タワー',
        places: [
          expect.objectContaining({
            name: '東京タワー',
            lat: 35.658584,
            lon: 139.745433,
            type: 'attraction',
          }),
        ],
      });

      expect(fetchMock).toHaveBeenCalledTimes(1);
      const url = String(fetchMock.mock.calls[0]?.[0]);
      expect(url).toContain('nominatim.openstreetmap.org/search');
      expect(url).toContain('limit=5'); // default limit
      expect(url).toContain('accept-language=ja'); // default lang
    });

    it('clamps limit to 1..20 and passes lang through', async () => {
      workspacePath = makeWorkspace();
      const fetchMock = vi.fn().mockResolvedValue(jsonResponse([NOMINATIM_PLACE]));
      vi.stubGlobal('fetch', fetchMock);

      await executeTool('SearchPlaces', { query: 'tower', limit: 50, lang: 'en' }, makeContext(workspacePath));
      expect(String(fetchMock.mock.calls[0]?.[0])).toContain('limit=20');
      expect(String(fetchMock.mock.calls[0]?.[0])).toContain('accept-language=en');

      await executeTool('SearchPlaces', { query: 'tower', limit: 0 }, makeContext(workspacePath));
      expect(String(fetchMock.mock.calls[1]?.[0])).toContain('limit=1');
    });

    it('returns error on Nominatim HTTP failure', async () => {
      workspacePath = makeWorkspace();
      vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({}, 500)));

      const result = await executeTool('SearchPlaces', { query: 'tower' }, makeContext(workspacePath));
      expect(result?.isError).toBe(true);
      expect(result?.output).toContain('Nominatim API エラー: HTTP 500');
    });

    it('returns non-error message when no places match', async () => {
      workspacePath = makeWorkspace();
      vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse([])));

      const result = await executeTool('SearchPlaces', { query: 'nowhere-xyz' }, makeContext(workspacePath));
      expect(result?.isError).toBe(false);
      expect(result?.output).toContain('「nowhere-xyz」に一致する場所が見つかりませんでした');
    });

    it('returns error when fetch throws', async () => {
      workspacePath = makeWorkspace();
      vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')));

      const result = await executeTool('SearchPlaces', { query: 'tower' }, makeContext(workspacePath));
      expect(result?.isError).toBe(true);
      expect(result?.output).toContain('地図検索エラー: network down');
    });

    it('uses Google Places API when API key is configured', async () => {
      workspacePath = makeWorkspace();
      const fetchMock = vi.fn().mockResolvedValue(jsonResponse({
        status: 'OK',
        results: [
          {
            name: 'Tokyo Tower',
            formatted_address: '4 Chome-2-8 Shibakoen, Minato City',
            geometry: { location: { lat: 35.6586, lng: 139.7454 } },
            types: ['tourist_attraction'],
            rating: 4.5,
            opening_hours: { open_now: true },
          },
        ],
      }));
      vi.stubGlobal('fetch', fetchMock);

      const result = await executeTool(
        'SearchPlaces',
        { query: 'Tokyo Tower' },
        makeContext(workspacePath, { apiKey: 'test-google-key' }),
      );

      expect(result?.isError).toBe(false);
      expect(result?.output).toContain('### 1. Tokyo Tower');
      expect(result?.output).toContain('**詳細**: 評価: 4.5, 営業中');
      expect(result?.output).toContain('**種別**: tourist_attraction');
      // only the Google endpoint was hit; no Nominatim fallback
      expect(fetchMock).toHaveBeenCalledTimes(1);
      const url = String(fetchMock.mock.calls[0]?.[0]);
      expect(url).toContain('maps.googleapis.com/maps/api/place/textsearch/json');
      expect(url).toContain('key=test-google-key');
    });

    it('falls back to Nominatim when Google returns no results', async () => {
      workspacePath = makeWorkspace();
      const fetchMock = vi.fn()
        .mockResolvedValueOnce(jsonResponse({ status: 'ZERO_RESULTS', results: [] }))
        .mockResolvedValueOnce(jsonResponse([NOMINATIM_PLACE]));
      vi.stubGlobal('fetch', fetchMock);

      const result = await executeTool(
        'SearchPlaces',
        { query: '東京タワー' },
        makeContext(workspacePath, { apiKey: 'test-google-key' }),
      );

      expect(result?.isError).toBe(false);
      expect(result?.output).toContain('### 1. 東京タワー');
      expect(fetchMock).toHaveBeenCalledTimes(2);
      expect(String(fetchMock.mock.calls[1]?.[0])).toContain('nominatim.openstreetmap.org');
    });
  });

  // ---------------------------------------------------------------------
  // GetDirections
  // ---------------------------------------------------------------------

  describe('GetDirections', () => {
    it('rejects missing origin / destination', async () => {
      workspacePath = makeWorkspace();
      const ctx = makeContext(workspacePath);
      const noOrigin = await executeTool('GetDirections', { destination: 'B' }, ctx);
      expect(noOrigin?.isError).toBe(true);
      expect(noOrigin?.output).toContain('origin は必須です');

      const noDest = await executeTool('GetDirections', { origin: 'A' }, ctx);
      expect(noDest?.isError).toBe(true);
      expect(noDest?.output).toContain('destination は必須です');
    });

    it('routes via OSRM with "lat,lon" inputs (no geocoding round-trip)', async () => {
      workspacePath = makeWorkspace();
      const fetchMock = vi.fn().mockResolvedValue(jsonResponse(OSRM_ROUTE));
      vi.stubGlobal('fetch', fetchMock);

      const result = await executeTool(
        'GetDirections',
        { origin: '35.6586,139.7454', destination: '35.681236, 139.767125' },
        makeContext(workspacePath),
      );

      expect(result?.isError).toBe(false);
      expect(result?.output).toContain('## 経路情報');
      expect(result?.output).toContain('**移動手段**: 車');
      expect(result?.output).toContain('**距離**: 3.5 km');
      expect(result?.output).toContain('**所要時間**: 13 分');
      expect(result?.output).toContain('### 経路ステップ');
      expect(result?.output).toContain('1. depart (桜田通り) — 100m');
      expect(result?.output).toContain('2. turn left — 200m');

      // single OSRM call, lat/lon parsed directly (lon,lat order in URL)
      expect(fetchMock).toHaveBeenCalledTimes(1);
      const url = String(fetchMock.mock.calls[0]?.[0]);
      expect(url).toContain('router.project-osrm.org/route/v1/car/');
      expect(url).toContain('139.7454,35.6586;139.767125,35.681236');
    });

    it('maps walking/cycling modes to OSRM profiles', async () => {
      workspacePath = makeWorkspace();
      const fetchMock = vi.fn().mockImplementation(async () => jsonResponse(OSRM_ROUTE));
      vi.stubGlobal('fetch', fetchMock);
      const ctx = makeContext(workspacePath);

      const walking = await executeTool(
        'GetDirections',
        { origin: '1,2', destination: '3,4', mode: 'walking' },
        ctx,
      );
      expect(walking?.output).toContain('**移動手段**: 徒歩');
      expect(String(fetchMock.mock.calls[0]?.[0])).toContain('/route/v1/foot/');

      const cycling = await executeTool(
        'GetDirections',
        { origin: '1,2', destination: '3,4', mode: 'cycling' },
        ctx,
      );
      expect(cycling?.output).toContain('**移動手段**: 自転車');
      expect(String(fetchMock.mock.calls[1]?.[0])).toContain('/route/v1/bike/');
    });

    it('geocodes address inputs through Nominatim before calling OSRM', async () => {
      workspacePath = makeWorkspace();
      const fetchMock = vi.fn().mockImplementation(async (rawUrl: string | URL) => {
        const url = String(rawUrl);
        if (url.includes('nominatim.openstreetmap.org/search')) {
          if (url.includes(encodeURIComponent('東京駅'))) {
            return jsonResponse([{ ...NOMINATIM_PLACE, lat: '35.681236', lon: '139.767125' }]);
          }
          return jsonResponse([NOMINATIM_PLACE]);
        }
        return jsonResponse(OSRM_ROUTE);
      });
      vi.stubGlobal('fetch', fetchMock);

      const result = await executeTool(
        'GetDirections',
        { origin: '東京タワー', destination: '東京駅' },
        makeContext(workspacePath),
      );

      expect(result?.isError).toBe(false);
      expect(result?.output).toContain('**出発地**: 東京タワー');
      expect(result?.output).toContain('**目的地**: 東京駅');
      // 2 geocode calls + 1 OSRM call
      expect(fetchMock).toHaveBeenCalledTimes(3);
      const osrmCall = fetchMock.mock.calls.map((c) => String(c[0])).find((u) => u.includes('router.project-osrm.org'));
      expect(osrmCall).toContain('139.745433,35.658584;139.767125,35.681236');
    });

    it('returns error when origin cannot be geocoded', async () => {
      workspacePath = makeWorkspace();
      vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse([])));

      const result = await executeTool(
        'GetDirections',
        { origin: '存在しない場所xyz', destination: '1,2' },
        makeContext(workspacePath),
      );
      expect(result?.isError).toBe(true);
      expect(result?.output).toContain('出発地「存在しない場所xyz」の座標を取得できませんでした');
    });

    it('returns error on OSRM HTTP failure', async () => {
      workspacePath = makeWorkspace();
      vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({}, 502)));

      const result = await executeTool(
        'GetDirections',
        { origin: '1,2', destination: '3,4' },
        makeContext(workspacePath),
      );
      expect(result?.isError).toBe(true);
      expect(result?.output).toContain('OSRM 経路取得エラー: HTTP 502');
    });

    it('returns non-error message when OSRM finds no route', async () => {
      workspacePath = makeWorkspace();
      vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ code: 'NoRoute', routes: [] })));

      const result = await executeTool(
        'GetDirections',
        { origin: '1,2', destination: '3,4' },
        makeContext(workspacePath),
      );
      expect(result?.isError).toBe(false);
      expect(result?.output).toContain('経路が見つかりませんでした');
    });

    it('writes a Leaflet HTML file when output_html=true', async () => {
      workspacePath = makeWorkspace();
      vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(OSRM_ROUTE)));

      const result = await executeTool(
        'GetDirections',
        { origin: '1,2', destination: '3,4', output_html: true, filename: 'my_route' },
        makeContext(workspacePath),
      );

      expect(result?.isError).toBe(false);
      // .html is appended when missing
      expect(result?.output).toContain('output/maps/my_route.html');
      const filePath = path.join(workspacePath, 'output', 'maps', 'my_route.html');
      expect(fs.existsSync(filePath)).toBe(true);
      const html = fs.readFileSync(filePath, 'utf-8');
      expect(html).toContain('leaflet');
      expect(html).toContain('3.5 km');
      expect(html).toContain('13 分');
    });

    it('escapes HTML in generated route file names', async () => {
      workspacePath = makeWorkspace();
      const fetchMock = vi.fn().mockImplementation(async (rawUrl: string | URL) => {
        const url = String(rawUrl);
        if (url.includes('nominatim')) return jsonResponse([NOMINATIM_PLACE]);
        return jsonResponse(OSRM_ROUTE);
      });
      vi.stubGlobal('fetch', fetchMock);

      const result = await executeTool(
        'GetDirections',
        { origin: '<script>x</script>', destination: '1,2', output_html: true, filename: 'esc.html' },
        makeContext(workspacePath),
      );

      expect(result?.isError).toBe(false);
      const html = fs.readFileSync(path.join(workspacePath, 'output', 'maps', 'esc.html'), 'utf-8');
      expect(html).not.toContain('<script>x</script>');
      expect(html).toContain('&lt;script&gt;x&lt;/script&gt;');
    });

    it('uses Google Directions API when API key is configured', async () => {
      workspacePath = makeWorkspace();
      const fetchMock = vi.fn().mockResolvedValue(jsonResponse({
        status: 'OK',
        routes: [
          {
            legs: [
              {
                distance: { text: '3.4 km' },
                duration: { text: '12分' },
                steps: [
                  { html_instructions: '<b>南</b>に進む', distance: { text: '0.2 km' } },
                ],
                start_address: '日本、東京都港区芝公園',
                end_address: '日本、東京都千代田区丸の内',
                start_location: { lat: 35.6586, lng: 139.7454 },
                end_location: { lat: 35.6812, lng: 139.7671 },
              },
            ],
          },
        ],
      }));
      vi.stubGlobal('fetch', fetchMock);

      const result = await executeTool(
        'GetDirections',
        { origin: '東京タワー', destination: '東京駅' },
        makeContext(workspacePath, { apiKey: 'test-google-key' }),
      );

      expect(result?.isError).toBe(false);
      expect(result?.output).toContain('**距離**: 3.4 km');
      expect(result?.output).toContain('**所要時間**: 12分');
      expect(result?.output).toContain('**出発地**: 日本、東京都港区芝公園');
      // HTML tags stripped from step instructions
      expect(result?.output).toContain('南に進む (0.2 km)');
      expect(result?.output).not.toContain('<b>');
      expect(fetchMock).toHaveBeenCalledTimes(1);
      expect(String(fetchMock.mock.calls[0]?.[0])).toContain('maps.googleapis.com/maps/api/directions/json');
    });
  });

  // ---------------------------------------------------------------------
  // ReverseGeocode
  // ---------------------------------------------------------------------

  describe('ReverseGeocode', () => {
    it('rejects non-numeric coordinates', async () => {
      workspacePath = makeWorkspace();
      const result = await executeTool('ReverseGeocode', { lat: 'abc', lon: 139 }, makeContext(workspacePath));
      expect(result?.isError).toBe(true);
      expect(result?.output).toContain('lat と lon は数値で指定してください');
    });

    it('rejects out-of-range coordinates', async () => {
      workspacePath = makeWorkspace();
      const ctx = makeContext(workspacePath);
      const badLat = await executeTool('ReverseGeocode', { lat: 91, lon: 0 }, ctx);
      expect(badLat?.isError).toBe(true);
      expect(badLat?.output).toContain('座標の範囲が無効です');

      const badLon = await executeTool('ReverseGeocode', { lat: 0, lon: -181 }, ctx);
      expect(badLon?.isError).toBe(true);
    });

    it('returns formatted address components on success', async () => {
      workspacePath = makeWorkspace();
      const fetchMock = vi.fn().mockResolvedValue(jsonResponse({
        place_id: 1,
        lat: '35.658584',
        lon: '139.745433',
        display_name: '東京タワー, 4, 芝公園, 港区, 東京都, 105-0011, 日本',
        address: {
          amenity: '東京タワー',
          house_number: '4',
          road: '都道301号',
          suburb: '芝公園',
          city: '港区',
          state: '東京都',
          postcode: '105-0011',
          country: '日本',
          country_code: 'jp',
        },
        boundingbox: ['35.657', '35.660', '139.744', '139.747'],
      }));
      vi.stubGlobal('fetch', fetchMock);

      const result = await executeTool(
        'ReverseGeocode',
        { lat: 35.658584, lon: 139.745433 },
        makeContext(workspacePath),
      );

      expect(result?.isError).toBe(false);
      expect(result?.output).toContain('## 逆ジオコーディング結果');
      expect(result?.output).toContain('**座標**: 35.658584, 139.745433');
      expect(result?.output).toContain('**住所（全体）**: 東京タワー, 4, 芝公園, 港区, 東京都, 105-0011, 日本');
      expect(result?.output).toContain('- **郵便番号**: 105-0011');
      expect(result?.output).toContain('- **国**: 日本');
      expect(result?.output).toContain('- **都道府県**: 東京都');
      expect(result?.output).toContain('- **市区町村**: 港区');
      expect(result?.output).toContain('- **地区**: 芝公園');
      expect(result?.output).toContain('- **道路/通り**: 都道301号');
      expect(result?.output).toContain('- **番地**: 4');
      expect(result?.output).toContain('- **施設**: 東京タワー');
      expect(result?.output).toContain('https://www.openstreetmap.org/?mlat=35.658584&mlon=139.745433&zoom=17');

      const url = String(fetchMock.mock.calls[0]?.[0]);
      expect(url).toContain('nominatim.openstreetmap.org/reverse');
      expect(url).toContain('lat=35.658584');
      expect(url).toContain('lon=139.745433');
      expect(url).toContain('accept-language=ja');
    });

    it('accepts numeric strings for lat/lon', async () => {
      workspacePath = makeWorkspace();
      const fetchMock = vi.fn().mockResolvedValue(jsonResponse({
        place_id: 1,
        lat: '35.0',
        lon: '139.0',
        display_name: 'どこか',
        address: {},
        boundingbox: [],
      }));
      vi.stubGlobal('fetch', fetchMock);

      const result = await executeTool(
        'ReverseGeocode',
        { lat: '35.0', lon: '139.0' },
        makeContext(workspacePath),
      );
      expect(result?.isError).toBe(false);
      expect(result?.output).toContain('**住所（全体）**: どこか');
    });

    it('returns error on Nominatim HTTP failure', async () => {
      workspacePath = makeWorkspace();
      vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({}, 404)));

      const result = await executeTool('ReverseGeocode', { lat: 35, lon: 139 }, makeContext(workspacePath));
      expect(result?.isError).toBe(true);
      expect(result?.output).toContain('Nominatim API エラー: HTTP 404');
    });

    it('returns non-error message when address is not found', async () => {
      workspacePath = makeWorkspace();
      vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ error: 'Unable to geocode' })));

      const result = await executeTool('ReverseGeocode', { lat: 0, lon: 0 }, makeContext(workspacePath));
      expect(result?.isError).toBe(false);
      expect(result?.output).toContain('座標 (0, 0) の住所が見つかりませんでした');
    });

    it('returns error when fetch throws', async () => {
      workspacePath = makeWorkspace();
      vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('boom')));

      const result = await executeTool('ReverseGeocode', { lat: 35, lon: 139 }, makeContext(workspacePath));
      expect(result?.isError).toBe(true);
      expect(result?.output).toContain('逆ジオコーディングエラー: boom');
    });
  });
});
