import * as React from 'react';
import {
  createContext,
  forwardRef,
  useCallback,
  useContext,
  useEffect,
  useImperativeHandle,
  useRef,
  useState,
} from 'react';
import { StyleSheet, View } from 'react-native';
import type { NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle } from 'react-native';
import NativeSMapView, { Commands } from './SMapNativeViewNativeComponent';
import { cameraJson, toFeatureCollection, toLatLng, type LayerData, type LngLatLike } from './geojson';

export type StyleFlavor = 'light' | 'white' | 'grayscale' | 'dark' | 'black';
export const styles: StyleFlavor[] = ['light', 'white', 'grayscale', 'dark', 'black'];

export type LayerKind = 'points' | 'lines' | 'polygons';

/** Called with the tapped feature's properties and its position. */
export type TapHandler = (properties: Record<string, unknown>, position: { lat: number; lng: number }) => void;

/** The map, as the ref of `<SMap>` and `useSMap()` give it once it is ready. */
export interface SMapHandle {
  addPoints(id: string, data: LayerData, options?: Record<string, unknown>): void;
  addLines(id: string, data: LayerData, options?: Record<string, unknown>): void;
  addPolygons(id: string, data: LayerData, options?: Record<string, unknown>): void;
  /** Replaces a layer's data and keeps its look. */
  setData(id: string, data: LayerData): void;
  setVisible(id: string, visible: boolean): void;
  removeLayer(id: string): void;
  /** Layer ids in draw order, bottom first. */
  layers(): string[];
  /** Moves the camera to show a layer's data; padding in points (40), maxZoom 15. */
  fitLayer(id: string, options?: { padding?: number; maxZoom?: number }): void;
  flyTo(center: LngLatLike, zoom?: number): void;
  /** Calls `handler` when a feature of layer `id` is tapped (null removes it). */
  onTap(id: string, handler: TapHandler | null): void;
  setStyle(style: StyleFlavor): void;
  setLanguage(language: string): void;
  setBuildings3D(on: boolean): void;
}

const MapContext = createContext<SMapHandle | null>(null);

/** The map of the nearest `<SMap>` above, once it is ready (null before). */
export function useSMap(): SMapHandle | null {
  return useContext(MapContext);
}

export interface SMapProps extends Omit<ViewProps, 'style'> {
  /** A map key (`smk_…`) whose allowlist names this app. Read when the map is created. */
  apiKey?: string;
  /** A map token. Read when the map is created; changing it creates the map again (use `getToken` to renew). */
  token?: string;
  /** Called at start when no token is given and again two minutes before each token expires. */
  getToken?: () => string | Promise<string>;
  /** The colour scheme (layers are kept on a change). A style object here is taken as `containerStyle`. */
  style?: StyleFlavor | StyleProp<ViewStyle>;
  /** Label language (`en`, `fr`, `zh-Hans`, …); the device language by default. Layers are kept on a change. */
  language?: string;
  /** [longitude, latitude] or `{lng, lat}`; the camera follows a change. */
  center?: LngLatLike;
  zoom?: number;
  bearing?: number;
  pitch?: number;
  /** Node URLs known in advance, used while `baseUrl` is down. */
  nodes?: string[];
  /** The map service, `https://tile.srsr.ca` by default. */
  baseUrl?: string;
  /** Read from the nearest service node (default true). */
  nearest?: boolean;
  /** Buildings in 3D. */
  buildings3D?: boolean;
  /** Called once the first style has loaded. */
  onReady?: (map: SMapHandle) => void;
  /** Errors from the map (a failed token, a style that could not load). */
  onError?: (error: Error) => void;
  /** The layout of the map view. */
  containerStyle?: StyleProp<ViewStyle>;
  children?: React.ReactNode;
}

type NativeRef = React.ElementRef<typeof NativeSMapView>;

/**
 * A map view. The ref is the map (`SMapHandle`) once the first style has loaded, null before.
 */
export const SMap = forwardRef<SMapHandle | null, SMapProps>(function SMap(props, ref) {
  const {
    apiKey, token, getToken, style, language, center, zoom, bearing, pitch, nodes, baseUrl, nearest, buildings3D,
    onReady, onError, containerStyle, children, ...viewProps
  } = props;
  const flavorProp = typeof style === 'string' ? style : undefined;
  const layoutStyle = typeof style === 'string' ? containerStyle : [containerStyle, style as StyleProp<ViewStyle>];

  const native = useRef<NativeRef | null>(null);
  const latest = useRef(props);
  latest.current = props;
  const [handle, setHandle] = useState<SMapHandle | null>(null);
  const [flavor, setFlavor] = useState<string>(flavorProp || 'light');
  const [lang, setLang] = useState<string>(language || '');
  const [buildings, setBuildings] = useState<boolean>(!!buildings3D);
  useEffect(() => { if (flavorProp) { setFlavor(flavorProp); } }, [flavorProp]);
  useEffect(() => { if (language !== undefined) { setLang(language); } }, [language]);
  useEffect(() => { setBuildings(!!buildings3D); }, [buildings3D]);

  // Read when the native map is created; a change creates it again (the native view gets a new key).
  const config = JSON.stringify({
    baseUrl: baseUrl || null,
    nodes: nodes || [],
    apiKey: apiKey || null,
    token: token || null,
    nearest: nearest !== false,
    getToken: !!getToken,
  });
  const camera = cameraJson({ center, zoom, bearing, pitch });

  // The layers of this map, in the order the native SDKs keep them (zIndex, then the order added).
  const layers = useRef<{ id: string; zIndex: number }[]>([]);
  const taps = useRef<Record<string, TapHandler>>({});
  // A handle works only for the native view it was made for; a new view (new config) makes the old handle inert.
  const generation = useRef(0);
  useEffect(() => {
    generation.current += 1;
    layers.current = [];
    taps.current = {};
    setHandle(null);
  }, [config]);

  const makeHandle = useCallback((): SMapHandle => {
    const mine = generation.current;
    const live = () => generation.current === mine && native.current !== null;
    const view = () => native.current as NativeRef;
    const add = (kind: LayerKind, id: string, data: LayerData, options: Record<string, unknown> = {}) => {
      if (!live()) { return; }
      const zIndex = typeof options.zIndex === 'number' ? options.zIndex : 0;
      const rest = layers.current.filter((l) => l.id !== id);
      rest.push({ id, zIndex });
      // A stable sort: equal zIndex keeps the order added.
      layers.current = rest.map((l, i) => ({ l, i })).sort((a, b) => a.l.zIndex - b.l.zIndex || a.i - b.i).map((x) => x.l);
      Commands.addLayer(view(), kind, id, JSON.stringify(toFeatureCollection(data)), JSON.stringify(options));
    };
    return {
      addPoints: (id, data, options) => add('points', id, data, options),
      addLines: (id, data, options) => add('lines', id, data, options),
      addPolygons: (id, data, options) => add('polygons', id, data, options),
      setData: (id, data) => { if (live()) { Commands.setData(view(), id, JSON.stringify(toFeatureCollection(data))); } },
      setVisible: (id, visible) => { if (live()) { Commands.setVisible(view(), id, visible); } },
      removeLayer: (id) => {
        if (!live()) { return; }
        layers.current = layers.current.filter((l) => l.id !== id);
        delete taps.current[id];
        Commands.removeLayer(view(), id);
      },
      layers: () => (live() ? layers.current.map((l) => l.id) : []),
      fitLayer: (id, options = {}) => { if (live()) { Commands.fitLayer(view(), id, options.padding ?? 40, options.maxZoom ?? 15); } },
      flyTo: (c, z) => {
        if (!live()) { return; }
        const p = toLatLng(c);
        Commands.flyTo(view(), p.lat, p.lng, typeof z === 'number' ? z : -1);
      },
      onTap: (id, handler) => {
        if (!live()) { return; }
        if (handler) { taps.current[id] = handler; } else { delete taps.current[id]; }
      },
      setStyle: (s) => setFlavor(s),
      setLanguage: (l) => setLang(l),
      setBuildings3D: (on) => setBuildings(on),
    };
  }, []);

  useImperativeHandle(ref, () => handle as SMapHandle, [handle]);

  const handleReady = useCallback(() => {
    const h = makeHandle();
    setHandle(h);
    latest.current.onReady?.(h);
  }, [makeHandle]);

  const handleError = useCallback((e: NativeSyntheticEvent<{ message: string }>) => {
    latest.current.onError?.(new Error(e.nativeEvent.message));
  }, []);

  const handleTap = useCallback((e: NativeSyntheticEvent<{ layerId: string; properties: string; lat: number; lng: number }>) => {
    const { layerId, properties, lat, lng } = e.nativeEvent;
    const handler = taps.current[layerId];
    if (!handler) { return; }
    let parsed: Record<string, unknown> = {};
    try { parsed = JSON.parse(properties || '{}'); } catch { parsed = {}; }
    handler(parsed, { lat, lng });
  }, []);

  const handleTokenRequest = useCallback((e: NativeSyntheticEvent<{ requestId: number }>) => {
    const id = e.nativeEvent.requestId;
    const answer = (t: string, err: string) => { if (native.current) { Commands.resolveToken(native.current, id, t, err); } };
    const ask = latest.current.getToken;
    if (!ask) { answer('', 'sMap: no getToken'); return; }
    Promise.resolve()
      .then(() => ask())
      .then((t) => answer(String(t || ''), t ? '' : 'sMap: getToken gave no token'),
        (err: unknown) => answer('', 'sMap: ' + (err instanceof Error ? err.message : String(err))));
  }, []);

  return (
    <View {...viewProps} style={[{ flex: 1 }, layoutStyle]}>
      <NativeSMapView
        key={config}
        ref={native}
        style={StyleSheet.absoluteFill}
        config={config}
        mapStyle={flavor}
        language={lang}
        camera={camera}
        buildings3D={buildings}
        onMapReady={handleReady}
        onMapError={handleError}
        onLayerTap={handleTap}
        onTokenRequest={handleTokenRequest}
      />
      <MapContext.Provider value={handle}>{children}</MapContext.Provider>
    </View>
  );
});
