import type { ViewState, Bounds } from '../types';
import type { StyleSpecification } from 'maplibre-gl';
import { layers, namedFlavor } from '@protomaps/basemaps';
import type { BasemapId } from './basemaps';
import {
  GLYPHS_URL,
  FEATURE_GRADIENT,
  DENSITY_GRADIENT,
  ZOOM_TO_RESOLUTION_THRESHOLDS,
  TWEMOJI_BASE,
  BUFFER_MULTIPLIER,
  POI_CATEGORY_LOGOS,
  MAP_MIN_ZOOM,
  type GradientStop,
} from './consts';
const ROAD_OPACITY = 0.4;
const TILE_SIZE = 512;
const MAX_MERCATOR_LATITUDE = 85;
const SATELLITE_MAX_ZOOM = 13;
const SATELLITE_ATTRIBUTION =
  'Sentinel-2 cloudless - https://s2maps.eu by EOX IT Services GmbH (Contains modified Copernicus Sentinel data 2024)';
// High-resolution EA Vertical Aerial Photography, stacked over Sentinel-2 where it
// exists (currently Greater London). The source is sparse: the server returns 204
// outside coverage, so the (overzoomed) Sentinel base shows through the gaps.
const SATELLITE_HIGHRES_MIN_ZOOM = 14;
const SATELLITE_HIGHRES_MAX_ZOOM = 19;
const SATELLITE_HIGHRES_ATTRIBUTION =
  'Aerial photography © Environment Agency copyright and/or database right 2011. All rights reserved. Open Government Licence v3.0';

export function getMapDataBeforeId(basemap: BasemapId): string {
  return basemap === 'satellite' ? 'roads_runway' : 'landuse_park';
}

function clampLatitude(latitude: number): number {
  return Math.max(-MAX_MERCATOR_LATITUDE, Math.min(MAX_MERCATOR_LATITUDE, latitude));
}

function longitudeToWorldX(longitude: number, worldSize: number): number {
  return ((longitude + 180) / 360) * worldSize;
}

function latitudeToWorldY(latitude: number, worldSize: number): number {
  const clampedLat = clampLatitude(latitude);
  const latRad = (clampedLat * Math.PI) / 180;
  const mercatorY = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2;
  return mercatorY * worldSize;
}

function worldXToLongitude(pixelX: number, worldSize: number): number {
  const longitude = (pixelX / worldSize) * 360 - 180;
  return ((((longitude + 180) % 360) + 360) % 360) - 180;
}

function worldYToLatitude(pixelY: number, worldSize: number): number {
  const mercY = Math.max(0.001, Math.min(0.999, pixelY / worldSize));
  const latRadians = Math.atan(Math.sinh(Math.PI * (1 - 2 * mercY)));
  return (latRadians * 180) / Math.PI;
}

export function getMapCenterForTargetScreenPoint(
  targetLatitude: number,
  targetLongitude: number,
  zoom: number,
  width: number,
  height: number,
  targetScreenX: number,
  targetScreenY: number
): Pick<ViewState, 'latitude' | 'longitude'> {
  if (width <= 0 || height <= 0) {
    return { latitude: targetLatitude, longitude: targetLongitude };
  }

  const worldSize = TILE_SIZE * Math.pow(2, zoom);
  const targetWorldX = longitudeToWorldX(targetLongitude, worldSize);
  const targetWorldY = latitudeToWorldY(targetLatitude, worldSize);
  const centerWorldX = targetWorldX + width / 2 - targetScreenX;
  const centerWorldY = targetWorldY + height / 2 - targetScreenY;

  return {
    latitude: worldYToLatitude(centerWorldY, worldSize),
    longitude: worldXToLongitude(centerWorldX, worldSize),
  };
}

function isSatelliteReferenceLayer(layer: ReturnType<typeof layers>[number]): boolean {
  if (layer.type === 'symbol') return true;
  if (layer.type !== 'line') return false;
  return (
    layer.id.startsWith('roads_') ||
    layer.id.startsWith('boundaries') ||
    layer.id.startsWith('water_')
  );
}

function satelliteReferenceLayer(layer: ReturnType<typeof layers>[number]) {
  if (layer.type === 'symbol') {
    return {
      ...layer,
      paint: {
        ...layer.paint,
        'text-color': '#f8fafc',
        'text-halo-color': '#111827',
        'text-halo-width': 1.6,
        'text-halo-blur': 0.3,
        'icon-opacity': 0.9,
      },
    };
  }

  if (layer.type === 'line') {
    const isCasing = layer.id.includes('casing');
    const isBoundary = layer.id.startsWith('boundaries');
    return {
      ...layer,
      paint: {
        ...layer.paint,
        'line-color': isBoundary ? '#f8fafc' : isCasing ? '#111827' : '#f9fafb',
        'line-opacity': isBoundary ? 0.45 : isCasing ? 0.62 : 0.78,
      },
    };
  }

  return layer;
}

export function getMapStyle(theme: 'light' | 'dark', basemap: BasemapId): StyleSpecification {
  const flavor = namedFlavor(theme);
  // Use absolute URL for tiles - required by MapLibre
  const tileUrl = `${window.location.origin}/api/tiles/{z}/{x}/{y}`;
  const satelliteTileUrl = `${window.location.origin}/api/tiles/satellite/{z}/{x}/{y}`;
  const satelliteHighresTileUrl = `${window.location.origin}/api/tiles/satellite-highres/{z}/{x}/{y}`;
  const baseLayers = layers('protomaps', flavor, { lang: 'en' });
  const isDark = theme === 'dark';

  // Reduce road layer opacity so hexagons are more visible
  // In dark mode, make all text white with dark outline
  const modifiedLayers = baseLayers
    .filter((layer) => !layer.id.includes('buildings'))
    .map((original) => {
      let layer = original;

      // Modify road opacity
      if (layer.id.includes('roads_') || layer.id.includes('road_')) {
        if (layer.type === 'line') {
          layer = { ...layer, paint: { ...layer.paint, 'line-opacity': ROAD_OPACITY } };
        } else if (layer.type === 'fill') {
          layer = { ...layer, paint: { ...layer.paint, 'fill-opacity': ROAD_OPACITY } };
        }
      }

      // Modify text colors in dark mode
      if (isDark && layer.type === 'symbol' && layer.paint?.['text-color']) {
        layer = {
          ...layer,
          paint: {
            ...layer.paint,
            'text-color': '#ffffff',
            'text-halo-color': '#1a1a1a',
            'text-halo-width': 1.5,
          },
        };
      }

      return layer;
    });

  if (basemap === 'satellite') {
    return {
      version: 8,
      sprite: `${window.location.origin}/assets/sprites/${theme}`,
      glyphs: GLYPHS_URL,
      sources: {
        satellite: {
          type: 'raster',
          tiles: [satelliteTileUrl],
          tileSize: 256,
          minzoom: MAP_MIN_ZOOM,
          maxzoom: SATELLITE_MAX_ZOOM,
          attribution: SATELLITE_ATTRIBUTION,
        },
        'satellite-highres': {
          type: 'raster',
          tiles: [satelliteHighresTileUrl],
          tileSize: 256,
          minzoom: SATELLITE_HIGHRES_MIN_ZOOM,
          maxzoom: SATELLITE_HIGHRES_MAX_ZOOM,
          attribution: SATELLITE_HIGHRES_ATTRIBUTION,
        },
        protomaps: {
          type: 'vector',
          tiles: [tileUrl],
          maxzoom: 15,
        },
      },
      layers: [
        {
          id: 'satellite-background',
          type: 'background',
          paint: {
            'background-color': isDark ? '#111827' : '#d4cec3',
          },
        },
        {
          id: 'satellite-raster',
          type: 'raster',
          source: 'satellite',
          paint: {
            'raster-fade-duration': 120,
            'raster-brightness-min': isDark ? 0.08 : 0,
            'raster-brightness-max': isDark ? 0.86 : 1,
            'raster-contrast': isDark ? 0.08 : 0.03,
          },
        },
        {
          // Stacked above the Sentinel-2 raster: sharp EA aerial where it exists,
          // transparent (Sentinel shows through) where the server returns 204.
          id: 'satellite-highres-raster',
          type: 'raster',
          source: 'satellite-highres',
          paint: {
            'raster-fade-duration': 120,
            'raster-brightness-min': isDark ? 0.08 : 0,
            'raster-brightness-max': isDark ? 0.86 : 1,
            'raster-contrast': isDark ? 0.08 : 0.03,
          },
        },
        ...modifiedLayers.filter(isSatelliteReferenceLayer).map(satelliteReferenceLayer),
      ],
    } as StyleSpecification;
  }

  return {
    version: 8,
    sprite: `${window.location.origin}/assets/sprites/${theme}`,
    glyphs: GLYPHS_URL,
    sources: {
      protomaps: {
        type: 'vector',
        tiles: [tileUrl],
        maxzoom: 15,
      },
    },
    layers: modifiedLayers,
  } as StyleSpecification;
}

// Oklab color space for perceptually uniform interpolation
function srgbToLinear(c: number): number {
  const v = c / 255;
  return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
}

function linearToSrgb(c: number): number {
  const v = c <= 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
  return Math.round(Math.max(0, Math.min(255, v * 255)));
}

function rgbToOklab(rgb: [number, number, number]): [number, number, number] {
  const r = srgbToLinear(rgb[0]);
  const g = srgbToLinear(rgb[1]);
  const b = srgbToLinear(rgb[2]);

  const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
  const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
  const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);

  return [
    0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
    1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
    0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
  ];
}

function oklabToRgb(lab: [number, number, number]): [number, number, number] {
  const L = lab[0],
    a = lab[1],
    b = lab[2];

  const l = Math.pow(L + 0.3963377774 * a + 0.2158037573 * b, 3);
  const m = Math.pow(L - 0.1055613458 * a - 0.0638541728 * b, 3);
  const s = Math.pow(L - 0.0894841775 * a - 1.291485548 * b, 3);

  return [
    linearToSrgb(+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
    linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
    linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
  ];
}

function interpolateGradient(t: number, gradient: GradientStop[]): [number, number, number] {
  if (t <= 0) return gradient[0].color;
  if (t >= 1) return gradient[gradient.length - 1].color;

  for (let i = 0; i < gradient.length - 1; i++) {
    const lo = gradient[i];
    const hi = gradient[i + 1];
    if (t >= lo.t && t <= hi.t) {
      const frac = (t - lo.t) / (hi.t - lo.t);
      const loLab = rgbToOklab(lo.color);
      const hiLab = rgbToOklab(hi.color);
      const interpLab: [number, number, number] = [
        loLab[0] + (hiLab[0] - loLab[0]) * frac,
        loLab[1] + (hiLab[1] - loLab[1]) * frac,
        loLab[2] + (hiLab[2] - loLab[2]) * frac,
      ];
      return oklabToRgb(interpLab);
    }
  }
  return gradient[gradient.length - 1].color;
}

function normalizedToColor(
  t: number,
  gradient: GradientStop[] = FEATURE_GRADIENT
): [number, number, number] {
  return interpolateGradient(t, gradient);
}

function countToColor(
  t: number,
  gradient: GradientStop[] = DENSITY_GRADIENT
): [number, number, number] {
  return interpolateGradient(t, gradient);
}

export function zoomToResolution(zoom: number): number {
  for (const { maxZoom, resolution } of ZOOM_TO_RESOLUTION_THRESHOLDS) {
    if (zoom < maxZoom) return resolution;
  }
  return ZOOM_TO_RESOLUTION_THRESHOLDS[ZOOM_TO_RESOLUTION_THRESHOLDS.length - 1].resolution;
}

export function getBoundsFromViewState(
  viewState: ViewState,
  width: number,
  height: number,
  bufferMultiplier: number = BUFFER_MULTIPLIER
): Bounds {
  const { longitude, latitude, zoom } = viewState;
  const clampedLat = clampLatitude(latitude);
  const scale = Math.pow(2, zoom);
  const worldSize = TILE_SIZE * scale;

  const bufferedWidth = width * bufferMultiplier;
  const bufferedHeight = height * bufferMultiplier;

  const degreesPerPixelLng = 360 / worldSize;
  const halfWidthDeg = (bufferedWidth / 2) * degreesPerPixelLng;

  const centerPixelY = latitudeToWorldY(clampedLat, worldSize);

  const topPixelY = centerPixelY - bufferedHeight / 2;
  const bottomPixelY = centerPixelY + bufferedHeight / 2;

  const north = Math.min(MAX_MERCATOR_LATITUDE, worldYToLatitude(topPixelY, worldSize));
  const south = Math.max(-MAX_MERCATOR_LATITUDE, worldYToLatitude(bottomPixelY, worldSize));
  const west = Math.max(-180, longitude - halfWidthDeg);
  const east = Math.min(180, longitude + halfWidthDeg);

  return { south, west, north, east };
}

export function getBoundsFromScreenRect(
  viewState: ViewState,
  width: number,
  height: number,
  rect: { left?: number; top?: number; right?: number; bottom?: number } = {}
): Bounds {
  const { longitude, latitude, zoom } = viewState;
  const worldSize = TILE_SIZE * Math.pow(2, zoom);
  const centerPixelX = longitudeToWorldX(longitude, worldSize);
  const centerPixelY = latitudeToWorldY(clampLatitude(latitude), worldSize);

  const left = Math.min(rect.left ?? 0, rect.right ?? width);
  const right = Math.max(rect.left ?? 0, rect.right ?? width);
  const top = Math.min(rect.top ?? 0, rect.bottom ?? height);
  const bottom = Math.max(rect.top ?? 0, rect.bottom ?? height);

  const longitudeAtX = (screenX: number) => {
    const worldX = centerPixelX + screenX - width / 2;
    const rawLongitude = (worldX / worldSize) * 360 - 180;
    return Math.max(-180, Math.min(180, rawLongitude));
  };
  const latitudeAtY = (screenY: number) => {
    const worldY = centerPixelY + screenY - height / 2;
    return Math.max(
      -MAX_MERCATOR_LATITUDE,
      Math.min(MAX_MERCATOR_LATITUDE, worldYToLatitude(worldY, worldSize))
    );
  };

  const west = longitudeAtX(left);
  const east = longitudeAtX(right);
  const topLatitude = latitudeAtY(top);
  const bottomLatitude = latitudeAtY(bottom);

  return {
    south: Math.min(topLatitude, bottomLatitude),
    west: Math.min(west, east),
    north: Math.max(topLatitude, bottomLatitude),
    east: Math.max(west, east),
  };
}

export function getVisibleBoundsFromViewState(
  viewState: ViewState,
  width: number,
  height: number,
  bottomScreenInset: number = 0
): Bounds {
  const visibleBottom = height - Math.max(0, Math.min(height, bottomScreenInset));
  return getBoundsFromScreenRect(viewState, width, height, { bottom: visibleBottom });
}

export function getLatitudeAtVerticalPixelOffset(
  latitude: number,
  zoom: number,
  pixelOffsetY: number
): number {
  const worldSize = TILE_SIZE * Math.pow(2, zoom);
  const pixelY = latitudeToWorldY(latitude, worldSize) + pixelOffsetY;
  return worldYToLatitude(pixelY, worldSize);
}

export function getBoundsWithBottomScreenInset(
  bounds: [number, number, number, number],
  zoom: number,
  bottomInsetPx: number
): [number, number, number, number] {
  if (bottomInsetPx <= 0) return bounds;

  const [west, south, east, north] = bounds;
  return [west, getLatitudeAtVerticalPixelOffset(south, zoom, bottomInsetPx), east, north];
}

export function emojiToTwemojiUrl(emoji: string): string {
  const codePoint = emoji.codePointAt(0);
  if (!codePoint) {
    throw new Error('Cannot build a Twemoji URL without an emoji');
  }
  const hex = codePoint.toString(16);
  return `${TWEMOJI_BASE}${hex}.png`;
}

function inferPoiIconCategory(category: string, name?: string): string | undefined {
  if (!name) return undefined;
  const text = `${category} ${name}`.toLowerCase();

  switch (category) {
    case 'Asda':
      if (text.includes('asda express') || text.includes(' express')) return 'Asda Express';
      if (text.includes('asda living')) return 'Asda Living';
      if (text.includes('asda pfs') || /\bpfs\b/.test(text)) return 'Asda PFS';
      return undefined;
    case 'Iceland':
      return text.includes('food warehouse') ? 'The Food Warehouse' : undefined;
    case 'M&S':
      if (text.includes('hospital')) return 'M&S Hospital';
      if (text.includes('moto')) return 'M&S MSA';
      if (text.includes('outlet')) return 'M&S Outlet';
      if (
        text.includes('foodhall') ||
        text.includes('simply food') ||
        text.includes('food to go') ||
        text.includes(' bp') ||
        /\bsf\b/.test(text)
      ) {
        return 'M&S Food';
      }
      if (text.includes('clothing')) return 'M&S Clothing';
      return undefined;
    case 'Morrisons':
      return text.includes('morrisons daily') || text.includes('morrisons dailly')
        ? 'Morrisons Daily'
        : undefined;
    case "Sainsbury's":
      return text.includes('local') ? "Sainsbury's Local" : undefined;
    case 'Tesco':
      if (text.includes('tesco extra')) return 'Tesco Extra';
      if (text.includes('tesco express') || text.includes(' express')) return 'Tesco Express';
      return undefined;
    case 'Waitrose':
      return text.includes('little waitrose') ? 'Little Waitrose' : undefined;
    default:
      return undefined;
  }
}

const GENERATED_POI_LOGO_COLORS: [string, string][] = [
  ['#0f766e', '#ccfbf1'],
  ['#1d4ed8', '#dbeafe'],
  ['#b45309', '#fef3c7'],
  ['#be123c', '#ffe4e6'],
  ['#6d28d9', '#ede9fe'],
  ['#047857', '#d1fae5'],
  ['#9d174d', '#fce7f3'],
  ['#334155', '#e2e8f0'],
];

const generatedPoiLogoCache = new Map<string, string>();

function hashLabel(label: string): number {
  let hash = 0;
  for (let i = 0; i < label.length; i += 1) {
    hash = (hash * 31 + label.charCodeAt(i)) >>> 0;
  }
  return hash;
}

function escapeSvgText(text: string): string {
  return text
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

function getPoiLogoInitials(label: string): string {
  const words = label.match(/[A-Za-z0-9]+/g) ?? [];
  const significantWords = words.filter(
    (word) => !['and', 'of', 'the'].includes(word.toLowerCase())
  );
  const selectedWords = significantWords.length > 0 ? significantWords : words;
  if (selectedWords.length === 0) return 'POI';
  if (selectedWords.length === 1) return selectedWords[0].slice(0, 3).toUpperCase();
  return selectedWords
    .slice(0, 3)
    .map((word) => word[0])
    .join('')
    .toUpperCase();
}

function getGeneratedPoiLogoUrl(label: string): string {
  const key = label.trim() || 'POI';
  const cached = generatedPoiLogoCache.get(key);
  if (cached) return cached;

  const [background, foreground] =
    GENERATED_POI_LOGO_COLORS[hashLabel(key) % GENERATED_POI_LOGO_COLORS.length];
  const initials = escapeSvgText(getPoiLogoInitials(key));
  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256"><rect width="256" height="256" rx="48" fill="${background}"/><text x="128" y="144" text-anchor="middle" font-family="Inter,Arial,sans-serif" font-size="82" font-weight="800" fill="${foreground}">${initials}</text></svg>`;
  const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  generatedPoiLogoCache.set(key, url);
  return url;
}

export function getPoiCategoryLogoUrl(
  category: string,
  iconCategory?: string,
  name?: string
): string {
  const resolvedIconCategory = iconCategory || inferPoiIconCategory(category, name);
  if (resolvedIconCategory && POI_CATEGORY_LOGOS[resolvedIconCategory]) {
    return POI_CATEGORY_LOGOS[resolvedIconCategory];
  }
  return POI_CATEGORY_LOGOS[category] ?? getGeneratedPoiLogoUrl(resolvedIconCategory || category);
}

export function getPoiIconUrl(
  category: string,
  _emoji: string,
  iconCategory?: string,
  name?: string
): string {
  return getPoiCategoryLogoUrl(category, iconCategory, name);
}

/** Look up a discrete color from the enum palette by index (wraps if > palette size). */
export function enumIndexToColor(
  index: number,
  palette: [number, number, number][]
): [number, number, number] {
  const i = Math.round(Math.max(0, index)) % palette.length;
  return palette[i];
}

/**
 * Shared fill-color logic for hex and postcode layers.
 * When a viewFeature is active, normalizes by colorRange and applies the feature gradient.
 * For enum features (enumCount > 0), uses discrete palette colors instead of gradient.
 * Otherwise falls back to density-based coloring using countRange.
 */
export function getFeatureFillColor(
  value: number | null | undefined,
  minVal: number | undefined,
  maxVal: number | undefined,
  colorRange: [number, number] | null,
  filterRange: [number, number] | null,
  countNormalized: number,
  densityGradient: GradientStop[],
  isDark: boolean,
  alpha: number,
  enumCount: number = 0,
  enumPalette?: [number, number, number][] | null,
  featureGradient: GradientStop[] = FEATURE_GRADIENT
): [number, number, number, number] {
  if (colorRange) {
    if (value == null)
      return (isDark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
    if (filterRange) {
      if ((maxVal as number) < filterRange[0] || (minVal as number) > filterRange[1]) {
        return (isDark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [
          number,
          number,
          number,
          number,
        ];
      }
    }

    // Discrete coloring for enum features (used as base; PieHexExtension overrides when active)
    if (enumCount > 0) {
      if (!enumPalette) {
        throw new Error('Enum feature fill requested without an enum color palette');
      }
      const rgb = enumIndexToColor(Math.round(value as number), enumPalette);
      return [...rgb, alpha] as [number, number, number, number];
    }

    const range = colorRange[1] - colorRange[0];
    if (range === 0)
      return [...featureGradient[0].color, alpha] as [number, number, number, number];
    const t = ((value as number) - colorRange[0]) / range;
    const rgb = normalizedToColor(Math.max(0, Math.min(1, t)), featureGradient);
    return [...rgb, alpha] as [number, number, number, number];
  }
  return [...countToColor(Math.max(0, Math.min(1, countNormalized)), densityGradient), alpha] as [
    number,
    number,
    number,
    number,
  ];
}
