import { render, screen, fireEvent, act } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { NeighborhoodPOIMap } from '../neighborhood-poi-map';
import type { POIItem } from '../types';

// ── Mock Mapbox GL ────────────────────────────────────────────────────────────
// vi.mock factories are hoisted before imports. We use vi.hoisted() to share
// mutable state between the mock factory and the test body.

const {
  mockMapInstance,
  mapLoadCallbackHolder,
} = vi.hoisted(() => {
  const mapLoadCallbackHolder: { fn: (() => void) | null } = { fn: null };

  const mockMapInstance = {
    addControl: vi.fn(),
    remove: vi.fn(),
    flyTo: vi.fn(),
    setStyle: vi.fn(),
    once: vi.fn(),
    off: vi.fn(),
    getCanvas: vi.fn().mockReturnValue({ style: { cursor: '' } }),
    getSource: vi.fn().mockReturnValue(null),
    addSource: vi.fn(),
    addLayer: vi.fn(),
    // `on` captures the 'load' callback so tests can fire it
    on: vi.fn().mockImplementation(function (event: string, layerOrCb: unknown) {
      if (event === 'load' && typeof layerOrCb === 'function') {
        mapLoadCallbackHolder.fn = layerOrCb as () => void;
      }
    }),
    queryRenderedFeatures: vi.fn().mockReturnValue([]),
    easeTo: vi.fn(),
  };

  return { mockMapInstance, mapLoadCallbackHolder };
});

vi.mock('mapbox-gl', () => {
  // Must use regular `function` (not arrow) for constructors in Vitest v4+.
  function MockMap(this: unknown, _container: unknown, options: Record<string, unknown>) {
    void _container;
    void options;
    Object.assign(this as object, mockMapInstance);
  }
  function MockMarker(this: unknown) {
    Object.assign(this as object, {
      setLngLat: vi.fn().mockReturnThis(),
      setPopup: vi.fn().mockReturnThis(),
      addTo: vi.fn().mockReturnThis(),
      remove: vi.fn(),
    });
  }
  function MockPopup(this: unknown) {
    Object.assign(this as object, {
      setHTML: vi.fn().mockReturnThis(),
      setLngLat: vi.fn().mockReturnThis(),
      addTo: vi.fn().mockReturnThis(),
      remove: vi.fn(),
    });
  }
  return {
    default: {
      Map: MockMap,
      Marker: MockMarker,
      Popup: MockPopup,
      NavigationControl: vi.fn(),
      AttributionControl: vi.fn(),
      accessToken: '',
    },
  };
});

vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));

// ── Sample data ───────────────────────────────────────────────────────────────
const samplePois: POIItem[] = [
  { id: '1', name: 'Trường THPT Nguyễn Du', category: 'school', lat: 10.82, lng: 106.63, distance: 200 },
  { id: '2', name: 'Bệnh viện Nhân dân 115', category: 'hospital', lat: 10.83, lng: 106.64, distance: 500 },
  { id: '3', name: 'Trạm Metro Bến Thành', category: 'transit', lat: 10.77, lng: 106.70, distance: 800 },
];

const center = { lat: 10.82, lng: 106.63 };

/** Fire the Mapbox 'load' event — wrapped in `act` because it triggers setMapLoaded. */
async function triggerMapLoad() {
  await act(async () => {
    mapLoadCallbackHolder.fn?.();
  });
}

// ── Tests ─────────────────────────────────────────────────────────────────────
describe('NeighborhoodPOIMap', () => {
  beforeEach(() => {
    mapLoadCallbackHolder.fn = null;
    // Reset call history only — preserve mock implementations.
    mockMapInstance.addControl.mockClear();
    mockMapInstance.remove.mockClear();
    mockMapInstance.flyTo.mockClear();
    mockMapInstance.setStyle.mockClear();
    mockMapInstance.once.mockClear();
    mockMapInstance.off.mockClear();
    mockMapInstance.on.mockClear();
    mockMapInstance.getCanvas.mockClear();
    mockMapInstance.getSource.mockClear();
    mockMapInstance.addSource.mockClear();
    mockMapInstance.addLayer.mockClear();
    mockMapInstance.queryRenderedFeatures.mockClear();
    mockMapInstance.easeTo.mockClear();

    // Restore implementations cleared by mockClear
    mockMapInstance.getCanvas.mockReturnValue({ style: { cursor: '' } });
    mockMapInstance.getSource.mockReturnValue(null);
    mockMapInstance.queryRenderedFeatures.mockReturnValue([]);
    mockMapInstance.on.mockImplementation(function (event: string, layerOrCb: unknown) {
      if (event === 'load' && typeof layerOrCb === 'function') {
        mapLoadCallbackHolder.fn = layerOrCb as () => void;
      }
    });

    // Ensure the Mapbox token env var is set so map init runs.
    process.env['NEXT_PUBLIC_MAPBOX_TOKEN'] = 'test-token';
  });

  // ── Render ──────────────────────────────────────────────────────────────────
  it('renders map container', () => {
    const { container } = render(<NeighborhoodPOIMap center={center} pois={samplePois} />);
    expect(container.querySelector('.rounded-lg')).toBeInTheDocument();
  });

  it('renders all 6 category toggle buttons', () => {
    render(<NeighborhoodPOIMap center={center} pois={samplePois} />);
    expect(screen.getByText('Trường học')).toBeInTheDocument();
    expect(screen.getByText('Bệnh viện')).toBeInTheDocument();
    expect(screen.getByText('Giao thông')).toBeInTheDocument();
    expect(screen.getByText('Mua sắm')).toBeInTheDocument();
    expect(screen.getByText('Nhà hàng')).toBeInTheDocument();
    expect(screen.getByText('Công viên')).toBeInTheDocument();
  });

  it('shows POI count badge for categories that have POIs', () => {
    render(<NeighborhoodPOIMap center={center} pois={samplePois} />);
    const schoolBtn = screen.getByText('Trường học').closest('button')!;
    expect(schoolBtn.textContent).toContain('1');
  });

  // ── Category toggle ──────────────────────────────────────────────────────────
  it('toggles category off → button gets line-through class', () => {
    render(<NeighborhoodPOIMap center={center} pois={samplePois} />);
    const schoolBtn = screen.getByText('Trường học').closest('button')!;
    fireEvent.click(schoolBtn);
    expect(schoolBtn.className).toContain('line-through');
  });

  it('re-enables category on second click', () => {
    render(<NeighborhoodPOIMap center={center} pois={samplePois} />);
    const schoolBtn = screen.getByText('Trường học').closest('button')!;
    fireEvent.click(schoolBtn);
    fireEvent.click(schoolBtn);
    expect(schoolBtn.className).not.toContain('line-through');
  });

  // ── GeoJSON source + cluster layers ─────────────────────────────────────────
  it('adds GeoJSON source with cluster:true after map load', async () => {
    render(<NeighborhoodPOIMap center={center} pois={samplePois} />);
    await triggerMapLoad();
    expect(mockMapInstance.addSource).toHaveBeenCalledWith(
      'poi-source',
      expect.objectContaining({ type: 'geojson', cluster: true }),
    );
  });

  it('adds cluster, count label, and unclustered layers', async () => {
    render(<NeighborhoodPOIMap center={center} pois={samplePois} />);
    await triggerMapLoad();
    const layerIds = (mockMapInstance.addLayer.mock.calls as [{ id: string }][]).map(
      (call) => call[0].id,
    );
    expect(layerIds).toContain('poi-clusters');
    expect(layerIds).toContain('poi-cluster-count');
    expect(layerIds).toContain('poi-unclustered');
  });

  it('calls setData on existing source instead of adding a new one', async () => {
    const mockGeoJsonSource = { setData: vi.fn() };
    mockMapInstance.getSource.mockReturnValue(mockGeoJsonSource);

    render(<NeighborhoodPOIMap center={center} pois={samplePois} />);
    await triggerMapLoad();

    expect(mockMapInstance.addSource).not.toHaveBeenCalled();
    expect(mockGeoJsonSource.setData).toHaveBeenCalled();
  });

  it('includes all 3 POIs in the initial GeoJSON FeatureCollection', async () => {
    let capturedData: GeoJSON.FeatureCollection | null = null;
    mockMapInstance.addSource.mockImplementation(
      (_id: string, opts: { data: GeoJSON.FeatureCollection }) => {
        capturedData = opts.data;
      },
    );

    render(<NeighborhoodPOIMap center={center} pois={samplePois} />);
    await triggerMapLoad();

    expect(capturedData).not.toBeNull();
    expect(capturedData!.features).toHaveLength(3);
  });

  it('excludes deactivated categories from the GeoJSON FeatureCollection', async () => {
    let capturedData: GeoJSON.FeatureCollection | null = null;

    // First render: no source yet
    render(<NeighborhoodPOIMap center={center} pois={samplePois} />);
    await triggerMapLoad();

    // Simulate source existing for subsequent updates
    const mockGeoJsonSource = {
      setData: vi.fn().mockImplementation((data: GeoJSON.FeatureCollection) => {
        capturedData = data;
      }),
    };
    mockMapInstance.getSource.mockReturnValue(mockGeoJsonSource);

    // Toggle school off — triggers the POI effect which calls setData
    fireEvent.click(screen.getByText('Trường học').closest('button')!);

    expect(capturedData).not.toBeNull();
    expect(capturedData!.features).toHaveLength(2);
    expect(capturedData!.features.map((f) => f.properties?.['category'])).not.toContain('school');
  });

  // ── Loading state ─────────────────────────────────────────────────────────────
  it('does not add source/layers before the load event fires', () => {
    render(<NeighborhoodPOIMap center={center} pois={samplePois} />);
    // mapLoadCallbackHolder.fn not called yet
    expect(mockMapInstance.addSource).not.toHaveBeenCalled();
    expect(mockMapInstance.addLayer).not.toHaveBeenCalled();
  });

  // ── Fallback ──────────────────────────────────────────────────────────────────
  it('shows fallback when NEXT_PUBLIC_MAPBOX_TOKEN is absent', () => {
    delete process.env['NEXT_PUBLIC_MAPBOX_TOKEN'];
    render(<NeighborhoodPOIMap center={center} pois={samplePois} />);
    expect(screen.getByText(/NEXT_PUBLIC_MAPBOX_TOKEN/)).toBeInTheDocument();
  });

  it('renders correctly with zero POIs', () => {
    render(<NeighborhoodPOIMap center={center} pois={[]} />);
    expect(screen.getByText('Trường học')).toBeInTheDocument();
    // Count badge should not appear when poiCount === 0
    const schoolBtn = screen.getByText('Trường học').closest('button')!;
    expect(schoolBtn.querySelector('.rounded-full')).toBeNull();
  });
});
