/* eslint-disable max-classes-per-file -- Google Maps API のモックに複数のクラスが必要なため */
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
import GoogleMapsPicker from './google-maps-picker';
import loadGoogleMap from '../loader';

vi.hoisted(() => {
  // google-maps-picker.ts はモジュール評価時に ACMS.Config.googleApiKey を参照するため、
  // import より前（hoisted）に Config を用意しておく
  const win = window as unknown as { ACMS?: { Config?: Record<string, unknown> } };
  win.ACMS = win.ACMS ?? {};
  win.ACMS.Config = { ...(win.ACMS.Config ?? {}), googleApiKey: 'test-api-key' };
});

vi.mock('../loader', () => ({
  default: vi.fn(),
}));

type Listener = (...args: unknown[]) => void;

/**
 * Google Maps JavaScript API の最小モック。
 * MVCObject の仕様に合わせ、setZoom() は同値でも zoom_changed を発火する。
 */
const createGoogleMock = () => {
  const listeners = new Map<object, Map<string, Listener[]>>();

  const instances: {
    maps: object[];
    markers: object[];
    panoramas: object[];
  } = {
    maps: [],
    markers: [],
    panoramas: [],
  };

  const event = {
    addListener: vi.fn((instance: object, type: string, listener: Listener) => {
      const byType = listeners.get(instance) ?? new Map<string, Listener[]>();
      byType.set(type, [...(byType.get(type) ?? []), listener]);
      listeners.set(instance, byType);
    }),
    clearInstanceListeners: vi.fn((instance: object) => {
      listeners.delete(instance);
    }),
    trigger: (instance: object, type: string, ...args: unknown[]) => {
      (listeners.get(instance)?.get(type) ?? []).forEach((listener) => listener(...args));
    },
  };

  class LatLngMock {
    private latValue: number;

    private lngValue: number;

    constructor(lat: number, lng: number) {
      this.latValue = lat;
      this.lngValue = lng;
    }

    lat() {
      return this.latValue;
    }

    lng() {
      return this.lngValue;
    }
  }

  class StreetViewPanoramaMock {
    div: HTMLElement;

    config: Record<string, unknown>;

    status = 'OK';

    constructor(div: HTMLElement, config: Record<string, unknown>) {
      this.div = div;
      this.config = config;
      instances.panoramas.push(this);
    }

    getStatus() {
      return this.status;
    }

    getPov() {
      return this.config.pov as { heading?: number; pitch?: number };
    }

    getZoom() {
      return this.config.zoom as number;
    }

    getPosition() {
      const position = this.config.position as { lat: number; lng: number };
      return new LatLngMock(position.lat, position.lng);
    }
  }

  class MapMock {
    div: HTMLElement;

    options: Record<string, unknown>;

    private zoom: number;

    private center: LatLngMock;

    private streetView: StreetViewPanoramaMock | null = null;

    private defaultStreetView: StreetViewPanoramaMock;

    constructor(div: HTMLElement, options: { zoom: number; center: LatLngMock } & Record<string, unknown>) {
      this.div = div;
      this.options = options;
      this.zoom = options.zoom;
      this.center = options.center;
      this.defaultStreetView = new StreetViewPanoramaMock(div, {});
      instances.maps.push(this);
    }

    setZoom(zoom: number) {
      this.zoom = zoom;
      // 実 API（MVCObject）は同値の set でも *_changed イベントを発火する
      event.trigger(this, 'zoom_changed');
    }

    getZoom() {
      return this.zoom;
    }

    panTo(latLng: LatLngMock) {
      this.center = latLng;
    }

    getCenter() {
      return this.center;
    }

    getDiv() {
      return this.div;
    }

    setStreetView(panorama: StreetViewPanoramaMock | null) {
      this.streetView = panorama;
    }

    getStreetView() {
      // 実 API は未設定時もマップ既定のパノラマを返す
      return this.streetView ?? this.defaultStreetView;
    }

    setOptions(options: Record<string, unknown>) {
      this.options = { ...this.options, ...options };
    }
  }

  class MarkerMock {
    options: Record<string, unknown>;

    position: LatLngMock | null;

    map: MapMock | null;

    constructor(options: { position?: LatLngMock; map?: MapMock } & Record<string, unknown>) {
      this.options = options;
      this.position = options.position ?? null;
      this.map = options.map ?? null;
      instances.markers.push(this);
    }

    setPosition(latLng: LatLngMock) {
      this.position = latLng;
    }

    setMap(map: MapMock | null) {
      this.map = map;
    }
  }

  const geocodeMock = vi.fn();

  class GeocoderMock {
    geocode(request: unknown, callback: (results: unknown, status: string) => void) {
      geocodeMock(request, callback);
    }
  }

  const google = {
    maps: {
      LatLng: LatLngMock,
      Map: MapMock,
      Marker: MarkerMock,
      StreetViewPanorama: StreetViewPanoramaMock,
      Geocoder: GeocoderMock,
      MapTypeId: { ROADMAP: 'roadmap' },
      StreetViewStatus: { OK: 'OK' },
      GeocoderStatus: { OK: 'OK' },
      event,
    },
  };

  const typedInstances = instances as {
    maps: InstanceType<typeof MapMock>[];
    markers: InstanceType<typeof MarkerMock>[];
    panoramas: InstanceType<typeof StreetViewPanoramaMock>[];
  };

  return {
    google,
    event,
    geocodeMock,
    instances: typedInstances,
    LatLngMock,
    MapMock,
    StreetViewPanoramaMock,
    MarkerMock,
  };
};

type GoogleMock = ReturnType<typeof createGoogleMock>;
type MapMockInstance = InstanceType<GoogleMock['MapMock']>;

const createPickerDOM = (html?: string) => {
  document.body.innerHTML =
    html ??
    `
    <div class="js-map-editable">
      <input type="text" name="geo_lat" value="35.170915" class="js-map_editable-lat" />
      <input type="text" name="geo_lng" value="136.881537" class="js-map_editable-lng" />
      <input type="text" name="geo_zoom" value="10" class="js-map_editable-zoom" />
      <input type="checkbox" name="map_view_activate_test" class="js-map_editable-activate" />
      <input type="text" name="map_view_pitch_test" class="js-map_editable-pitch" />
      <input type="text" name="map_view_zoom_test" class="js-map_editable-zoom-view" />
      <input type="text" name="map_view_heading_test" class="js-map_editable-heading" />
      <input type="text" class="js-editable_map-search_text" disabled />
      <button type="button" class="js-editable_map-search_button" disabled>検索</button>
      <div class="js-streetview-table"></div>
      <div class="js-map-table"></div>
      <div class="js-map_editable-container"></div>
    </div>
  `;
  return document.querySelector<HTMLElement>('.js-map-editable')!;
};

const getInput = (selector: string) => document.querySelector<HTMLInputElement>(selector)!;

const changeValue = (input: HTMLInputElement, value: string) => {
  input.value = value;
  input.dispatchEvent(new Event('change'));
};

const flushPromises = () =>
  new Promise<void>((resolve) => {
    setTimeout(resolve, 0);
  });

describe('GoogleMapsPicker', () => {
  let googleMock: GoogleMock;

  const getMapMock = (picker: GoogleMapsPicker) => picker.getMap() as unknown as MapMockInstance;

  const createPicker = async (
    target: string | HTMLElement,
    options: ConstructorParameters<typeof GoogleMapsPicker>[1] = {}
  ) => {
    const picker = new GoogleMapsPicker(target, options);
    await picker.init();
    return picker;
  };

  beforeEach(() => {
    vi.clearAllMocks();
    googleMock = createGoogleMock();
    vi.mocked(loadGoogleMap).mockResolvedValue(
      googleMock.google as unknown as Awaited<ReturnType<typeof loadGoogleMap>>
    );
  });

  afterEach(() => {
    document.body.innerHTML = '';
    vi.restoreAllMocks();
    vi.useRealTimers();
  });

  describe('ズーム入力の変更', () => {
    test('geo_zoom 入力を変更すると入力値が巻き戻らずマップに反映される', async () => {
      const container = createPickerDOM();
      const picker = await createPicker(container);

      const zoomInput = getInput('.js-map_editable-zoom');
      changeValue(zoomInput, '15');

      // 変更した値が巻き戻らない
      expect(zoomInput.value).toBe('15');
      // マップ側のズームにも反映される
      expect(getMapMock(picker).getZoom()).toBe(15);
    });

    test('geo_zoom 入力の変更で onChange に新しいズーム値が渡される', async () => {
      const container = createPickerDOM();
      const onChange = vi.fn();
      await createPicker(container, { onChange });

      changeValue(getInput('.js-map_editable-zoom'), '15');

      const lastValue = onChange.mock.calls.at(-1)?.[0];
      expect(lastValue.zoom).toBe(15);
    });

    test('数値として不正なズーム値はデフォルト値 14 に補正される', async () => {
      const container = createPickerDOM();
      const picker = await createPicker(container);

      changeValue(getInput('.js-map_editable-zoom'), 'abc');

      expect(getMapMock(picker).getZoom()).toBe(14);
      expect(getInput('.js-map_editable-zoom').value).toBe('14');
    });
  });

  describe('コンストラクタ', () => {
    test('セレクタ文字列からコンテナを解決できる', async () => {
      createPickerDOM();
      const picker = await createPicker('.js-map-editable');
      expect(picker.getMap()).not.toBeNull();
    });

    test('コンテナが見つからない場合は例外を投げる', () => {
      createPickerDOM();
      expect(() => new GoogleMapsPicker('.not-found', {})).toThrow('Container element not found');
    });

    test('緯度入力が無い場合は例外を投げる', () => {
      const container = createPickerDOM();
      getInput('.js-map_editable-lat').remove();
      expect(() => new GoogleMapsPicker(container, {})).toThrow('Latitude input element is required');
    });

    test('経度入力が無い場合は例外を投げる', () => {
      const container = createPickerDOM();
      getInput('.js-map_editable-lng').remove();
      expect(() => new GoogleMapsPicker(container, {})).toThrow('Longitude input element is required');
    });

    test('ズーム入力が無い場合は例外を投げる', () => {
      const container = createPickerDOM();
      getInput('.js-map_editable-zoom').remove();
      expect(() => new GoogleMapsPicker(container, {})).toThrow('Zoom input element is required');
    });

    test('マップルート要素が無い場合は例外を投げる', () => {
      const container = createPickerDOM();
      container.querySelector('.js-map_editable-container')!.remove();
      expect(() => new GoogleMapsPicker(container, {})).toThrow('Map root element is required');
    });

    test('要素を直接オプションに渡してもインスタンス化できる', async () => {
      const container = createPickerDOM();
      const picker = await createPicker(container, {
        latInput: getInput('.js-map_editable-lat'),
        lngInput: getInput('.js-map_editable-lng'),
        zoomInput: getInput('.js-map_editable-zoom'),
        mapRoot: container.querySelector<HTMLElement>('.js-map_editable-container')!,
        pitchInput: undefined,
      });
      expect(picker.getMap()).not.toBeNull();
    });

    test('input 以外の要素にマッチするセレクタを searchInput に渡した場合は無視される', async () => {
      const container = createPickerDOM();
      // searchInput に div へのセレクタを渡すと null となり、検索 UI は初期化されない
      await createPicker(container, { searchInput: '.js-map-table' });
      expect(getInput('.js-editable_map-search_text').disabled).toBe(true);
      expect(document.querySelector<HTMLButtonElement>('.js-editable_map-search_button')!.disabled).toBe(true);
    });

    test('button 以外の要素にマッチするセレクタを searchButton に渡した場合は無視される', async () => {
      const container = createPickerDOM();
      await createPicker(container, { searchButton: '.js-map-table' });
      expect(getInput('.js-editable_map-search_text').disabled).toBe(true);
    });

    test('コンテナ自身がマップルートのセレクタに一致する場合はコンテナを利用する', async () => {
      const container = createPickerDOM(`
        <div class="js-map-editable js-map_editable-container">
          <input type="text" value="35" class="js-map_editable-lat" />
          <input type="text" value="136" class="js-map_editable-lng" />
          <input type="text" value="10" class="js-map_editable-zoom" />
        </div>
      `);
      const picker = await createPicker(container);

      // コンテナ直下がマップ用の div に置き換えられている
      expect(picker.getMap()).not.toBeNull();
      expect((container.firstElementChild as HTMLElement).style.height).toBe('100%');
    });

    test('マップルートが img 要素の場合は同サイズの div に置き換える', async () => {
      const container = createPickerDOM(`
        <div class="js-map-editable">
          <input type="text" value="35" class="js-map_editable-lat" />
          <input type="text" value="136" class="js-map_editable-lng" />
          <input type="text" value="10" class="js-map_editable-zoom" />
          <img class="js-map_editable-container" width="600" height="400" />
        </div>
      `);
      const picker = await createPicker(container);

      expect(container.querySelector('img')).toBeNull();
      const replaced = container.querySelector<HTMLElement>('div.js-map_editable-container')!;
      expect(replaced.style.width).toBe('600px');
      expect(replaced.style.height).toBe('400px');
      expect(picker.getMap()).not.toBeNull();
    });

    test('入力が空の場合は defaultValue が利用される', async () => {
      const container = createPickerDOM(`
        <div class="js-map-editable">
          <input type="text" value="" class="js-map_editable-lat" />
          <input type="text" value="" class="js-map_editable-lng" />
          <input type="text" value="" class="js-map_editable-zoom" />
          <input type="text" value="" class="js-map_editable-pitch" />
          <input type="text" value="" class="js-map_editable-zoom-view" />
          <input type="text" value="" class="js-map_editable-heading" />
          <div class="js-map_editable-container"></div>
        </div>
      `);
      const picker = await createPicker(container, {
        defaultValue: {
          lat: 35.5,
          lng: 136.5,
          zoom: 8,
          streetView: { heading: 45, pitch: 5, zoomView: 1 },
        },
      });

      const map = getMapMock(picker);
      expect(map.getCenter().lat()).toBe(35.5);
      expect(map.getCenter().lng()).toBe(136.5);
      expect(map.getZoom()).toBe(8);
    });

    test('入力が空で defaultValue も無い場合は組み込みの初期値が利用される', async () => {
      const container = createPickerDOM(`
        <div class="js-map-editable">
          <input type="text" value="" class="js-map_editable-lat" />
          <input type="text" value="" class="js-map_editable-lng" />
          <input type="text" value="" class="js-map_editable-zoom" />
          <input type="text" value="" class="js-map_editable-pitch" />
          <input type="text" value="" class="js-map_editable-zoom-view" />
          <input type="text" value="" class="js-map_editable-heading" />
          <div class="js-map_editable-container"></div>
        </div>
      `);
      const picker = await createPicker(container);

      const map = getMapMock(picker);
      expect(map.getCenter().lat()).toBe(0);
      expect(map.getCenter().lng()).toBe(0);
      expect(map.getZoom()).toBe(14);
    });

    test('mapOptions でマップ生成オプションを上書きできる', async () => {
      const container = createPickerDOM();
      const picker = await createPicker(container, {
        mapOptions: { gestureHandling: 'greedy' },
      });
      expect(getMapMock(picker).options.gestureHandling).toBe('greedy');
    });
  });

  describe('init', () => {
    test('二重に init してもマップは一度しか初期化されない', async () => {
      const container = createPickerDOM();
      const picker = new GoogleMapsPicker(container, {});
      await picker.init();
      const map = picker.getMap();
      await picker.init();

      expect(vi.mocked(loadGoogleMap)).toHaveBeenCalledTimes(1);
      expect(picker.getMap()).toBe(map);
    });

    test('API のロードに失敗した場合はエラーを投げる', async () => {
      vi.mocked(loadGoogleMap).mockRejectedValue(new Error('load error'));
      const container = createPickerDOM();
      const picker = new GoogleMapsPicker(container, {});

      await expect(picker.init()).rejects.toThrow('Failed to initialize Google Maps');
    });
  });

  describe('緯度・経度入力の変更', () => {
    test('緯度の変更でマップとマーカーが移動し onChange が呼ばれる', async () => {
      const container = createPickerDOM();
      const onChange = vi.fn();
      const picker = await createPicker(container, { onChange });

      changeValue(getInput('.js-map_editable-lat'), '36.5');

      const map = getMapMock(picker);
      expect(map.getCenter().lat()).toBe(36.5);
      expect(googleMock.instances.markers[0].position?.lat()).toBe(36.5);
      expect(onChange.mock.calls.at(-1)?.[0].lat).toBe(36.5);
    });

    test('経度の変更でもマップが移動する', async () => {
      const container = createPickerDOM();
      const picker = await createPicker(container);

      changeValue(getInput('.js-map_editable-lng'), '137.5');

      expect(getMapMock(picker).getCenter().lng()).toBe(137.5);
    });
  });

  describe('マップ操作', () => {
    test('マップ上のズーム操作で入力欄が更新され onChange が呼ばれる', async () => {
      const container = createPickerDOM();
      const onChange = vi.fn();
      const picker = await createPicker(container, { onChange });

      // ユーザーがマップ上でズーム操作したことをシミュレート
      getMapMock(picker).setZoom(5);

      expect(getInput('.js-map_editable-zoom').value).toBe('5');
      expect(onChange.mock.calls.at(-1)?.[0].zoom).toBe(5);
    });

    test('マーカーのドラッグで緯度経度入力が小数 6 桁に丸めて更新される', async () => {
      const container = createPickerDOM();
      const onChange = vi.fn();
      const picker = await createPicker(container, { onChange });

      const marker = googleMock.instances.markers[0];
      googleMock.event.trigger(marker, 'dragend', {
        latLng: new googleMock.LatLngMock(35.1234567, 136.7654324),
      });

      expect(getInput('.js-map_editable-lat').value).toBe('35.123457');
      expect(getInput('.js-map_editable-lng').value).toBe('136.765432');
      expect(getMapMock(picker).getCenter().lat()).toBe(35.1234567);
      expect(onChange).toHaveBeenCalled();
    });
  });

  describe('ストリートビュー', () => {
    const activate = () => {
      const modeSwitcher = getInput('.js-map_editable-activate');
      modeSwitcher.checked = true;
      modeSwitcher.dispatchEvent(new Event('change'));
    };

    const deactivate = () => {
      const modeSwitcher = getInput('.js-map_editable-activate');
      modeSwitcher.checked = false;
      modeSwitcher.dispatchEvent(new Event('change'));
    };

    test('有効化すると現在の視点設定からパノラマが生成され UI が切り替わる', async () => {
      vi.useFakeTimers();
      const container = createPickerDOM();
      const onChange = vi.fn();
      const picker = await createPicker(container, {
        onChange,
        defaultValue: { streetView: { heading: 45, pitch: 5, zoomView: 1 } },
      });

      activate();

      const map = getMapMock(picker);
      const panorama = googleMock.instances.panoramas.at(-1)!;
      expect(map.getStreetView()).toBe(panorama);
      expect(panorama.config.pov).toEqual({ heading: 45, pitch: 5 });
      expect(panorama.config.zoom).toBe(1);
      expect(map.options.streetViewControl).toBe(true);
      // パノラマの視点が各入力欄に書き戻される
      expect(getInput('.js-map_editable-heading').value).toBe('45');
      expect(getInput('.js-map_editable-pitch').value).toBe('5');
      expect(getInput('.js-map_editable-zoom-view').value).toBe('1');
      // UI の切り替え
      expect(container.querySelector<HTMLElement>('.js-streetview-table')!.style.display).toBe('block');
      expect(container.querySelector<HTMLElement>('.js-map-table')!.style.display).toBe('none');
      expect(onChange.mock.calls.at(-1)?.[0].type).toBe('STREETVIEW');

      // ストリートビューが利用可能（OK）の場合はアラートを表示しない
      const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
      vi.advanceTimersByTime(1000);
      expect(alertSpy).not.toHaveBeenCalled();
    });

    test('指定位置にストリートビューが無い場合はアラートを表示する', async () => {
      vi.useFakeTimers();
      const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
      const container = createPickerDOM();
      await createPicker(container);

      activate();
      googleMock.instances.panoramas.at(-1)!.status = 'ZERO_RESULTS';
      vi.advanceTimersByTime(1000);

      expect(alertSpy).toHaveBeenCalledWith(
        '指定の位置にストリートビューがありません。ストリートビューの範囲にペグマンをドロップしてください。'
      );
    });

    test('パノラマの視点変更で各入力欄が更新される', async () => {
      vi.useFakeTimers();
      const container = createPickerDOM();
      const onChange = vi.fn();
      await createPicker(container, { onChange });

      activate();
      const panorama = googleMock.instances.panoramas.at(-1)!;
      panorama.config = { ...panorama.config, pov: { heading: 45, pitch: -5 }, zoom: 3 };
      googleMock.event.trigger(panorama, 'pov_changed');

      expect(getInput('.js-map_editable-heading').value).toBe('45');
      expect(getInput('.js-map_editable-pitch').value).toBe('-5');
      expect(getInput('.js-map_editable-zoom-view').value).toBe('3');
      expect(onChange.mock.calls.at(-1)?.[0].streetView).toEqual({ heading: 45, pitch: -5, zoomView: 3 });
    });

    test('視点情報が欠けている場合は該当の入力欄を更新しない', async () => {
      vi.useFakeTimers();
      const container = createPickerDOM();
      await createPicker(container);

      activate();
      // 直接センチネル値を入れて、上書きされないことを確認する
      getInput('.js-map_editable-heading').value = 'sentinel-heading';
      getInput('.js-map_editable-pitch').value = 'sentinel-pitch';
      const panorama = googleMock.instances.panoramas.at(-1)!;
      panorama.config = { ...panorama.config, pov: {} };
      googleMock.event.trigger(panorama, 'pov_changed');

      expect(getInput('.js-map_editable-heading').value).toBe('sentinel-heading');
      expect(getInput('.js-map_editable-pitch').value).toBe('sentinel-pitch');
    });

    test('パノラマの位置変更で緯度経度入力が更新される', async () => {
      vi.useFakeTimers();
      const container = createPickerDOM();
      await createPicker(container);

      activate();
      const panorama = googleMock.instances.panoramas.at(-1)!;
      panorama.config = { ...panorama.config, position: { lat: 34.5, lng: 135.5 } };
      googleMock.event.trigger(panorama, 'position_changed');

      expect(getInput('.js-map_editable-lat').value).toBe('34.5');
      expect(getInput('.js-map_editable-lng').value).toBe('135.5');
    });

    test('無効化するとストリートビュー関連の入力がリセットされ UI が戻る', async () => {
      vi.useFakeTimers();
      const container = createPickerDOM();
      const onChange = vi.fn();
      const picker = await createPicker(container, {
        onChange,
        defaultValue: { streetView: { heading: 45, pitch: 5, zoomView: 1 } },
      });

      activate();
      deactivate();

      const map = getMapMock(picker);
      expect(map.options.streetViewControl).toBe(false);
      expect(getInput('.js-map_editable-heading').value).toBe('');
      expect(getInput('.js-map_editable-pitch').value).toBe('');
      expect(getInput('.js-map_editable-zoom-view').value).toBe('');
      expect(container.querySelector<HTMLElement>('.js-streetview-table')!.style.display).toBe('none');
      expect(container.querySelector<HTMLElement>('.js-map-table')!.style.display).toBe('block');
      expect(onChange.mock.calls.at(-1)?.[0].type).toBe('ROADMAP');
    });

    test('視点入力やテーブル要素が無い最小構成でも切り替えできる', async () => {
      vi.useFakeTimers();
      const container = createPickerDOM(`
        <div class="js-map-editable">
          <input type="text" value="35" class="js-map_editable-lat" />
          <input type="text" value="136" class="js-map_editable-lng" />
          <input type="text" value="10" class="js-map_editable-zoom" />
          <input type="checkbox" class="js-map_editable-activate" />
          <div class="js-map_editable-container"></div>
        </div>
      `);
      await createPicker(container);

      expect(() => activate()).not.toThrow();
      expect(() => deactivate()).not.toThrow();
    });
  });

  describe('検索', () => {
    test('検索ボタンで Geocoder の結果が位置に反映される', async () => {
      const container = createPickerDOM();
      const onChange = vi.fn();
      await createPicker(container, { onChange });

      googleMock.geocodeMock.mockImplementation(
        (request: unknown, callback: (results: unknown, status: string) => void) => {
          callback([{ geometry: { location: new googleMock.LatLngMock(35.17, 136.88) } }], 'OK');
        }
      );

      const searchInput = getInput('.js-editable_map-search_text');
      searchInput.value = '名古屋駅';
      document.querySelector<HTMLButtonElement>('.js-editable_map-search_button')!.click();
      await flushPromises();

      expect(googleMock.geocodeMock).toHaveBeenCalledWith({ address: '名古屋駅' }, expect.any(Function));
      expect(getInput('.js-map_editable-lat').value).toBe('35.17');
      expect(getInput('.js-map_editable-lng').value).toBe('136.88');
      expect(onChange).toHaveBeenCalled();
      expect(searchInput.disabled).toBe(false);
    });

    test('検索結果が空の場合は位置を変更しない', async () => {
      const container = createPickerDOM();
      await createPicker(container);

      googleMock.geocodeMock.mockImplementation(
        (request: unknown, callback: (results: unknown, status: string) => void) => {
          callback([], 'OK');
        }
      );

      getInput('.js-editable_map-search_text').value = '存在しない住所';
      document.querySelector<HTMLButtonElement>('.js-editable_map-search_button')!.click();
      await flushPromises();

      expect(getInput('.js-map_editable-lat').value).toBe('35.170915');
    });

    test('検索に失敗した場合はエラーログを出力し入力を再度有効化する', async () => {
      const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
      const container = createPickerDOM();
      await createPicker(container);

      googleMock.geocodeMock.mockImplementation(
        (request: unknown, callback: (results: unknown, status: string) => void) => {
          callback(null, 'ZERO_RESULTS');
        }
      );

      const searchInput = getInput('.js-editable_map-search_text');
      const searchButton = document.querySelector<HTMLButtonElement>('.js-editable_map-search_button')!;
      searchInput.value = '謎の場所';
      searchButton.click();
      await flushPromises();

      expect(consoleErrorSpy).toHaveBeenCalledWith('Search failed:', expect.any(Error));
      expect(searchInput.disabled).toBe(false);
      expect(searchButton.disabled).toBe(false);
    });

    test('検索キーワードが空の場合は Geocoder を呼ばない', async () => {
      const container = createPickerDOM();
      await createPicker(container);

      document.querySelector<HTMLButtonElement>('.js-editable_map-search_button')!.click();
      await flushPromises();

      expect(googleMock.geocodeMock).not.toHaveBeenCalled();
    });

    test('Enter キーで検索が実行される', async () => {
      const container = createPickerDOM();
      await createPicker(container);

      googleMock.geocodeMock.mockImplementation(
        (request: unknown, callback: (results: unknown, status: string) => void) => {
          callback([], 'OK');
        }
      );

      const searchInput = getInput('.js-editable_map-search_text');
      searchInput.value = '名古屋城';
      const enterEvent = new KeyboardEvent('keydown', { cancelable: true });
      Object.defineProperty(enterEvent, 'keyCode', { value: 13 });
      searchInput.dispatchEvent(enterEvent);
      await flushPromises();

      expect(googleMock.geocodeMock).toHaveBeenCalledWith({ address: '名古屋城' }, expect.any(Function));
    });

    test('Enter 以外のキーでは検索しない', async () => {
      const container = createPickerDOM();
      await createPicker(container);

      const searchInput = getInput('.js-editable_map-search_text');
      searchInput.value = '名古屋城';
      const keyEvent = new KeyboardEvent('keydown', { cancelable: true });
      Object.defineProperty(keyEvent, 'keyCode', { value: 65 });
      searchInput.dispatchEvent(keyEvent);
      await flushPromises();

      expect(googleMock.geocodeMock).not.toHaveBeenCalled();
    });

    test('検索用の要素が無い場合は検索機能を初期化しない', async () => {
      const container = createPickerDOM(`
        <div class="js-map-editable">
          <input type="text" value="35" class="js-map_editable-lat" />
          <input type="text" value="136" class="js-map_editable-lng" />
          <input type="text" value="10" class="js-map_editable-zoom" />
          <div class="js-map_editable-container"></div>
        </div>
      `);
      const picker = await createPicker(container);
      expect(picker.getMap()).not.toBeNull();
    });
  });

  describe('getMap', () => {
    test('init 前は null を返す', () => {
      const container = createPickerDOM();
      const picker = new GoogleMapsPicker(container, {});
      expect(picker.getMap()).toBeNull();
    });
  });

  describe('destroy', () => {
    test('destroy するとイベントリスナーとマップが破棄される', async () => {
      const container = createPickerDOM();
      const onChange = vi.fn();
      const picker = await createPicker(container, { onChange });
      const marker = googleMock.instances.markers[0];

      picker.destroy();

      expect(picker.getMap()).toBeNull();
      expect(marker.map).toBeNull();
      expect(container.querySelector('.js-map_editable-container')!.children.length).toBe(0);

      // DOM イベントリスナーも解除されている
      onChange.mockClear();
      changeValue(getInput('.js-map_editable-lat'), '40');
      expect(onChange).not.toHaveBeenCalled();
    });

    test('init 前に destroy を呼んでも安全に動作する', () => {
      const container = createPickerDOM();
      const picker = new GoogleMapsPicker(container, {});
      expect(() => picker.destroy()).not.toThrow();
    });
  });
});
