import { consume } from "@lit/context";
import { isToday } from "date-fns";
import type { HassConfig, HassEntities } from "home-assistant-js-websocket";
import type {
  Circle,
  CircleMarker,
  LatLngExpression,
  LatLngTuple,
  Layer,
  Map,
  Marker,
  MarkerClusterGroup,
  Polyline,
} from "leaflet";
import type { PropertyValues } from "lit";
import { css, ReactiveElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { formatDateTime } from "../../common/datetime/format_date_time";
import {
  formatTimeWeekday,
  formatTimeWithSeconds,
} from "../../common/datetime/format_time";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import type { LeafletModuleType } from "../../common/dom/setup-leaflet-map";
import { setupLeafletMap } from "../../common/dom/setup-leaflet-map";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { getEntityLocation } from "../../common/entity/get_entity_location";
import { DecoratedMarker } from "../../common/map/decorated_marker";
import { filterXSS } from "../../common/util/xss";
import {
  configContext,
  connectionContext,
  formattersContext,
  internationalizationContext,
  statesContext,
  uiContext,
} from "../../data/context";
import type {
  HomeAssistantConfig,
  HomeAssistantConnection,
  HomeAssistantFormatters,
  HomeAssistantInternationalization,
  HomeAssistantUI,
  ThemeMode,
} from "../../types";
import { isTouch } from "../../util/is_touch";
import "../ha-icon-button";
import "./ha-entity-marker";

declare global {
  // for fire event
  interface HASSDomEvents {
    "map-clicked": { location: [number, number] };
  }
}

const PROGRAMMITIC_FIT_DELAY = 250;

const getEntityId = (entity: string | HaMapEntity): string =>
  typeof entity === "string" ? entity : entity.entity_id;

export interface HaMapPathPoint {
  point: LatLngTuple;
  timestamp: Date;
}
export interface HaMapPaths {
  points: HaMapPathPoint[];
  color?: string;
  name?: string;
  gradualOpacity?: number;
  fullDatetime?: boolean;
}

export const MAP_CARD_MARKER_LABEL_MODES = [
  "name",
  "state",
  "attribute",
  "icon",
] as const;
export type MapCardMarkerLabelMode =
  (typeof MAP_CARD_MARKER_LABEL_MODES)[number];

export interface HaMapEntity {
  entity_id: string;
  color: string;
  label_mode?: MapCardMarkerLabelMode;
  attribute?: string;
  unit?: string;
  name?: string;
  focus?: boolean;
}

@customElement("ha-map")
export class HaMap extends ReactiveElement {
  @state()
  @consume({ context: statesContext, subscribe: true })
  private _states!: HassEntities;

  @state()
  @consume({ context: configContext, subscribe: true })
  @transform<HomeAssistantConfig, HassConfig>({
    transformer: ({ config }) => config,
  })
  private _config!: HassConfig;

  @state()
  @consume({ context: uiContext, subscribe: true })
  private _ui!: HomeAssistantUI;

  @state()
  @consume({ context: internationalizationContext, subscribe: true })
  private _i18n!: HomeAssistantInternationalization;

  @state()
  @consume({ context: formattersContext, subscribe: true })
  private _formatters!: HomeAssistantFormatters;

  @state()
  @consume({ context: connectionContext, subscribe: true })
  private _connection!: HomeAssistantConnection;

  @property({ attribute: false }) public entities?: string[] | HaMapEntity[];

  @property({ attribute: false }) public paths?: HaMapPaths[];

  @property({ attribute: false }) public layers?: Layer[];

  @property({ type: Boolean }) public clickable = false;

  @property({ attribute: "auto-fit", type: Boolean }) public autoFit = false;

  @property({ attribute: "render-passive", type: Boolean })
  public renderPassive = false;

  @property({ attribute: "interactive-zones", type: Boolean })
  public interactiveZones = false;

  @property({ attribute: "fit-zones", type: Boolean }) public fitZones = false;

  @property({ attribute: "theme-mode", type: String })
  public themeMode: ThemeMode = "auto";

  @property({ type: Number }) public zoom = 14;

  @property({ attribute: "cluster-markers", type: Boolean })
  public clusterMarkers = true;

  @state() private _loaded = false;

  @query("#map") private _mapElement?: HTMLElement;

  public leafletMap?: Map;

  private Leaflet?: LeafletModuleType;

  private _resizeObserver?: ResizeObserver;

  private _mapItems: (Marker | Circle)[] = [];

  private _mapFocusItems: (Marker | Circle)[] = [];

  private _mapZones: DecoratedMarker[] = [];

  private _mapFocusZones: (Marker | Circle)[] = [];

  private _mapCluster: MarkerClusterGroup | undefined;

  private _mapPaths: (Polyline | CircleMarker)[] = [];

  private _clickCount = 0;

  private _isProgrammaticFit = false;

  private _pauseAutoFit = false;

  public connectedCallback(): void {
    this._pauseAutoFit = false;
    document.addEventListener("visibilitychange", this._handleVisibilityChange);
    this._handleVisibilityChange();
    super.connectedCallback();
    this._loadMap();
    this._attachObserver();
  }

  private _handleVisibilityChange = async () => {
    if (!document.hidden) {
      setTimeout(() => {
        this._pauseAutoFit = false;
      }, 500);
    }
  };

  public disconnectedCallback(): void {
    super.disconnectedCallback();
    document.removeEventListener(
      "visibilitychange",
      this._handleVisibilityChange
    );
    if (this.leafletMap) {
      this.leafletMap.remove();
      this.leafletMap = undefined;
      this.Leaflet = undefined;
    }

    this._loaded = false;

    if (this._resizeObserver) {
      this._resizeObserver.unobserve(this);
    }
  }

  protected update(changedProps: PropertyValues) {
    super.update(changedProps);

    if (!this._loaded) {
      return;
    }
    let autoFitRequired = false;
    const oldStates = changedProps.get("_states") as HassEntities | undefined;

    if (changedProps.has("_loaded") || changedProps.has("entities")) {
      this._drawEntities();
      autoFitRequired = !this._pauseAutoFit;
    } else if (this._loaded && oldStates && this.entities) {
      // Check if any state has changed
      for (const entity of this.entities) {
        if (
          oldStates[getEntityId(entity)] !== this._states[getEntityId(entity)]
        ) {
          this._drawEntities();
          autoFitRequired = !this._pauseAutoFit;
          break;
        }
      }
    }

    if (changedProps.has("clusterMarkers")) {
      this._drawEntities();
    }

    if (changedProps.has("_loaded") || changedProps.has("paths")) {
      this._drawPaths();
    }

    if (changedProps.has("_loaded") || changedProps.has("layers")) {
      this._drawLayers(changedProps.get("layers") as Layer[] | undefined);
      autoFitRequired = true;
    }

    if (changedProps.has("_loaded") || (this.autoFit && autoFitRequired)) {
      this.fitMap();
    }

    if (changedProps.has("zoom")) {
      this._isProgrammaticFit = true;
      this.leafletMap!.setZoom(this.zoom);
      setTimeout(() => {
        this._isProgrammaticFit = false;
      }, PROGRAMMITIC_FIT_DELAY);
    }

    const oldUi = changedProps.get("_ui") as HomeAssistantUI | undefined;
    if (
      !changedProps.has("themeMode") &&
      (!changedProps.has("_ui") ||
        (oldUi && oldUi.themes?.darkMode === this._ui.themes?.darkMode))
    ) {
      return;
    }

    this._updateMapStyle();
  }

  private get _darkMode() {
    return (
      this.themeMode === "dark" ||
      (this.themeMode === "auto" && Boolean(this._ui?.themes.darkMode))
    );
  }

  private _updateMapStyle(): void {
    const map = this._mapElement!;
    map.classList.toggle("clickable", this.clickable);
    map.classList.toggle("dark", this._darkMode);
    map.classList.toggle("forced-dark", this.themeMode === "dark");
    map.classList.toggle("forced-light", this.themeMode === "light");
  }

  private _loading = false;

  private async _loadMap(): Promise<void> {
    if (this._loading) return;
    let map = this.shadowRoot!.getElementById("map");
    if (!map) {
      map = document.createElement("div");
      map.id = "map";
      this.shadowRoot!.append(map);
    }
    this._loading = true;
    try {
      [this.leafletMap, this.Leaflet] = await setupLeafletMap(map, {
        latitude: this._config?.latitude ?? 52.3731339,
        longitude: this._config?.longitude ?? 4.8903147,
        zoom: this.zoom,
      });
      this._updateMapStyle();
      this.leafletMap.on("click", (ev) => {
        if (this._clickCount === 0) {
          setTimeout(() => {
            if (this._clickCount === 1) {
              fireEvent(this, "map-clicked", {
                location: [ev.latlng.lat, ev.latlng.lng],
              });
            }
            this._clickCount = 0;
          }, 250);
        }
        this._clickCount++;
      });
      this.leafletMap.on("zoomstart", () => {
        if (!this._isProgrammaticFit) {
          this._pauseAutoFit = true;
        }
      });
      this.leafletMap.on("movestart", () => {
        if (!this._isProgrammaticFit) {
          this._pauseAutoFit = true;
        }
      });
      this._loaded = true;
    } finally {
      this._loading = false;
    }
  }

  public fitMap(options?: {
    zoom?: number;
    pad?: number;
    unpause_autofit?: boolean;
  }): void {
    if (options?.unpause_autofit) {
      this._pauseAutoFit = false;
    }
    if (!this.leafletMap || !this.Leaflet || !this._config) {
      return;
    }

    if (
      !this._mapFocusItems.length &&
      !this._mapFocusZones.length &&
      !this.layers?.length
    ) {
      this._isProgrammaticFit = true;
      this.leafletMap.setView(
        new this.Leaflet.LatLng(this._config.latitude, this._config.longitude),
        options?.zoom || this.zoom
      );
      setTimeout(() => {
        this._isProgrammaticFit = false;
      }, PROGRAMMITIC_FIT_DELAY);
      return;
    }

    let bounds = this.Leaflet.latLngBounds(
      this._mapFocusItems
        ? this._mapFocusItems.map((item) => item.getLatLng())
        : []
    );

    this._mapFocusZones?.forEach((zone) => {
      bounds.extend("getBounds" in zone ? zone.getBounds() : zone.getLatLng());
    });

    this.layers?.forEach((layer: any) => {
      bounds.extend(
        "getBounds" in layer ? layer.getBounds() : layer.getLatLng()
      );
    });

    bounds = bounds.pad(options?.pad ?? 0.5);
    this._isProgrammaticFit = true;
    this.leafletMap.fitBounds(bounds, { maxZoom: options?.zoom || this.zoom });
    setTimeout(() => {
      this._isProgrammaticFit = false;
    }, PROGRAMMITIC_FIT_DELAY);
  }

  public fitBounds(
    boundingbox: LatLngExpression[],
    options?: { zoom?: number; pad?: number }
  ) {
    if (!this.leafletMap || !this.Leaflet) {
      return;
    }
    const bounds = this.Leaflet.latLngBounds(boundingbox).pad(
      options?.pad ?? 0.5
    );
    this._isProgrammaticFit = true;
    this.leafletMap.fitBounds(bounds, { maxZoom: options?.zoom || this.zoom });
    setTimeout(() => {
      this._isProgrammaticFit = false;
    }, PROGRAMMITIC_FIT_DELAY);
  }

  private _drawLayers(prevLayers: Layer[] | undefined): void {
    if (prevLayers) {
      prevLayers.forEach((layer) => layer.remove());
    }
    if (!this.layers) {
      return;
    }
    const map = this.leafletMap!;
    this.layers.forEach((layer) => {
      map.addLayer(layer);
    });
  }

  private _computePathTooltip(path: HaMapPaths, point: HaMapPathPoint): string {
    let formattedTime: string;
    if (path.fullDatetime) {
      formattedTime = formatDateTime(
        point.timestamp,
        this._i18n.locale,
        this._config
      );
    } else if (isToday(point.timestamp)) {
      formattedTime = formatTimeWithSeconds(
        point.timestamp,
        this._i18n.locale,
        this._config
      );
    } else {
      formattedTime = formatTimeWeekday(
        point.timestamp,
        this._i18n.locale,
        this._config
      );
    }
    return `${filterXSS(path.name ?? "")}<br>${formattedTime}`;
  }

  private _drawPaths(): void {
    const map = this.leafletMap;
    // eslint-disable-next-line @typescript-eslint/naming-convention
    const Leaflet = this.Leaflet;

    if (!this._i18n || !this._config || !map || !Leaflet) {
      return;
    }
    if (this._mapPaths.length) {
      this._mapPaths.forEach((marker) => marker.remove());
      this._mapPaths = [];
    }
    if (!this.paths) {
      return;
    }

    const darkPrimaryColor = getComputedStyle(this).getPropertyValue(
      "--dark-primary-color"
    );

    this.paths.forEach((path) => {
      let opacityStep: number;
      let baseOpacity: number;
      if (path.gradualOpacity) {
        opacityStep = path.gradualOpacity / (path.points.length - 2);
        baseOpacity = 1 - path.gradualOpacity;
      }

      for (
        let pointIndex = 0;
        pointIndex < path.points.length - 1;
        pointIndex++
      ) {
        const opacity = path.gradualOpacity
          ? baseOpacity! + pointIndex * opacityStep!
          : undefined;

        const thisPoint = path.points[pointIndex];
        const nextPoint = path.points[pointIndex + 1];

        // DRAW point
        this._mapPaths.push(
          Leaflet.circleMarker(thisPoint.point, {
            radius: isTouch ? 8 : 3,
            color: path.color || darkPrimaryColor,
            opacity,
            fillOpacity: opacity,
            interactive: true,
          }).bindTooltip(this._computePathTooltip(path, thisPoint), {
            direction: "top",
          })
        );

        // DRAW line between this and next point
        if (Math.abs(thisPoint.point[1] - nextPoint.point[1]) <= 180) {
          // if the path does not cross the antimeridian, draw a simple line
          // between the two points
          this._mapPaths.push(
            Leaflet.polyline([thisPoint.point, nextPoint.point], {
              color: path.color || darkPrimaryColor,
              opacity,
              interactive: false,
            })
          );
        } else {
          // if the path crosses the antimeridian, split the line into two, to
          // avoid it being drawn across the entire map
          const longitudeDifference =
            ((nextPoint.point[1] - thisPoint.point[1] + 540) % 360) - 180;
          let intersectionLatitude: number;
          if (longitudeDifference === 0) {
            // very, very unlikely edge case
            intersectionLatitude =
              (thisPoint.point[0] + nextPoint.point[0]) / 2;
          } else {
            intersectionLatitude =
              thisPoint.point[0] +
              ((nextPoint.point[0] - thisPoint.point[0]) *
                (thisPoint.point[1] > 0
                  ? 180 - thisPoint.point[1]
                  : -180 - thisPoint.point[1])) /
                longitudeDifference;
          }

          const intersectionPoint1: LatLngTuple = [
            intersectionLatitude,
            thisPoint.point[1] > 0 ? 180 : -180,
          ];
          const intersectionPoint2: LatLngTuple = [
            intersectionLatitude,
            nextPoint.point[1] > 0 ? 180 : -180,
          ];

          this._mapPaths.push(
            Leaflet.polyline([thisPoint.point, intersectionPoint1], {
              color: path.color || darkPrimaryColor,
              opacity,
              interactive: false,
            })
          );
          this._mapPaths.push(
            Leaflet.polyline([intersectionPoint2, nextPoint.point], {
              color: path.color || darkPrimaryColor,
              opacity,
              interactive: false,
            })
          );
        }
      }
      const pointIndex = path.points.length - 1;
      if (pointIndex >= 0) {
        const opacity = path.gradualOpacity
          ? baseOpacity! + pointIndex * opacityStep!
          : undefined;
        // DRAW end path point
        this._mapPaths.push(
          Leaflet.circleMarker(path.points[pointIndex].point, {
            radius: isTouch ? 8 : 3,
            color: path.color || darkPrimaryColor,
            opacity,
            fillOpacity: opacity,
            interactive: true,
          }).bindTooltip(
            this._computePathTooltip(path, path.points[pointIndex]),
            { direction: "top" }
          )
        );
      }
      this._mapPaths.forEach((marker) => map.addLayer(marker));
    });
  }

  private _drawEntities(): void {
    const states = this._states;
    const map = this.leafletMap;
    // eslint-disable-next-line @typescript-eslint/naming-convention
    const Leaflet = this.Leaflet;

    if (!states || !map || !Leaflet) {
      return;
    }

    if (this._mapItems.length) {
      this._mapItems.forEach((marker) => marker.remove());
      this._mapItems = [];
      this._mapFocusItems = [];
    }

    if (this._mapZones.length) {
      this._mapZones.forEach((marker) => marker.remove());
      this._mapZones = [];
      this._mapFocusZones = [];
    }

    if (this._mapCluster) {
      this._mapCluster.remove();
      this._mapCluster = undefined;
    }

    if (!this.entities) {
      return;
    }

    const computedStyles = getComputedStyle(this);
    const zoneColor = computedStyles.getPropertyValue("--accent-color");
    const passiveZoneColor = computedStyles.getPropertyValue(
      "--secondary-text-color"
    );

    const darkPrimaryColor = computedStyles.getPropertyValue(
      "--dark-primary-color"
    );

    const className = this._darkMode ? "dark" : "light";

    for (const entity of this.entities) {
      const stateObj = states[getEntityId(entity)];
      if (!stateObj) {
        continue;
      }
      const customTitle = typeof entity !== "string" ? entity.name : undefined;
      const title = customTitle ?? computeStateName(stateObj);
      const {
        passive,
        icon,
        radius,
        entity_picture: entityPicture,
      } = stateObj.attributes;

      const location = getEntityLocation(stateObj, states);
      if (!location) {
        continue;
      }
      const { latitude, longitude, gpsAccuracy } = location;

      if (computeStateDomain(stateObj) === "zone") {
        // DRAW ZONE
        if (passive && !this.renderPassive) {
          continue;
        }

        // create icon
        let iconHTML: string;
        if (icon) {
          const el = document.createElement("ha-icon");
          el.setAttribute("icon", icon);
          iconHTML = el.outerHTML;
        } else {
          const el = document.createElement("span");
          el.textContent = title;
          iconHTML = el.outerHTML;
        }

        // create circle around it
        const circle = Leaflet.circle([latitude, longitude], {
          interactive: false,
          color: passive ? passiveZoneColor : zoneColor,
          radius,
        });

        const markerIconSize = this._getMarkerSize(computedStyles) / 2;
        const marker = new DecoratedMarker([latitude, longitude], circle, {
          icon: Leaflet.divIcon({
            html: iconHTML,
            iconSize: [markerIconSize, markerIconSize],
            className,
          }),
          interactive: this.interactiveZones,
          title,
        });

        this._mapZones.push(marker);
        if (
          this.fitZones &&
          (typeof entity === "string" || entity.focus !== false)
        ) {
          this._mapFocusZones.push(circle);
        }

        continue;
      }

      // DRAW ENTITY
      // create icon
      const entityName =
        typeof entity !== "string" && entity.label_mode === "state"
          ? this._formatters.formatEntityState(stateObj)
          : typeof entity !== "string" &&
              entity.label_mode === "attribute" &&
              entity.attribute !== undefined
            ? this._formatters.formatEntityAttributeValue(
                stateObj,
                entity.attribute
              )
            : (customTitle ??
              title
                .split(" ")
                .map((part) => part[0])
                .join("")
                .substr(0, 3));

      const entityMarker = document.createElement("ha-entity-marker");
      entityMarker.showIcon =
        typeof entity !== "string" && entity.label_mode === "icon";
      entityMarker.entityId = getEntityId(entity);
      entityMarker.entityName = entityName;
      entityMarker.entityUnit =
        typeof entity !== "string" &&
        entity.unit &&
        entity.label_mode === "attribute"
          ? entity.unit
          : "";
      entityMarker.entityPicture =
        entityPicture && (typeof entity === "string" || !entity.label_mode)
          ? this._connection.hassUrl(entityPicture)
          : "";
      if (typeof entity !== "string") {
        entityMarker.entityColor = entity.color;
      }

      // create marker with the icon
      const markerSize = this._getMarkerSize(computedStyles);
      const marker = new DecoratedMarker([latitude, longitude], undefined, {
        icon: Leaflet.divIcon({
          html: entityMarker,
          iconSize: [markerSize, markerSize],
          className: "",
        }),
        title: title,
      });
      if (typeof entity === "string" || entity.focus !== false) {
        this._mapFocusItems.push(marker);
      }

      // create circle around if entity has accuracy
      if (gpsAccuracy) {
        marker.decorationLayer = Leaflet.circle([latitude, longitude], {
          interactive: false,
          color: darkPrimaryColor,
          radius: gpsAccuracy,
        });
      }

      this._mapItems.push(marker);
    }

    if (this.clusterMarkers) {
      this._mapCluster = Leaflet.markerClusterGroup({
        showCoverageOnHover: false,
        removeOutsideVisibleBounds: false,
        maxClusterRadius: 40,
      });
      this._mapCluster.addLayers(this._mapItems);
      map.addLayer(this._mapCluster);
    } else {
      this._mapItems.forEach((marker) => map.addLayer(marker));
    }

    this._mapZones.forEach((marker) => map.addLayer(marker));
  }

  private _getMarkerSize(computedStyles: CSSStyleDeclaration): number {
    const markerSizeVarValue =
      computedStyles.getPropertyValue("--ha-marker-size");
    const parsed = parseFloat(markerSizeVarValue);
    return Number.isNaN(parsed) ? 48 : parsed;
  }

  private async _attachObserver(): Promise<void> {
    if (!this._resizeObserver) {
      this._resizeObserver = new ResizeObserver(() => {
        this.leafletMap?.invalidateSize({ debounceMoveend: true });
      });
    }
    this._resizeObserver.observe(this);
  }

  static styles = css`
    :host {
      display: block;
      height: 300px;
    }
    #map {
      height: 100%;
    }
    #map.clickable {
      cursor: pointer;
    }
    #map.dark {
      background: #090909;
    }
    #map.forced-dark {
      color: #ffffff;
      --map-filter: invert(0.9) hue-rotate(170deg) brightness(1.5) contrast(1.2)
        saturate(0.3);
    }
    #map.forced-light {
      background: #ffffff;
      color: #000000;
      --map-filter: invert(0);
    }
    #map.clickable:active,
    #map:active {
      cursor: grabbing;
      cursor: -moz-grabbing;
      cursor: -webkit-grabbing;
    }
    .leaflet-tile-pane {
      filter: var(--map-filter);
    }
    .dark .leaflet-bar a {
      background-color: #1c1c1c;
      color: #ffffff;
    }
    .dark .leaflet-bar a:hover {
      background-color: #313131;
    }
    .leaflet-marker-draggable {
      cursor: move !important;
    }
    .leaflet-edit-resize {
      border-radius: var(--ha-border-radius-circle);
      cursor: nesw-resize !important;
    }
    .named-icon {
      display: flex;
      align-items: center;
      justify-content: center;
      flex-direction: column;
      text-align: center;
      color: var(--primary-text-color);
    }
    .leaflet-pane {
      z-index: 0 !important;
    }
    .leaflet-control,
    .leaflet-top,
    .leaflet-bottom {
      z-index: 1 !important;
    }
    .leaflet-tooltip {
      padding: 8px;
      font-size: var(--ha-font-size-s);
      background: rgba(80, 80, 80, 0.9) !important;
      color: white !important;
      border-radius: var(--ha-border-radius-sm);
      box-shadow: none !important;
      text-align: center;
    }

    ha-icon {
      --mdc-icon-size: calc(var(--ha-marker-size, 48px) / 2);
    }

    .marker-cluster div {
      background-clip: padding-box;
      background-color: var(--primary-color);
      border: 3px solid rgba(var(--rgb-primary-color), 0.2);
      width: calc(var(--ha-marker-size, 48px) * 0.667);
      height: calc(var(--ha-marker-size, 48px) * 0.667);
      border-radius: 50%;
      text-align: center;
      align-content: center;
      color: var(--text-primary-color);
      font-size: var(--ha-font-size-m);
    }

    .marker-cluster span {
      line-height: var(--ha-line-height-expanded);
    }
  `;
}

declare global {
  interface HTMLElementTagNameMap {
    "ha-map": HaMap;
  }
}
