import { expect, describe, it, vi } from 'vitest';
import { Map } from 'ol';
import { Collection } from 'ol';
import { Modify } from 'ol/interaction';
import MapBrowserEventType from 'ol/MapBrowserEventType';
import {
  clearHeldVertexOnRelease,
  clearVertexDeleted,
  clearVertexDeletedOnDrag,
  hasGrabbedVertex,
  isTouchContextMenuWhileEditing,
  markVertexDeleted,
  startPointerGesture,
  tryDeleteHeldVertexOnHold,
  vertexDeleted
} from './vertex-delete';

// Minimal stand-in for the OL map: only the members the helper touches.
function fakeMap(interactions: unknown[]) {
  const props: Record<string, unknown> = {};
  return {
    getInteractions: () => ({ getArray: () => interactions }),
    getViewport: () => document.createElement('div'),
    get: (k: string) => props[k],
    set: (k: string, v: unknown) => {
      props[k] = v;
    }
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
  } as any;
}

function activeModify(removePointResult = true): Modify {
  const modify = new Modify({ features: new Collection() });
  vi.spyOn(modify, 'getActive').mockReturnValue(true);
  vi.spyOn(modify, 'removePoint').mockReturnValue(removePointResult);
  return modify;
}

describe('tryDeleteHeldVertexOnHold', () => {
  it('removes the grabbed vertex mid-hold and reports the removal when a Modify is active', () => {
    const modify = activeModify();
    const map = fakeMap([modify]);

    expect(tryDeleteHeldVertexOnHold(map as Map)).toBe(true);
    expect(modify.removePoint).toHaveBeenCalled();
  });

  it('nulls the latched drag event BEFORE removePoint runs, so it is not refused', () => {
    // A finger jitter past OL's 1px tolerance leaves lastPointerEvent_ a
    // POINTERDRAG, which removePoint() refuses. The nulling must happen *before*
    // the call — assert the state removePoint actually observes, not the state
    // afterward (which a no-op mock leaves identical either way).
    const modify = new Modify({ features: new Collection() });
    vi.spyOn(modify, 'getActive').mockReturnValue(true);
    let seenAtRemove: unknown = 'unset';
    vi.spyOn(modify, 'removePoint').mockImplementation(() => {
      seenAtRemove = (modify as unknown as { lastPointerEvent_: unknown })
        .lastPointerEvent_;
      return true;
    });
    (modify as unknown as { lastPointerEvent_: unknown }).lastPointerEvent_ = {
      type: 'pointerdrag'
    };
    tryDeleteHeldVertexOnHold(fakeMap([modify]) as Map);
    expect(seenAtRemove).toBeNull();
  });

  it('reports false (nothing removed) when removePoint refuses, and marks no delete', () => {
    const map = fakeMap([activeModify(false)]);
    expect(tryDeleteHeldVertexOnHold(map as Map)).toBe(false);
    expect(vertexDeleted(map)).toBe(false);
  });

  it('returns null (caller opens the context menu) when no Modify is active', () => {
    const map = fakeMap([]);
    expect(tryDeleteHeldVertexOnHold(map as Map)).toBeNull();
  });

  it('relies on an OpenLayers field name that still exists (rename guard)', () => {
    expect(
      'lastPointerEvent_' in new Modify({ features: new Collection() })
    ).toBe(true);
  });

  it('removePoint removes only when the last event is not a drag — the OL guard the nudge defeats', () => {
    // Behavioral canary beyond a rename: since the delete has no fallback, if a
    // future OpenLayers changes *when* removePoint refuses, fail here rather than
    // let the touch delete silently die. `removeVertex_` runs only past the guard.
    const modify = new Modify({ features: new Collection() });
    const removeVertex = vi
      .spyOn(
        modify as unknown as { removeVertex_: () => boolean },
        'removeVertex_'
      )
      .mockReturnValue(false);

    (modify as unknown as { lastPointerEvent_: unknown }).lastPointerEvent_ = {
      type: MapBrowserEventType.POINTERDRAG
    };
    modify.removePoint();
    expect(removeVertex).not.toHaveBeenCalled();

    removeVertex.mockClear();
    (modify as unknown as { lastPointerEvent_: unknown }).lastPointerEvent_ =
      null;
    modify.removePoint();
    expect(removeVertex).toHaveBeenCalled();
  });
});

describe('hasGrabbedVertex', () => {
  it('is true when the active edit has a vertex grabbed under the pointer', () => {
    const modify = activeModify();
    (modify as unknown as { vertexFeature_: unknown }).vertexFeature_ = {};
    expect(hasGrabbedVertex(fakeMap([modify]) as Map)).toBe(true);
  });

  it('is false when nothing is grabbed', () => {
    const modify = activeModify();
    (modify as unknown as { vertexFeature_: unknown }).vertexFeature_ = null;
    expect(hasGrabbedVertex(fakeMap([modify]) as Map)).toBe(false);
  });

  it('is false when no Modify is active', () => {
    expect(hasGrabbedVertex(fakeMap([]) as Map)).toBe(false);
  });

  it('relies on an OpenLayers field name that still exists (rename guard)', () => {
    expect('vertexFeature_' in new Modify({ features: new Collection() })).toBe(
      true
    );
  });
});

// A touch long-press makes the Android WebView emit a *native* `contextmenu` at
// ~500ms (the OS long-press timeout), a full second before the 1500ms
// vertex-delete hold timer. Left to run, its handler clears that timer and the
// delete never fires. While editing, that native touch contextmenu must be
// ignored so the hold timer survives; every other case keeps working.
describe('isTouchContextMenuWhileEditing', () => {
  it('is true for a native touch contextmenu while a Modify is active', () => {
    const map = fakeMap([activeModify()]);
    expect(
      isTouchContextMenuWhileEditing(map as Map, 'contextmenu', 'touch')
    ).toBe(true);
  });

  it('is false when no Modify is active — the not-editing menu is unchanged', () => {
    const map = fakeMap([]);
    expect(
      isTouchContextMenuWhileEditing(map as Map, 'contextmenu', 'touch')
    ).toBe(false);
  });

  it('is false for a mouse contextmenu — a real right-click still opens the menu', () => {
    const map = fakeMap([activeModify()]);
    expect(
      isTouchContextMenuWhileEditing(map as Map, 'contextmenu', 'mouse')
    ).toBe(false);
  });

  it("is false for touchHold's own pointerdown source, not a native contextmenu", () => {
    const map = fakeMap([activeModify()]);
    expect(
      isTouchContextMenuWhileEditing(map as Map, 'pointerdown', 'touch')
    ).toBe(false);
  });

  it('is false for a contextmenu with no pointer type — the keyboard menu key', () => {
    const map = fakeMap([activeModify()]);
    expect(
      isTouchContextMenuWhileEditing(map as Map, 'contextmenu', undefined)
    ).toBe(false);
  });
});

// #643: OL retires the grabbed-vertex dot only from a pointermove that lands
// clear of the line. Touch emits none once the finger lifts, so the dot stayed
// lit on a vertex nothing was holding, reading as a selection that could not be
// dismissed. Re-arming the interaction is what drops it (OL clears the dot in
// `setActive(false)`), so that toggle is the observable contract here.
describe('clearHeldVertexOnRelease', () => {
  function releaseOn(map: Map, pointerType: string) {
    clearHeldVertexOnRelease(map, { pointerType } as PointerEvent);
  }

  it('drops the dot when a touch gesture ends', () => {
    const modify = activeModify();
    const setActive = vi.spyOn(modify, 'setActive');
    const map = fakeMap([modify]);

    releaseOn(map as Map, 'touch');
    expect(setActive.mock.calls.map(([a]) => a)).toEqual([false, true]);
  });

  it('leaves a mouse release alone — moving the cursor away clears it', () => {
    const modify = activeModify();
    const setActive = vi.spyOn(modify, 'setActive');

    releaseOn(fakeMap([modify]) as Map, 'mouse');
    expect(setActive).not.toHaveBeenCalled();
  });

  it('does nothing when no Modify is active', () => {
    expect(() => releaseOn(fakeMap([]) as Map, 'touch')).not.toThrow();
  });
});

// OpenLayers delays `singleclick` by 250 ms, so the click completing a delete
// can arrive after the user has pressed down again. The marker is therefore
// retired only where that click resolves — never as a side effect of a new
// gesture starting (#608).
describe('vertexDeleted marker', () => {
  it('reads false on a map that has never deleted a vertex', () => {
    expect(vertexDeleted(fakeMap([]))).toBe(false);
  });

  it('stays set until explicitly cleared, outliving the gesture that set it', () => {
    const map = fakeMap([]);
    startPointerGesture(map);
    markVertexDeleted(map);
    expect(vertexDeleted(map)).toBe(true);

    // A fast follow-up press begins a new gesture while the delete's click is
    // still pending — the marker must survive it.
    startPointerGesture(map);
    expect(vertexDeleted(map)).toBe(true);

    clearVertexDeleted(map); // the pending singleclick finally lands
    expect(vertexDeleted(map)).toBe(false);
  });

  it('is left set by a long-press delete, for the pending click to consume', () => {
    // The delete lands mid-hold, so the release still produces a click — the
    // marker must survive the whole gesture for that click to be suppressed.
    const map = fakeMap([activeModify(true)]);
    startPointerGesture(map);
    tryDeleteHeldVertexOnHold(map as Map);
    expect(vertexDeleted(map)).toBe(true);
  });

  it('is retired when the deleting gesture itself drags (no click will come)', () => {
    const map = fakeMap([]);
    startPointerGesture(map);
    markVertexDeleted(map);

    clearVertexDeletedOnDrag(map);
    expect(vertexDeleted(map)).toBe(false);
  });

  it('survives a LATER gesture dragging while the delete click is pending', () => {
    // Delete a vertex, then immediately pan the chart. That drag belongs to a
    // new gesture; retiring the marker there would let the still-pending
    // singleclick from the delete append an end point.
    const map = fakeMap([]);
    startPointerGesture(map);
    markVertexDeleted(map);

    startPointerGesture(map); // the pan begins
    clearVertexDeletedOnDrag(map);
    expect(vertexDeleted(map)).toBe(true);
  });
});
