// Copyright (C) 2025 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import m from 'mithril';
import {createContext, startDragGesture} from './mithril_utils';

// jsdom doesn't implement PointerEvent. Build a minimal stand-in by
// extending MouseEvent with a pointerId — sufficient for startDragGesture,
// which only reads clientX/clientY and pointerId.
function makePointerEvent(
  type: string,
  init: {clientX?: number; clientY?: number; pointerId?: number} = {},
): PointerEvent {
  const ev = new MouseEvent(type, {
    bubbles: true,
    cancelable: true,
    clientX: init.clientX ?? 0,
    clientY: init.clientY ?? 0,
  }) as MouseEvent & {pointerId: number};
  ev.pointerId = init.pointerId ?? 1;
  return ev as unknown as PointerEvent;
}

function makeTarget(): HTMLElement {
  const el = document.createElement('div');
  document.body.appendChild(el);
  // jsdom lacks pointer-capture; stub the methods startDragGesture calls.
  el.setPointerCapture = () => {};
  el.releasePointerCapture = () => {};
  el.hasPointerCapture = () => false;
  return el;
}

describe('startDragGesture', () => {
  afterEach(() => {
    document.body.innerHTML = '';
  });

  test('invokes onDragFailed when released inside deadzone', () => {
    const el = makeTarget();
    const onDrag = vi.fn();
    const onDragEnd = vi.fn();
    const onDragFailed = vi.fn();

    const down = makePointerEvent('pointerdown', {clientX: 100, clientY: 100});
    Object.defineProperty(down, 'currentTarget', {value: el});
    startDragGesture({e: down, deadzonePx: 5, onDrag, onDragEnd, onDragFailed});

    el.dispatchEvent(
      makePointerEvent('pointerup', {clientX: 101, clientY: 101}),
    );

    expect(onDrag).not.toHaveBeenCalled();
    expect(onDragEnd).not.toHaveBeenCalled();
    expect(onDragFailed).toHaveBeenCalledTimes(1);
  });

  test('top-level onDrag/onDragEnd fire after deadzone is crossed', () => {
    const el = makeTarget();
    const onDrag = vi.fn();
    const onDragEnd = vi.fn();
    const onDragFailed = vi.fn();
    const onDragStart = vi.fn();

    const down = makePointerEvent('pointerdown', {clientX: 0, clientY: 0});
    Object.defineProperty(down, 'currentTarget', {value: el});
    startDragGesture({
      e: down,
      deadzonePx: 5,
      onDragStart,
      onDrag,
      onDragEnd,
      onDragFailed,
    });

    // Inside deadzone — nothing fires.
    el.dispatchEvent(makePointerEvent('pointermove', {clientX: 2, clientY: 2}));
    expect(onDragStart).not.toHaveBeenCalled();
    expect(onDrag).not.toHaveBeenCalled();

    // Crosses deadzone — onDragStart fires, but this move is consumed by it.
    el.dispatchEvent(
      makePointerEvent('pointermove', {clientX: 10, clientY: 0}),
    );
    expect(onDragStart).toHaveBeenCalledTimes(1);
    expect(onDrag).not.toHaveBeenCalled();

    // Subsequent moves go to the top-level onDrag.
    el.dispatchEvent(
      makePointerEvent('pointermove', {clientX: 20, clientY: 0}),
    );
    el.dispatchEvent(
      makePointerEvent('pointermove', {clientX: 30, clientY: 0}),
    );
    expect(onDrag).toHaveBeenCalledTimes(2);

    el.dispatchEvent(makePointerEvent('pointerup', {clientX: 30, clientY: 0}));
    expect(onDragEnd).toHaveBeenCalledTimes(1);
    expect(onDragFailed).not.toHaveBeenCalled();
  });

  test('works with no onDragStart at all (direct mode)', () => {
    const el = makeTarget();
    const onDrag = vi.fn();
    const onDragEnd = vi.fn();

    const down = makePointerEvent('pointerdown');
    Object.defineProperty(down, 'currentTarget', {value: el});
    startDragGesture({e: down, onDrag, onDragEnd});

    el.dispatchEvent(
      makePointerEvent('pointermove', {clientX: 50, clientY: 0}),
    );
    el.dispatchEvent(
      makePointerEvent('pointermove', {clientX: 60, clientY: 0}),
    );
    el.dispatchEvent(makePointerEvent('pointerup', {clientX: 60, clientY: 0}));

    expect(onDrag).toHaveBeenCalledTimes(2);
    expect(onDragEnd).toHaveBeenCalledTimes(1);
  });

  test('per-gesture handlers from onDragStart override top-level ones', () => {
    const el = makeTarget();
    const topOnDrag = vi.fn();
    const topOnDragEnd = vi.fn();
    const perOnDrag = vi.fn();
    const perOnDragEnd = vi.fn();

    const down = makePointerEvent('pointerdown');
    Object.defineProperty(down, 'currentTarget', {value: el});
    startDragGesture({
      e: down,
      onDragStart: () => ({onDrag: perOnDrag, onDragEnd: perOnDragEnd}),
      onDrag: topOnDrag,
      onDragEnd: topOnDragEnd,
    });

    el.dispatchEvent(
      makePointerEvent('pointermove', {clientX: 50, clientY: 0}),
    );
    el.dispatchEvent(
      makePointerEvent('pointermove', {clientX: 60, clientY: 0}),
    );
    el.dispatchEvent(makePointerEvent('pointerup', {clientX: 60, clientY: 0}));

    expect(perOnDrag).toHaveBeenCalledTimes(2);
    expect(perOnDragEnd).toHaveBeenCalledTimes(1);
    expect(topOnDrag).not.toHaveBeenCalled();
    expect(topOnDragEnd).not.toHaveBeenCalled();
  });

  test('listeners are removed after gesture ends', () => {
    const el = makeTarget();
    const onDrag = vi.fn();
    const onDragEnd = vi.fn();

    const down = makePointerEvent('pointerdown');
    Object.defineProperty(down, 'currentTarget', {value: el});
    startDragGesture({e: down, onDrag, onDragEnd});

    el.dispatchEvent(
      makePointerEvent('pointermove', {clientX: 50, clientY: 0}),
    );
    el.dispatchEvent(makePointerEvent('pointerup', {clientX: 50, clientY: 0}));

    onDrag.mockClear();
    onDragEnd.mockClear();
    el.dispatchEvent(
      makePointerEvent('pointermove', {clientX: 100, clientY: 0}),
    );
    el.dispatchEvent(makePointerEvent('pointerup', {clientX: 100, clientY: 0}));

    expect(onDrag).not.toHaveBeenCalled();
    expect(onDragEnd).not.toHaveBeenCalled();
  });

  test('deadzonePx: 0 starts the drag immediately on pointerdown', () => {
    const el = makeTarget();
    const onDragStart = vi.fn();
    const onDrag = vi.fn();
    const onDragEnd = vi.fn();
    const onDragFailed = vi.fn();

    const down = makePointerEvent('pointerdown', {clientX: 10, clientY: 10});
    Object.defineProperty(down, 'currentTarget', {value: el});
    startDragGesture({
      e: down,
      deadzonePx: 0,
      onDragStart,
      onDrag,
      onDragEnd,
      onDragFailed,
    });

    // onDragStart fires synchronously, before any pointermove.
    expect(onDragStart).toHaveBeenCalledTimes(1);

    // A release without moving counts as drag end, not drag failed.
    el.dispatchEvent(makePointerEvent('pointerup', {clientX: 10, clientY: 10}));
    expect(onDragEnd).toHaveBeenCalledTimes(1);
    expect(onDragFailed).not.toHaveBeenCalled();
  });

  test('pointercancel ends the gesture like pointerup', () => {
    const el = makeTarget();
    const onDragEnd = vi.fn();
    const onDragFailed = vi.fn();

    const down = makePointerEvent('pointerdown');
    Object.defineProperty(down, 'currentTarget', {value: el});
    startDragGesture({e: down, onDragEnd, onDragFailed});

    el.dispatchEvent(
      makePointerEvent('pointermove', {clientX: 50, clientY: 0}),
    );
    el.dispatchEvent(
      makePointerEvent('pointercancel', {clientX: 50, clientY: 0}),
    );

    expect(onDragEnd).toHaveBeenCalledTimes(1);
    expect(onDragFailed).not.toHaveBeenCalled();
  });
});

describe('createContext', () => {
  test('provides default value to consumers', () => {
    const {Consumer} = createContext('default');

    let receivedValue: string | undefined;
    const TestComponent = {
      view: () =>
        m(Consumer, (value) => {
          receivedValue = value;
        }),
    };

    m.render(document.body, m(TestComponent));
    expect(receivedValue).toBe('default');
  });

  test('provides undefined when no default value', () => {
    const {Consumer} = createContext<string>();

    let receivedValue: string | undefined = 'sentinel';
    const TestComponent = {
      view: () =>
        m(Consumer, (value) => {
          receivedValue = value;
        }),
    };

    m.render(document.body, m(TestComponent));
    expect(receivedValue).toBeUndefined();
  });

  test('provider overrides default value', () => {
    const {Provider, Consumer} = createContext('default');

    let receivedValue: string | undefined;
    const TestComponent = {
      view: () =>
        m(
          Provider,
          {value: 'custom'},
          m(Consumer, (value) => {
            receivedValue = value;
          }),
        ),
    };

    m.render(document.body, m(TestComponent));
    expect(receivedValue).toBe('custom');
  });

  test('nested providers use innermost value', () => {
    const {Provider, Consumer} = createContext('default');

    const receivedValues: string[] = [];
    const TestComponent = {
      view: () =>
        m(Provider, {value: 'outer'}, [
          m(Consumer, (value) => {
            receivedValues.push(value);
          }),
          m(
            Provider,
            {value: 'inner'},
            m(Consumer, (value) => {
              receivedValues.push(value);
            }),
          ),
          m(Consumer, (value) => {
            receivedValues.push(value);
          }),
        ]),
    };

    m.render(document.body, m(TestComponent));
    expect(receivedValues).toEqual(['outer', 'inner', 'outer']);
  });

  test('multiple providers in parallel', () => {
    const {Provider, Consumer} = createContext('default');

    const receivedValues: string[] = [];
    const TestComponent = {
      view: () => [
        m(Consumer, (value) => {
          receivedValues.push(value);
        }),
        m(
          Provider,
          {value: 'foo'},
          m(Consumer, (value) => {
            receivedValues.push(value);
          }),
        ),
        m(
          Provider,
          {value: 'bar'},
          m(Consumer, (value) => {
            receivedValues.push(value);
          }),
        ),
        m(Consumer, (value) => {
          receivedValues.push(value);
        }),
      ],
    };

    m.render(document.body, m(TestComponent));
    expect(receivedValues).toEqual(['default', 'foo', 'bar', 'default']);
  });

  test('different contexts are independent', () => {
    const Context1 = createContext('default1');
    const Context2 = createContext('default2');

    const receivedValues: string[] = [];
    const TestComponent = {
      view: () =>
        m(Context1.Provider, {value: 'value1'}, [
          m(Context2.Provider, {value: 'value2'}, [
            m(Context1.Consumer, (value) => {
              receivedValues.push(value);
            }),
            m(Context2.Consumer, (value) => {
              receivedValues.push(value);
            }),
          ]),
        ]),
    };

    m.render(document.body, m(TestComponent));
    expect(receivedValues).toEqual(['value1', 'value2']);
  });

  test('works with complex types', () => {
    interface User {
      name: string;
      age: number;
    }

    const {Provider, Consumer} = createContext<User>({name: 'Default', age: 0});

    let receivedValue: User | undefined;
    const TestComponent = {
      view: () =>
        m(
          Provider,
          {value: {name: 'Alice', age: 30}},
          m(Consumer, (value) => {
            receivedValue = value;
          }),
        ),
    };

    m.render(document.body, m(TestComponent));
    expect(receivedValue).toEqual({name: 'Alice', age: 30});
  });

  test('handles null values', () => {
    const {Provider, Consumer} = createContext<string | null>('default');

    let receivedValue: string | null = 'sentinel';
    const TestComponent = {
      view: () =>
        m(
          Provider,
          {value: null},
          m(Consumer, (value) => {
            receivedValue = value;
          }),
        ),
    };

    m.render(document.body, m(TestComponent));
    expect(receivedValue).toBeNull();
  });
});
