import {
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  ElementRef,
  EventEmitter,
  inject,
  Input,
  NgZone,
  OnDestroy,
  OnInit,
  Output,
  signal,
  SimpleChanges
} from '@angular/core';
import { Map, MapBrowserEvent } from 'ol';
import { Modify } from 'ol/interaction';
import MapEvent from 'ol/MapEvent';
import ObjectEvent from 'ol/Object';
import RenderEvent from 'ol/render/Event';
import { MapService } from './map.service';
import { MapReadyEvent } from './models';
import { AsyncSubject } from 'rxjs';
import { toLonLat, transformExtent } from 'ol/proj';
import { Coordinate } from 'ol/coordinate';
import { FeatureLike } from 'ol/Feature';
import { Extent, getWidth } from 'ol/extent';
import { hitToleranceForPointer, worldCopyOffset } from './util';
import {
  clearHeldVertexOnRelease,
  clearVertexDeleted,
  clearVertexDeletedOnDrag,
  hasGrabbedVertex,
  isTouchContextMenuWhileEditing,
  startPointerGesture,
  tryDeleteHeldVertexOnHold
} from './vertex-delete';
import {
  completeVertexDeleteIndicator,
  hideVertexDeleteIndicator,
  showVertexDeleteIndicator
} from './vertex-delete-indicator';

export interface FBMapEvent extends MapEvent {
  lonlat: Coordinate;
  zoom: number;
  zoomChanged: boolean;
  extent: Extent;
  projCode: string;
  /** Geographic coords of the viewport's top-centre pixel (rotation-aware) */
  topCenter: Coordinate;
  /** Geographic coords of the viewport's right-centre pixel (rotation-aware) */
  rightCenter: Coordinate;
  /** OL view rotation in radians (CCW positive) at the time of the event */
  rotation: number;
}

export interface FBClickEvent extends MapBrowserEvent<PointerEvent> {
  features: Array<FeatureLike>;
  lonlat: Coordinate;
  /** EPSG:3857-metre offset of the clicked world copy from the primary world. */
  worldOffset: number;
}

export interface FBPointerEvent extends MapBrowserEvent<PointerEvent> {
  lonlat: Coordinate;
  /** EPSG:3857-metre offset of the pointer's world copy from the primary world. */
  worldOffset: number;
}

// used for wind vector scaling
export const zoomOffsetLevel = [
  1, 1000000, 550000, 290000, 140000, 70000, 38000, 17000, 7600, 3900, 1900,
  950, 470, 250, 120, 60, 30, 15.5, 8.1, 4, 2, 1, 0.5, 0.25, 0.12, 0.06, 0.03,
  0.015, 0.008, 1
];

@Component({
  selector: 'ol-map',
  styleUrls: ['./map.component.scss'],
  template: `
    <div
      style="width: 100%; height: 100%; margin: 0; padding: 0; touch-action: none;"
      tabindex="-1"
    ></div>
    <ng-content></ng-content>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  standalone: false
})
export class MapComponent implements OnInit, OnDestroy {
  private map: Map;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  private timeoutId: any;

  /**
   * This event is triggered after the map is initialized
   * Use this to have access to the maps and some helper functions
   */
  @Output() mapReady: AsyncSubject<MapReadyEvent> = new AsyncSubject(); // AsyncSubject will only store the last value, and only publish it when the sequence is completed

  /**
   * This event is triggered after the user clicks on the map.
   * A true single click with no dragging and no double click.
   * Note that this event is delayed by 250 ms to ensure that it is not a double click.
   */
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  @Output() mapClick: EventEmitter<any> = new EventEmitter<any>();
  @Output() mapRightClick: EventEmitter<{
    features: FeatureLike[];
    lonlat: Coordinate;
    worldOffset: number;
  }> = new EventEmitter<{
    features: FeatureLike[];
    lonlat: Coordinate;
    worldOffset: number;
  }>();
  @Output() mapContextMenu: EventEmitter<FBPointerEvent> =
    new EventEmitter<FBPointerEvent>();
  @Output() mapSingleClick: EventEmitter<FBClickEvent> =
    new EventEmitter<FBClickEvent>();
  @Output() mapDblClick: EventEmitter<FBClickEvent> =
    new EventEmitter<FBClickEvent>();
  @Output() mapMoveStart: EventEmitter<MapEvent> =
    new EventEmitter<FBMapEvent>();
  @Output() mapMoveEnd: EventEmitter<MapEvent> = new EventEmitter<FBMapEvent>();
  @Output() mapPointerDrag: EventEmitter<FBPointerEvent> =
    new EventEmitter<FBPointerEvent>();
  @Output() mapPointerMove: EventEmitter<FBPointerEvent> =
    new EventEmitter<FBPointerEvent>();
  @Output() mapPointerDown: EventEmitter<FBPointerEvent> =
    new EventEmitter<FBPointerEvent>();
  @Output() mapPostCompose: EventEmitter<RenderEvent> =
    new EventEmitter<RenderEvent>();
  @Output() mapPostRender: EventEmitter<MapEvent> =
    new EventEmitter<MapEvent>();
  @Output() mapPreCompose: EventEmitter<RenderEvent> =
    new EventEmitter<RenderEvent>();
  @Output() mapPropertyChange: EventEmitter<ObjectEvent> =
    new EventEmitter<ObjectEvent>();

  @Input() pixelRatio: number;
  @Input() keyboardEventTarget: Element | string;
  @Input() logo: string | boolean;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  @Input() properties: { [index: string]: any };
  @Input() setFocus = '';
  @Input() hitTolerance = 5;
  // Touch pointers get a larger radius than the mouse so a deliberate tap on a
  // thin route line lands in the common case (#548).
  @Input() touchHitTolerance = 15;

  private _pointerDown = signal<MouseEvent>(undefined);
  public pointerDownSignal = this._pointerDown.asReadonly();

  protected changeDetectorRef = inject(ChangeDetectorRef);
  protected element = inject(ElementRef);
  protected mapService = inject(MapService);
  private ngZone = inject(NgZone);

  constructor() {
    this.changeDetectorRef.detach();
  }

  ngOnInit() {
    const target = this.element.nativeElement.firstElementChild;
    // Create the OL map and register its event handlers OUTSIDE the Angular
    // zone. zone.js patches requestAnimationFrame, so leaving OL's render loop
    // inside the zone forces a full app-wide change detection pass on every
    // render frame (pan/zoom/animation) and every pointer move. Running it
    // outside the zone eliminates that; the specific handlers that must update
    // Angular state re-enter the zone via ngZone.run() (see below).
    this.ngZone.runOutsideAngular(() => {
      this.map = new Map();
      this.map.setTarget(target);
      this.map.setProperties(this.properties, true);
      // register the map in the injectable mapService
      this.mapService.addMap(this.map);

      this.map.once('postrender', () => {
        this.afterMapReady();
      });
    });
  }

  ngOnDestroy() {
    if (!this.map) {
      return;
    }
    // Cancel any pending long-press timer so touchHold cannot fire after the
    // map is torn down (it dereferences this.map).
    this.clearTouchTimer();
    this.map.un('singleclick', this.emitSingleClickEvent);
    this.map.un('dblclick', this.emitDblClickEvent);
    this.map.un('click', this.emitClickEvent);
    this.map.un('movestart', this.emitMoveStartEvent);
    this.map.un('moveend', this.emitMoveEndEvent);
    this.map.un('pointerdrag', this.emitPointerDragEvent);
    this.map.un('pointermove', this.emitPointerMoveEvent);
    this.map.un('postcompose', this.emitPostComposeEvent);
    this.map.un('postrender', this.emitPostRenderEvent);
    this.map.un('precompose', this.emitPreComposeEvent);
    // right click handler
    this.map
      .getViewport()
      .removeEventListener('contextmenu', this.rightClickHandler);
    this.map
      .getViewport()
      .removeEventListener('pointerdown', this.pointerDownHandler);
    this.map
      .getViewport()
      .removeEventListener('pointerup', this.pointerUpHandler);
    window.removeEventListener('resize', this.updateSizeThrottle);
    window.removeEventListener('orientationchange', this.updateSizeThrottle);

    this.map.setTarget(null);
    this.map = null;
  }

  ngOnChanges(changes: SimpleChanges) {
    if (this.map && typeof changes.setFocus !== 'undefined') {
      if (changes.setFocus.currentValue) {
        this.focusMap();
      }
    }
  }

  afterMapReady() {
    // register map events
    this.map.on('singleclick', this.emitSingleClickEvent);
    this.map.on('dblclick', this.emitDblClickEvent);
    this.map.on('click', this.emitClickEvent);
    this.map.on('movestart', this.emitMoveStartEvent);
    this.map.on('moveend', this.emitMoveEndEvent);
    this.map.on('pointerdrag', this.emitPointerDragEvent);
    this.map.on('pointermove', this.emitPointerMoveEvent);
    this.map.on('postcompose', this.emitPostComposeEvent);
    this.map.on('postrender', this.emitPostRenderEvent);
    this.map.on('precompose', this.emitPreComposeEvent);

    // right click handler
    this.map
      .getViewport()
      .addEventListener('contextmenu', this.rightClickHandler);
    this.map
      .getViewport()
      .addEventListener('pointerdown', this.pointerDownHandler);
    this.map.getViewport().addEventListener('pointerup', this.pointerUpHandler);

    // react on window events
    window.addEventListener('resize', this.updateSizeThrottle, false);
    window.addEventListener(
      'orientationchange',
      this.updateSizeThrottle,
      false
    );

    this.updateSize();

    this.mapReady.next({ map: this.map, mapService: this.mapService });
    this.mapReady.complete();

    if (this.setFocus) {
      this.focusMap();
    }
  }

  // Only arrow function works with addEventListener

  // Long Press Detection (iOS & Android)
  // A deliberate hold deletes a grabbed vertex; a shorter hold in open water
  // opens the chart context menu.
  private readonly MODIFY_HOLD_MS = 1500;
  private readonly MENU_HOLD_MS = 500;
  // The delete-progress indicator is shown from the native long-press
  // `contextmenu` (which fires with the OS long-press buzz), so the bar starts
  // with that buzz. This fallback covers devices that emit no such event; it is
  // long enough that a quick tap or the start of a vertex drag never flashes it.
  private readonly INDICATOR_FALLBACK_MS = 650;
  // A short double pulse confirming the vertex delete completed — distinct from
  // the single OS long-press buzz. A no-op where web-page vibration is
  // unsupported or suppressed (iOS Safari; some WebView-based browsers), which
  // is harmless: the indicator turning red is the primary completion cue.
  private readonly VERTEX_DELETE_HAPTIC = [45, 40, 60];
  private touchTimer: ReturnType<typeof setTimeout>;
  private indicatorTimer: ReturnType<typeof setTimeout>;
  private indicatorShown = false;
  private pointerDownTime = 0;
  private evCache: { [id: number]: MouseEvent } = {};
  private touchStartXY: { x: number; y: number } | null = null;
  // The pointer type that opened the current gesture. A native `contextmenu`
  // event carries no pointer type of its own, so it is read from here to tell a
  // touch long-press apart from a mouse right-click.
  private lastPointerType: string | undefined;
  private clearTouchTimer = () => {
    clearTimeout(this.touchTimer);
    clearTimeout(this.indicatorTimer);
    if (this.indicatorShown) {
      // Hold cancelled (finger lifted early or moved off) — a completed delete
      // clears this flag first, so this only fires when nothing was deleted.
      hideVertexDeleteIndicator();
      this.indicatorShown = false;
    }
    this.evCache = {};
    this.touchStartXY = null;
    // Reset with the rest of the gesture state so a later pointer-less
    // `contextmenu` (the keyboard menu key) can't read a stale touch value.
    this.lastPointerType = undefined;
  };
  /** Cancel the long-press only once the pointer has moved beyond a small
   *  tolerance. A press inevitably jitters a few pixels (and a press on a
   *  Modify vertex often emits an immediate pointermove/drag) — without this,
   *  the hold was cancelled instantly and tap-hold never fired. */
  private clearTimerIfMoved = (event: MapBrowserEvent<PointerEvent>) => {
    if (!this.touchStartXY) {
      return;
    }
    const oe = event.originalEvent;
    if (
      Math.hypot(
        oe.clientX - this.touchStartXY.x,
        oe.clientY - this.touchStartXY.y
      ) > 15
    ) {
      this.clearTouchTimer();
    }
  };
  private touchHold = () => {
    if (!this.map) {
      // Destroyed before the timer fired.
      return;
    }
    if (Object.keys(this.evCache).length !== 1) {
      return;
    }
    const src = Object.values(this.evCache)[0];
    // During route editing a long hold removes the grabbed vertex; otherwise it
    // opens the chart context menu (null result = no active edit).
    const removed = tryDeleteHeldVertexOnHold(this.map);
    if (removed !== null) {
      // Confirm only a real removal — OpenLayers can refuse (e.g. a two-point
      // line), and a buzz + red trash on nothing deleted would be a lie.
      if (removed) {
        navigator.vibrate?.(this.VERTEX_DELETE_HAPTIC);
        completeVertexDeleteIndicator();
      } else {
        hideVertexDeleteIndicator();
      }
      this.indicatorShown = false;
      return;
    }
    this.mapContextMenu.emit(src as any);
    this.rightClickHandler(src);
  };
  // Show the delete-progress indicator over a grabbed vertex, its bar timed to
  // fill for the remainder of the hold. Triggered by the native long-press
  // `contextmenu` (in step with the OS buzz), with a fallback timer; the guard
  // keeps those two triggers from showing it twice.
  private showDeleteIndicator = () => {
    if (
      this.indicatorShown ||
      !this.map ||
      !this.touchStartXY ||
      !hasGrabbedVertex(this.map)
    ) {
      return;
    }
    const remaining =
      this.MODIFY_HOLD_MS - (performance.now() - this.pointerDownTime);
    showVertexDeleteIndicator(
      this.touchStartXY.x,
      this.touchStartXY.y,
      Math.max(0, remaining)
    );
    this.indicatorShown = true;
  };
  private pointerDownHandler = (event) => {
    // Re-enter the Angular zone: schedules the long-press timer in-zone (so the
    // context menu / vertex-delete it triggers gets change detection) and emits
    // to consumers that update Angular state. The map was created in
    // runOutsideAngular, so without this the whole pointer-down path (including
    // the setTimeout that patches into the zone) would run outside it.
    this.ngZone.run(() => {
      this.evCache[event.pointerId] = event;
      this.lastPointerType = event.pointerType;
      this.pointerDownTime = performance.now();
      startPointerGesture(this.map);
      this.touchStartXY = { x: event.clientX, y: event.clientY };
      // A vertex delete during Modify needs a deliberate long hold (1500 ms) so a
      // pause mid-edit doesn't delete a point; the chart context-menu long-press
      // stays at 500 ms.
      const modifying = this.map
        .getInteractions()
        .getArray()
        .some((i) => i instanceof Modify && i.getActive());
      this.touchTimer = setTimeout(
        this.touchHold,
        modifying ? this.MODIFY_HOLD_MS : this.MENU_HOLD_MS
      );
      if (modifying) {
        this.indicatorTimer = setTimeout(
          this.showDeleteIndicator,
          this.INDICATOR_FALLBACK_MS
        );
      }
      const rawCoord = this.map.getEventCoordinate(event);
      const c = toLonLat(rawCoord);
      const e = Object.assign(event, {
        lonlat: c,
        worldOffset: this.worldOffsetOf(rawCoord)
      });
      this.mapService.clearFeatureUrls();
      this.mapPointerDown.emit(e);
      this._pointerDown.update(() => e);
    });
  };
  private pointerUpHandler = (event: PointerEvent) => {
    this.clearTouchTimer();
    clearHeldVertexOnRelease(this.map, event);
  };
  private rightClickHandler = (event: MouseEvent) => {
    // A finger long-press makes the WebView fire a native `contextmenu` at the OS
    // long-press timeout, well before the vertex-delete hold completes. Clearing
    // the hold timer here would cancel the delete, so ignore that event while
    // editing and let the hold run its course. This fires with the OS long-press
    // buzz, so it is also where the delete-progress indicator starts.
    if (
      isTouchContextMenuWhileEditing(this.map, event.type, this.lastPointerType)
    ) {
      this.showDeleteIndicator();
      event.preventDefault();
      return;
    }
    this.clearTouchTimer();
    this.emitRightClickEvent(event);
  };

  // Discrete user actions (clicks) re-enter the Angular zone so consumers that
  // mutate Angular state / open popovers trigger change detection.
  private emitClickEvent = (event: MapBrowserEvent<PointerEvent>) => {
    this.ngZone.run(() => this.mapClick.emit(this.augmentClickEvent(event)));
  };

  private emitRightClickEvent = (event: MouseEvent) => {
    this.ngZone.run(() => {
      event.preventDefault();
      const c = this.map.getEventCoordinate(event);
      this.mapRightClick.emit({
        features: this.map.getFeaturesAtPixel(
          this.map.getPixelFromCoordinateInternal(c),
          {
            hitTolerance: hitToleranceForPointer(
              (event as PointerEvent).pointerType,
              this.hitTolerance,
              this.touchHitTolerance
            )
          }
        ),
        lonlat: toLonLat(c),
        worldOffset: this.worldOffsetOf(c)
      });
    });
  };
  private emitSingleClickEvent = (event: MapBrowserEvent<PointerEvent>) => {
    this.ngZone.run(() =>
      this.mapSingleClick.emit(this.augmentClickEvent(event))
    );
    // Consumers have now seen this click, so a vertex delete it completed is
    // spent and must not suppress the next one (#608).
    clearVertexDeleted(this.map);
  };
  private emitDblClickEvent = (event: MapBrowserEvent<PointerEvent>) => {
    this.ngZone.run(() => this.mapDblClick.emit(this.augmentClickEvent(event)));
    // A double click cancels the pending singleclick, so retire the marker here
    // instead (#608).
    clearVertexDeleted(this.map);
  };

  // Render-space offset (EPSG:3857 metres) of the world copy a click landed in,
  // relative to the primary world. `toLonLat` normalises a click to [-180,180]
  // and so discards which copy the user was looking at; consumers that place an
  // overlay or hit-test a feature need this offset to work in that copy. It stays
  // in Mercator and never becomes a lon/lat, so it cannot leak into stored data.
  private worldOffsetOf(coordinate: Coordinate): number {
    const worldWidth = getWidth(this.map.getView().getProjection().getExtent());
    return worldCopyOffset(coordinate[0], worldWidth);
  }

  // ** add {lonlat, worldOffset, features} fields to event
  private augmentClickEvent(event: MapBrowserEvent<PointerEvent>) {
    return Object.assign(event, {
      features: this.map.getFeaturesAtPixel(event.pixel, {
        hitTolerance: hitToleranceForPointer(
          event.originalEvent?.pointerType,
          this.hitTolerance,
          this.touchHitTolerance
        )
      }),
      lonlat: toLonLat(event.coordinate),
      worldOffset: this.worldOffsetOf(event.coordinate)
    });
  }

  private zoomAtStart: number;
  private emitMoveStartEvent = (event: MapEvent) => {
    this.zoomAtStart = this.map.getView().getZoom();
    this.mapMoveStart.emit(this.augmentMoveEvent(event));
  };
  private emitMoveEndEvent = (event: MapEvent) => {
    // Re-enter the zone: move-end writes app config/extent signals and persists
    // config, which must propagate to the UI.
    this.ngZone.run(() => this.mapMoveEnd.emit(this.augmentMoveEvent(event)));
  };

  // ** add {lonlat, zoom, extent, projCode, topCenter, rightCenter, rotation} fields to event
  private augmentMoveEvent(event: MapEvent) {
    const zoom = this.map.getView().getZoom();
    return Object.assign(event, {
      lonlat: this.getMapCenter(),
      zoom: zoom,
      zoomChanged: this.zoomAtStart !== zoom,
      extent: this.getMapExtent(),
      projCode: this.map.getView().getProjection().getCode(),
      topCenter: this.getMapViewTopCenter(),
      rightCenter: this.getMapViewRightCenter(),
      rotation: this.map.getView().getRotation()
    });
  }

  private emitPointerDragEvent = (event: MapBrowserEvent<PointerEvent>) => {
    // Only a real move (beyond tolerance) cancels the hold / pending delete —
    // a real drag means "move the vertex", a jitter does not.
    this.clearTimerIfMoved(event);
    // A gesture that has dragged emits no click at all, so its own delete
    // marker has nothing left to consume it. Only this gesture's marker —
    // a later drag must not retire one whose click is still pending (#608).
    clearVertexDeletedOnDrag(this.map);
    this.mapPointerDrag.emit(this.augmentPointerEvent(event));
  };
  private emitPointerMoveEvent = (event: MapBrowserEvent<PointerEvent>) => {
    this.clearTimerIfMoved(event);
    this.mapPointerMove.emit(this.augmentPointerEvent(event));
  };

  // ** add {lonlat, worldOffset} fields to event
  private augmentPointerEvent(event: MapBrowserEvent<PointerEvent>) {
    return Object.assign(event, {
      lonlat: toLonLat(event.coordinate),
      worldOffset: this.worldOffsetOf(event.coordinate)
    });
  }

  private emitPostComposeEvent = (event: RenderEvent) =>
    this.mapPostCompose.emit(event);
  private emitPostRenderEvent = (event: MapEvent) =>
    this.mapPostRender.emit(event);
  private emitPreComposeEvent = (event: RenderEvent) =>
    this.mapPreCompose.emit(event);
  private emitPropertyChangeEvent = (event: ObjectEvent) =>
    this.mapPropertyChange.emit(event);

  private updateSizeThrottle = () => {
    clearTimeout(this.timeoutId);
    this.timeoutId = setTimeout(() => {
      this.map.updateSize();
    }, 100);
  };

  focusMap() {
    this.element.nativeElement.firstElementChild.focus();
  }

  getMap() {
    return this.map;
  }

  updateSize() {
    this.updateSizeThrottle();
  }

  getMapCenter(): Coordinate {
    if (!this.map) {
      return [0, 0];
    }
    return toLonLat(this.map.getView().getCenter());
  }

  getMapExtent() {
    if (!this.map) {
      return [0, 0, 0, 0];
    }
    const v = this.map.getView();
    const mrid = v.getProjection().getCode();

    return transformExtent(
      v.calculateExtent(this.map.getSize()),
      mrid,
      'EPSG:4326'
    );
  }

  /**
   * Returns the geographic coordinates [lon, lat] of the viewport's
   * top-centre pixel, correctly accounting for map rotation.
   * In OL, positive rotation is CCW. The projected-space unit vector
   * pointing "up" on screen is (-sin θ, cos θ).
   */
  getMapViewTopCenter(): Coordinate {
    if (!this.map) {
      return [0, 0];
    }
    const v = this.map.getView();
    const center = v.getCenter();
    const rot = v.getRotation();
    const hh = (this.map.getSize()[1] / 2) * v.getResolution();
    return toLonLat(
      [center[0] - hh * Math.sin(rot), center[1] + hh * Math.cos(rot)],
      v.getProjection()
    );
  }

  /**
   * Returns the geographic coordinates [lon, lat] of the viewport's
   * right-centre pixel, correctly accounting for map rotation.
   * In OL, positive rotation is CCW. The projected-space unit vector
   * pointing "right" on screen is (cos θ, sin θ).
   */
  getMapViewRightCenter(): Coordinate {
    if (!this.map) {
      return [0, 0];
    }
    const v = this.map.getView();
    const center = v.getCenter();
    const rot = v.getRotation();
    const hw = (this.map.getSize()[0] / 2) * v.getResolution();
    return toLonLat(
      [center[0] + hw * Math.cos(rot), center[1] + hw * Math.sin(rot)],
      v.getProjection()
    );
  }
}
