import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { useMapData } from './useMapData';
import type { TravelTimeEntry } from './useTravelTime';
import type { ApiResponse, Bounds, FeatureMeta, ViewChangeParams } from '../types';

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

function response(features: ApiResponse['features']): Response {
  return new Response(JSON.stringify({ features }), {
    status: 200,
    headers: { 'Content-Type': 'application/json' },
  });
}

function viewChange(bounds: Bounds): ViewChangeParams {
  return {
    resolution: 8,
    bounds,
    visibleBounds: bounds,
    zoom: 10,
    latitude: (bounds.south + bounds.north) / 2,
    longitude: (bounds.west + bounds.east) / 2,
  };
}

async function flushPromises() {
  await Promise.resolve();
  await Promise.resolve();
}

describe('useMapData', () => {
  const requests: Array<{ url: string; resolve: (response: Response) => void }> = [];
  const noTravelTimeEntries: TravelTimeEntry[] = [];

  beforeEach(() => {
    vi.useFakeTimers();
    requests.length = 0;
    vi.stubGlobal(
      'fetch',
      vi.fn((url: string | URL | Request) => {
        return new Promise<Response>((resolve) => {
          requests.push({ url: String(url), resolve });
        });
      })
    );
  });

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

  it('ignores a stale map response after the view has already changed', async () => {
    const { result } = renderHook(() =>
      useMapData({
        filters: {},
        features: [],
        viewFeature: null,
        activeFeature: null,
        pinnedFeature: null,
        travelTimeEntries: noTravelTimeEntries,
      })
    );

    await act(async () => {
      result.current.handleViewChange(viewChange({ south: 1, west: 1, north: 2, east: 2 }));
    });
    await act(async () => {
      vi.advanceTimersByTime(150);
    });
    expect(requests).toHaveLength(1);

    await act(async () => {
      result.current.handleViewChange(viewChange({ south: 3, west: 3, north: 4, east: 4 }));
    });

    await act(async () => {
      requests[0].resolve(response([{ h3: 'old', count: 99, lat: 1.5, lon: 1.5 }]));
      await flushPromises();
    });
    expect(result.current.data).toEqual([]);

    await act(async () => {
      vi.advanceTimersByTime(150);
    });
    expect(requests).toHaveLength(2);

    await act(async () => {
      requests[1].resolve(response([{ h3: 'new', count: 7, lat: 3.5, lon: 3.5 }]));
      await flushPromises();
    });

    expect(result.current.data).toEqual([{ h3: 'new', count: 7, lat: 3.5, lon: 3.5 }]);
  });

  it('stores the visible map center separately from the rendered map center', async () => {
    const { result } = renderHook(() =>
      useMapData({
        filters: {},
        features: [],
        viewFeature: null,
        activeFeature: null,
        pinnedFeature: null,
        travelTimeEntries: noTravelTimeEntries,
      })
    );

    await act(async () => {
      result.current.handleViewChange({
        ...viewChange({ south: 1, west: 1, north: 2, east: 2 }),
        latitude: 51.5,
        longitude: -0.1,
        visibleLatitude: 51.6,
        visibleLongitude: -0.2,
      });
    });

    expect(result.current.currentView).toEqual({ latitude: 51.5, longitude: -0.1, zoom: 10 });
    expect(result.current.currentVisibleView).toEqual({
      latitude: 51.6,
      longitude: -0.2,
      zoom: 10,
    });
  });

  it('resets the colour range to visible drag preview data while a slider is active', async () => {
    const bounds = { south: 1, west: 1, north: 2, east: 2 };
    const features: FeatureMeta[] = [
      {
        name: 'price',
        type: 'numeric',
        min: 0,
        max: 100,
      },
    ];
    const filters = { price: [20, 80] as [number, number] };

    const { result, rerender } = renderHook(
      ({
        activeFeature,
        filterRange,
      }: {
        activeFeature: string | null;
        filterRange: [number, number] | null;
      }) =>
        useMapData({
          filters,
          features,
          viewFeature: 'price',
          activeFeature,
          pinnedFeature: null,
          filterRange,
          travelTimeEntries: noTravelTimeEntries,
        }),
      {
        initialProps: {
          activeFeature: null as string | null,
          filterRange: filters.price,
        },
      }
    );

    await act(async () => {
      result.current.handleViewChange(viewChange(bounds));
    });
    await act(async () => {
      vi.advanceTimersByTime(150);
    });

    await act(async () => {
      requests[0].resolve(
        response([
          { h3: 'committed-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 20 },
          { h3: 'committed-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 80 },
        ])
      );
      await flushPromises();
    });
    expect(result.current.colorRange?.[0]).toBeCloseTo(23);
    expect(result.current.colorRange?.[1]).toBeCloseTo(77);

    await act(async () => {
      rerender({ activeFeature: 'price', filterRange: filters.price });
      await flushPromises();
    });
    expect(requests).toHaveLength(2);

    const previewData = [
      {
        h3: 'preview-outside-low',
        count: 1,
        lat: 1.1,
        lon: 1.1,
        min_price: 0,
        max_price: 10,
        avg_price: 5,
      },
      {
        h3: 'preview-low',
        count: 1,
        lat: 1.25,
        lon: 1.25,
        min_price: 20,
        max_price: 20,
        avg_price: 20,
      },
      {
        h3: 'preview-high',
        count: 1,
        lat: 1.75,
        lon: 1.75,
        min_price: 80,
        max_price: 80,
        avg_price: 80,
      },
      {
        h3: 'preview-outside-high',
        count: 1,
        lat: 1.9,
        lon: 1.9,
        min_price: 90,
        max_price: 100,
        avg_price: 95,
      },
    ];

    await act(async () => {
      requests[1].resolve(response(previewData));
      await flushPromises();
    });

    expect(result.current.data).toEqual(previewData);
    expect(result.current.colorRange?.[0]).toBeCloseTo(23);
    expect(result.current.colorRange?.[1]).toBeCloseTo(77);
  });

  it('does not use metadata min/max while slider preview colour data is loading', async () => {
    const bounds = { south: 1, west: 1, north: 2, east: 2 };
    const features: FeatureMeta[] = [
      {
        name: 'price',
        type: 'numeric',
        min: 0,
        max: 100,
      },
    ];
    const filters = { price: [20, 80] as [number, number] };

    const { result, rerender } = renderHook(
      ({
        viewFeature,
        activeFeature,
      }: {
        viewFeature: string | null;
        activeFeature: string | null;
      }) =>
        useMapData({
          filters,
          features,
          viewFeature,
          activeFeature,
          pinnedFeature: null,
          travelTimeEntries: noTravelTimeEntries,
        }),
      {
        initialProps: {
          viewFeature: null as string | null,
          activeFeature: null as string | null,
        },
      }
    );

    await act(async () => {
      result.current.handleViewChange(viewChange(bounds));
    });
    await act(async () => {
      vi.advanceTimersByTime(150);
    });
    await act(async () => {
      requests[0].resolve(
        response([
          { h3: 'density-low', count: 1, lat: 1.25, lon: 1.25 },
          { h3: 'density-high', count: 1, lat: 1.75, lon: 1.75 },
        ])
      );
      await flushPromises();
    });

    await act(async () => {
      rerender({
        viewFeature: 'price',
        activeFeature: 'price',
      });
      await flushPromises();
    });

    expect(result.current.colorRange).toBeNull();

    await act(async () => {
      requests[1].resolve(
        response([
          { h3: 'preview-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 0 },
          { h3: 'preview-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 100 },
        ])
      );
      await flushPromises();
    });

    expect(result.current.colorRange?.[0]).toBeCloseTo(5);
    expect(result.current.colorRange?.[1]).toBeCloseTo(95);
  });

  it('does not use stale committed feature data while slider preview colour data is loading', async () => {
    const bounds = { south: 1, west: 1, north: 2, east: 2 };
    const features: FeatureMeta[] = [
      {
        name: 'price',
        type: 'numeric',
        min: 0,
        max: 1_000,
      },
    ];

    const { result, rerender } = renderHook(
      ({
        filters,
        activeFeature,
      }: {
        filters: Record<string, [number, number]>;
        activeFeature: string | null;
      }) =>
        useMapData({
          filters,
          features,
          viewFeature: 'price',
          activeFeature,
          pinnedFeature: null,
          travelTimeEntries: noTravelTimeEntries,
        }),
      {
        initialProps: {
          filters: { price: [0, 1_000] as [number, number] },
          activeFeature: null as string | null,
        },
      }
    );

    await act(async () => {
      result.current.handleViewChange(viewChange(bounds));
    });
    await act(async () => {
      vi.advanceTimersByTime(150);
    });
    await act(async () => {
      requests[0].resolve(
        response([
          { h3: 'stale-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 0 },
          { h3: 'stale-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 1_000 },
        ])
      );
      await flushPromises();
    });
    expect(result.current.colorRange?.[1]).toBeCloseTo(950);

    await act(async () => {
      rerender({
        filters: { price: [20, 80] },
        activeFeature: 'price',
      });
      await flushPromises();
    });

    expect(result.current.colorRange).toBeNull();

    await act(async () => {
      requests[1].resolve(
        response([
          { h3: 'preview-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 20 },
          { h3: 'preview-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 80 },
        ])
      );
      await flushPromises();
    });

    expect(result.current.colorRange?.[0]).toBeCloseTo(23);
    expect(result.current.colorRange?.[1]).toBeCloseTo(77);
  });

  it('does not reuse cached drag preview data when the drag request changes', async () => {
    const bounds = { south: 1, west: 1, north: 2, east: 2 };
    const features: FeatureMeta[] = [
      {
        name: 'price',
        type: 'numeric',
        min: 0,
        max: 100,
      },
    ];
    const committedData = [
      { h3: 'committed-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 20 },
      { h3: 'committed-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 80 },
    ];
    const previewData = [
      { h3: 'preview-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 0 },
      { h3: 'preview-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 100 },
    ];

    const { result, rerender } = renderHook(
      ({
        filters,
        activeFeature,
      }: {
        filters: Record<string, [number, number]>;
        activeFeature: string | null;
      }) =>
        useMapData({
          filters,
          features,
          viewFeature: 'price',
          activeFeature,
          pinnedFeature: null,
          travelTimeEntries: noTravelTimeEntries,
        }),
      {
        initialProps: {
          filters: { price: [20, 80] as [number, number] },
          activeFeature: null as string | null,
        },
      }
    );

    await act(async () => {
      result.current.handleViewChange(viewChange(bounds));
    });
    await act(async () => {
      vi.advanceTimersByTime(150);
    });
    await act(async () => {
      requests[0].resolve(response(committedData));
      await flushPromises();
    });

    await act(async () => {
      rerender({
        filters: { price: [20, 80] },
        activeFeature: 'price',
      });
      await flushPromises();
    });
    await act(async () => {
      requests[1].resolve(response(previewData));
      await flushPromises();
    });
    expect(result.current.data).toEqual(previewData);

    await act(async () => {
      rerender({
        filters: { price: [10, 90] },
        activeFeature: 'price',
      });
    });

    expect(result.current.data).toEqual(committedData);
  });

  it('resets a pinned colour range after a slider commits a new filter', async () => {
    const bounds = { south: 1, west: 1, north: 2, east: 2 };
    const features: FeatureMeta[] = [
      {
        name: 'price',
        type: 'numeric',
        min: 0,
        max: 100,
      },
    ];

    const { result, rerender } = renderHook(
      ({
        filters,
        activeFeature,
      }: {
        filters: Record<string, [number, number]>;
        activeFeature: string | null;
      }) =>
        useMapData({
          filters,
          features,
          viewFeature: 'price',
          activeFeature,
          pinnedFeature: 'price',
          travelTimeEntries: noTravelTimeEntries,
        }),
      {
        initialProps: {
          filters: { price: [20, 80] as [number, number] },
          activeFeature: null as string | null,
        },
      }
    );

    await act(async () => {
      result.current.handleViewChange(viewChange(bounds));
    });
    await act(async () => {
      vi.advanceTimersByTime(150);
    });
    await act(async () => {
      requests[0].resolve(
        response([
          { h3: 'old-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 20 },
          { h3: 'old-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 80 },
        ])
      );
      await flushPromises();
    });
    expect(result.current.colorRange?.[0]).toBeCloseTo(23);
    expect(result.current.colorRange?.[1]).toBeCloseTo(77);

    await act(async () => {
      rerender({
        filters: { price: [20, 80] },
        activeFeature: 'price',
      });
      await flushPromises();
    });
    await act(async () => {
      rerender({
        filters: { price: [10, 90] },
        activeFeature: null,
      });
    });
    await act(async () => {
      vi.advanceTimersByTime(150);
    });

    const committedRequest = requests[requests.length - 1];
    await act(async () => {
      committedRequest.resolve(
        response([
          { h3: 'new-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 0 },
          { h3: 'new-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 100 },
        ])
      );
      await flushPromises();
    });

    expect(result.current.colorRange?.[0]).toBeCloseTo(5);
    expect(result.current.colorRange?.[1]).toBeCloseTo(95);
  });
});
