import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';

import LocationSearch from './LocationSearch';
import type { PostcodeGeometry } from '../../types';

const RECENT_SEARCHES_STORAGE_KEY = 'perfect-postcode.locationSearch.recent';

vi.mock('react-i18next', () => ({
  useTranslation: () => ({
    t: (key: string) => {
      if (key === 'locationSearch.placeholder') return 'Search places or postcodes...';
      if (key === 'locationSearch.noResults') return 'No matching places or postcodes';
      return key;
    },
  }),
}));

vi.mock('../../hooks/useIsMobile', () => ({
  useIsMobile: () => false,
}));

vi.mock('../../lib/pocketbase', () => ({
  default: { authStore: { isValid: false, token: '' } },
}));

const postcodeGeometry: PostcodeGeometry = {
  type: 'Polygon',
  coordinates: [
    [
      [-0.12, 51.5],
      [-0.11, 51.5],
      [-0.11, 51.51],
      [-0.12, 51.51],
      [-0.12, 51.5],
    ],
  ],
};

function deferred<T>() {
  let resolve!: (value: T) => void;
  let reject!: (reason?: unknown) => void;
  const promise = new Promise<T>((res, rej) => {
    resolve = res;
    reject = rej;
  });
  return { promise, resolve, reject };
}

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

describe('LocationSearch', () => {
  afterEach(() => {
    cleanup();
    window.localStorage.clear();
    vi.unstubAllGlobals();
  });

  it('ignores stale postcode lookups when a newer search starts', async () => {
    const firstLookup = deferred<Response>();
    const secondLookup = deferred<Response>();
    const requests: { postcode: string; signal?: AbortSignal | null }[] = [];

    vi.stubGlobal(
      'fetch',
      vi.fn((input: string | URL | Request, init?: RequestInit) => {
        const url = new URL(String(input), 'http://localhost');
        const postcode = decodeURIComponent(url.pathname.replace('/api/postcode/', ''));
        requests.push({ postcode, signal: init?.signal });
        if (postcode === 'SW1A 1AA') return firstLookup.promise;
        if (postcode === 'E14 2DG') return secondLookup.promise;
        return Promise.resolve(new Response(null, { status: 404 }));
      })
    );

    const onFlyTo = vi.fn();
    const onLocationSearched = vi.fn();

    render(<LocationSearch onFlyTo={onFlyTo} onLocationSearched={onLocationSearched} />);

    const input = screen.getByRole('textbox');
    fireEvent.change(input, { target: { value: 'SW1A 1AA' } });
    fireEvent.keyDown(input, { key: 'Enter' });

    fireEvent.change(input, { target: { value: 'E14 2DG' } });
    fireEvent.keyDown(input, { key: 'Enter' });

    expect(requests).toHaveLength(2);
    expect(requests[0].signal?.aborted).toBe(true);

    secondLookup.resolve(
      jsonResponse({
        postcode: 'E14 2DG',
        latitude: 51.505,
        longitude: -0.01,
        geometry: postcodeGeometry,
      })
    );

    await waitFor(() => {
      expect(onLocationSearched).toHaveBeenCalledTimes(1);
    });
    expect(onFlyTo).toHaveBeenCalledWith(51.505, -0.01, 16);
    expect(onLocationSearched).toHaveBeenCalledWith({
      postcode: 'E14 2DG',
      geometry: postcodeGeometry,
      latitude: 51.505,
      longitude: -0.01,
      zoom: 16,
    });

    firstLookup.resolve(
      jsonResponse({
        postcode: 'SW1A 1AA',
        latitude: 51.501,
        longitude: -0.141,
        geometry: postcodeGeometry,
      })
    );

    await new Promise((resolve) => setTimeout(resolve, 0));
    expect(onFlyTo).toHaveBeenCalledTimes(1);
    expect(onLocationSearched).toHaveBeenCalledTimes(1);
  });

  it('stores successful searches locally and shows them when the input is empty', async () => {
    vi.stubGlobal(
      'fetch',
      vi.fn((input: string | URL | Request) => {
        const url = new URL(String(input), 'http://localhost');
        const postcode = decodeURIComponent(url.pathname.replace('/api/postcode/', ''));
        return Promise.resolve(
          jsonResponse({
            postcode,
            latitude: 51.505,
            longitude: -0.01,
            geometry: postcodeGeometry,
          })
        );
      })
    );

    const onFlyTo = vi.fn();
    const onLocationSearched = vi.fn();

    render(<LocationSearch onFlyTo={onFlyTo} onLocationSearched={onLocationSearched} />);

    const input = screen.getByRole('textbox');
    fireEvent.change(input, { target: { value: 'SW1A 1AA' } });
    fireEvent.keyDown(input, { key: 'Enter' });

    await waitFor(() => {
      expect(onLocationSearched).toHaveBeenCalledTimes(1);
    });

    expect(JSON.parse(window.localStorage.getItem(RECENT_SEARCHES_STORAGE_KEY) ?? '[]')).toEqual([
      { type: 'postcode', label: 'SW1A 1AA' },
    ]);

    fireEvent.focus(input);

    await waitFor(() => {
      expect(screen.getByRole('button', { name: 'SW1A 1AA' })).toBeTruthy();
    });
  });

  it('flies to a place result immediately and resolves its nearest postcode for the side panel', async () => {
    window.localStorage.setItem(
      RECENT_SEARCHES_STORAGE_KEY,
      JSON.stringify([
        {
          type: 'place',
          name: 'E14',
          slug: 'e14',
          place_type: 'outcode',
          lat: 51.505,
          lon: -0.01,
        },
      ])
    );
    const nearestLookup = deferred<Response>();
    vi.stubGlobal(
      'fetch',
      vi.fn((input: string | URL | Request) => {
        const url = new URL(String(input), 'http://localhost');
        if (url.pathname === '/api/nearest-postcode') return nearestLookup.promise;
        return Promise.resolve(new Response(null, { status: 404 }));
      })
    );

    const onFlyTo = vi.fn();
    const onLocationSearched = vi.fn();
    render(<LocationSearch onFlyTo={onFlyTo} onLocationSearched={onLocationSearched} />);

    const input = screen.getByRole('textbox');
    fireEvent.focus(input);
    fireEvent.mouseDown(await screen.findByRole('button', { name: 'E14' }));

    // The camera moves straight away from the result's own coordinates, without
    // waiting on the nearest-postcode lookup (which only feeds the side panel).
    expect(onFlyTo).toHaveBeenCalledWith(51.505, -0.01, 16);
    expect(onLocationSearched).not.toHaveBeenCalled();

    nearestLookup.resolve(
      jsonResponse({
        postcode: 'E14 2DG',
        latitude: 51.506,
        longitude: -0.012,
        geometry: postcodeGeometry,
      })
    );

    await waitFor(() => {
      expect(onLocationSearched).toHaveBeenCalledTimes(1);
    });
    expect(onLocationSearched).toHaveBeenCalledWith({
      postcode: 'E14 2DG',
      geometry: postcodeGeometry,
      latitude: 51.506,
      longitude: -0.012,
      zoom: 16,
      markerLatitude: 51.505,
      markerLongitude: -0.01,
    });
    expect(onFlyTo).toHaveBeenCalledTimes(1);
    expect(onFlyTo.mock.invocationCallOrder[0]).toBeLessThan(
      onLocationSearched.mock.invocationCallOrder[0]
    );
  });

  it('selects the first place suggestion with Enter when none is highlighted', async () => {
    vi.stubGlobal(
      'fetch',
      vi.fn((input: string | URL | Request) => {
        const url = new URL(String(input), 'http://localhost');
        if (url.pathname === '/api/places') {
          return Promise.resolve(
            jsonResponse({
              places: [
                {
                  type: 'place',
                  name: 'London',
                  slug: 'london',
                  place_type: 'city',
                  lat: 51.5074,
                  lon: -0.1278,
                },
              ],
              postcodes: [],
              addresses: [],
            })
          );
        }
        if (url.pathname === '/api/nearest-postcode') {
          return Promise.resolve(
            jsonResponse({
              postcode: 'SW1A 1AA',
              latitude: 51.501,
              longitude: -0.141,
              geometry: postcodeGeometry,
            })
          );
        }
        return Promise.resolve(new Response(null, { status: 404 }));
      })
    );

    const onFlyTo = vi.fn();
    const onLocationSearched = vi.fn();
    render(<LocationSearch onFlyTo={onFlyTo} onLocationSearched={onLocationSearched} />);

    const input = screen.getByRole('textbox');
    fireEvent.change(input, { target: { value: 'London' } });

    await screen.findByRole('button', { name: 'London' });
    fireEvent.keyDown(input, { key: 'Enter' });

    await waitFor(() => {
      expect(onLocationSearched).toHaveBeenCalledTimes(1);
    });
    expect(onFlyTo).toHaveBeenCalledWith(51.5074, -0.1278, 10);
    expect(onLocationSearched).toHaveBeenCalledWith({
      postcode: 'SW1A 1AA',
      geometry: postcodeGeometry,
      latitude: 51.501,
      longitude: -0.141,
      zoom: 10,
      markerLatitude: 51.5074,
      markerLongitude: -0.1278,
    });
  });

  it('preserves the server unified ordering and sends the viewport centre', async () => {
    const fetchMock = vi.fn((input: string | URL | Request) => {
      const url = new URL(String(input), 'http://localhost');
      if (url.pathname === '/api/places') {
        return Promise.resolve(
          jsonResponse({
            places: [],
            postcodes: [],
            addresses: [],
            // Intentionally out of "bucket" order: an address outranks a place. The hook must
            // render them in this server order rather than re-bucketing or re-filtering.
            results: [
              {
                type: 'address',
                address: '1 High Street',
                postcode: 'CR0 1AA',
                lat: 51.37,
                lon: -0.1,
                score: 930,
              },
              {
                type: 'place',
                name: 'Croydon',
                slug: 'croydon',
                place_type: 'town',
                lat: 51.37,
                lon: -0.1,
                score: 880,
              },
            ],
          })
        );
      }
      return Promise.resolve(new Response(null, { status: 404 }));
    });
    vi.stubGlobal('fetch', fetchMock);

    render(
      <LocationSearch
        onFlyTo={vi.fn()}
        onLocationSearched={vi.fn()}
        getViewportCenter={() => ({ lat: 51.5, lng: -0.12 })}
      />
    );

    fireEvent.change(screen.getByRole('textbox'), { target: { value: 'high street' } });

    // The address (first in the server list) renders before the place, unfiltered.
    const firstResult = await screen.findByText('1 High Street');
    const place = screen.getByText('Croydon');
    expect(
      firstResult.compareDocumentPosition(place) & Node.DOCUMENT_POSITION_FOLLOWING
    ).toBeTruthy();

    const placesCall = fetchMock.mock.calls.find(([input]) =>
      String(input).includes('/api/places')
    );
    const calledUrl = new URL(String(placesCall?.[0]), 'http://localhost');
    expect(calledUrl.searchParams.get('lat')).toBe('51.5');
    expect(calledUrl.searchParams.get('lng')).toBe('-0.12');
  });

  it('shows an empty state for invalid place queries (legacy server, no results key)', async () => {
    vi.stubGlobal(
      'fetch',
      vi.fn(() =>
        Promise.resolve(
          jsonResponse({
            places: [],
            postcodes: [],
            addresses: [],
          })
        )
      )
    );

    render(<LocationSearch onFlyTo={vi.fn()} onLocationSearched={vi.fn()} />);

    fireEvent.change(screen.getByRole('textbox'), { target: { value: '!!!!zzzzzz!!!!' } });

    await waitFor(() => {
      expect(screen.getByText('No matching places or postcodes')).toBeTruthy();
    });
  });

  it('shows the empty state when the new server returns an empty results array', async () => {
    vi.stubGlobal(
      'fetch',
      vi.fn(() =>
        Promise.resolve(jsonResponse({ places: [], postcodes: [], addresses: [], results: [] }))
      )
    );

    render(<LocationSearch onFlyTo={vi.fn()} onLocationSearched={vi.fn()} />);

    fireEvent.change(screen.getByRole('textbox'), { target: { value: 'zzzznowhere' } });

    await waitFor(() => {
      expect(screen.getByText('No matching places or postcodes')).toBeTruthy();
    });
  });

  it('keeps only the ten most recent local searches', async () => {
    vi.stubGlobal(
      'fetch',
      vi.fn((input: string | URL | Request) => {
        const url = new URL(String(input), 'http://localhost');
        const postcode = decodeURIComponent(url.pathname.replace('/api/postcode/', ''));
        return Promise.resolve(
          jsonResponse({
            postcode,
            latitude: 51.505,
            longitude: -0.01,
            geometry: postcodeGeometry,
          })
        );
      })
    );

    render(<LocationSearch onFlyTo={vi.fn()} onLocationSearched={vi.fn()} />);

    // Search eleven distinct postcodes; only the ten most recent should be kept.
    const postcodes = [
      'SW1A 1AA',
      'E14 2DG',
      'W1A 1AA',
      'EC1A 1BB',
      'N1 1AA',
      'M1 1AE',
      'B33 8TH',
      'CR2 6XH',
      'DN55 1PT',
      'L1 8JQ',
      'G1 1AA',
    ];
    const input = screen.getByRole('textbox');
    for (const postcode of postcodes) {
      fireEvent.change(input, { target: { value: postcode } });
      fireEvent.keyDown(input, { key: 'Enter' });

      await waitFor(() => {
        const stored = JSON.parse(
          window.localStorage.getItem(RECENT_SEARCHES_STORAGE_KEY) ?? '[]'
        ) as { label?: string }[];
        expect(stored[0]?.label).toBe(postcode);
      });
    }

    const stored = JSON.parse(window.localStorage.getItem(RECENT_SEARCHES_STORAGE_KEY) ?? '[]') as {
      label?: string;
    }[];
    // Most recent first, oldest ('SW1A 1AA') dropped past the limit of ten.
    expect(stored.map((search) => search.label)).toEqual([...postcodes].reverse().slice(0, 10));

    fireEvent.focus(input);

    await waitFor(() => {
      expect(screen.getByRole('button', { name: 'G1 1AA' })).toBeTruthy();
      expect(screen.getByRole('button', { name: 'E14 2DG' })).toBeTruthy();
    });
    expect(screen.queryByText('SW1A 1AA')).toBeNull();
  });
});
