import { afterEach, describe, expect, it, vi } from "vitest";

import {
  buildDragSegmentPauses,
  isManualVerificationPending,
  isManualVerificationWaiting,
  resolveDragToEndDurationMs,
  waitForVerificationClear,
} from "./human.js";

type FakePageMode = "blocked" | "drag" | "delayedDrag" | "instruction" | "transientPunish" | "weakSlider" | "clear";

class FakeLocator {
  constructor(
    private readonly page: FakePage,
    private readonly kind: "body" | "handle" | "track" | "missing",
  ) {}

  first() {
    return this;
  }

  async count() {
    return this.kind === "missing" || this.page.mode === "clear" ? 0 : 1;
  }

  async isVisible() {
    return this.kind !== "missing" && this.page.mode !== "clear";
  }

  async innerText() {
    this.page.maybeResolveTransient();
    if (this.page.mode === "blocked") {
      return "验证码拦截，请完成滑动验证";
    }
    if (this.page.mode === "drag") {
      return "拖动下方滑块完成验证";
    }
    if (this.page.mode === "delayedDrag") {
      return "拖动下方滑块完成验证";
    }
    if (this.page.mode === "instruction") {
      return "请按照说明拖动滑块完成验证";
    }
    if (this.page.mode === "transientPunish") {
      return "拖动下方滑块完成验证";
    }
    if (this.page.mode === "weakSlider") {
      return "商品详情 价格 主图";
    }
    return "";
  }

  async boundingBox() {
    if (this.kind === "handle") {
      return { x: 20, y: 20, width: 40, height: 40 };
    }
    if (this.kind === "track") {
      return { x: 10, y: 20, width: 260, height: 40 };
    }
    return null;
  }
}

class FakeFrame {
  constructor(private readonly page: FakePage) {}

  url() {
    this.page.maybeResolveTransient();
    return this.page.mode === "clear" || this.page.mode === "weakSlider"
      ? "https://detail.1688.com/offer/123.html"
      : "https://s.1688.com/punish";
  }

  locator(selector: string) {
    if (selector === "body") {
      this.page.detectCalls += 1;
      return new FakeLocator(this.page, "body");
    }

    const delayedDragReady = this.page.mode === "delayedDrag" && this.page.detectCalls >= 2;
    const hasSliderControls = this.page.mode === "drag"
      || this.page.mode === "instruction"
      || this.page.mode === "transientPunish"
      || this.page.mode === "weakSlider"
      || delayedDragReady;
    if (hasSliderControls && /scratch-captcha-btn|btn_slide|nc_.*n1z/.test(selector)) {
      return new FakeLocator(this.page, "handle");
    }

    if (hasSliderControls && /scratch-captcha|nc_.*scale|nc_scale/.test(selector)) {
      return new FakeLocator(this.page, "track");
    }

    return new FakeLocator(this.page, "missing");
  }

  async evaluate(fn: () => string) {
    return fn();
  }
}

class FakePage {
  mode: FakePageMode;
  transitionAfterMouseUp: FakePageMode | null = null;
  transientClearAfterDetectCalls = Number.POSITIVE_INFINITY;
  detectCalls = 0;
  closeCalls = 0;
  reloadCalls = 0;
  private closed = false;

  mouse = {
    move: vi.fn(async () => {}),
    down: vi.fn(async () => {}),
    up: vi.fn(async () => {
      if (!this.transitionAfterMouseUp) {
        return;
      }

      this.mode = this.transitionAfterMouseUp;
      this.transitionAfterMouseUp = null;
      if (this.mode === "transientPunish") {
        this.transientClearAfterDetectCalls = this.detectCalls + 2;
      }
    }),
    click: vi.fn(async () => {}),
    wheel: vi.fn(async () => {}),
  };

  constructor(mode: FakePageMode) {
    this.mode = mode;
  }

  maybeResolveTransient() {
    if (this.mode === "transientPunish" && this.detectCalls >= this.transientClearAfterDetectCalls) {
      this.mode = "weakSlider";
      this.transientClearAfterDetectCalls = Number.POSITIVE_INFINITY;
    }
  }

  isClosed() {
    return this.closed;
  }

  frames() {
    return [new FakeFrame(this)];
  }

  url() {
    this.maybeResolveTransient();
    return this.mode === "clear" || this.mode === "weakSlider"
      ? "https://detail.1688.com/offer/123.html"
      : "https://s.1688.com/punish";
  }

  context() {
    return {
      addCookies: vi.fn(async () => {}),
      storageState: vi.fn(async () => ({})),
    };
  }

  async waitForLoadState() {}

  async bringToFront() {}

  async reload() {
    this.reloadCalls += 1;
  }

  async close() {
    this.closeCalls += 1;
    this.closed = true;
  }
}

async function waitUntilManualPending(page: FakePage) {
  for (let index = 0; index < 400; index += 1) {
    await Promise.resolve();
    await vi.advanceTimersByTimeAsync(0).catch(() => {});
    if (isManualVerificationWaiting(page as never)) {
      return;
    }
  }

  throw new Error("manual verification wait was not registered");
}

async function waitForRealCondition(condition: () => boolean, message: string) {
  const startedAt = Date.now();
  while (Date.now() - startedAt < 8000) {
    if (condition()) {
      return;
    }
    await new Promise((resolve) => setTimeout(resolve, 50));
  }

  throw new Error(message);
}

describe("verification waiting", () => {
  afterEach(() => {
    vi.useRealTimers();
  });

  it("registers manual wait before returning control to callers", async () => {
    vi.useFakeTimers();
    const page = new FakePage("blocked");

    const pending = waitForVerificationClear(page as never, { logPrefix: "[test]" });
    await waitUntilManualPending(page);

    expect(isManualVerificationPending(page as never)).toBe(true);
    expect(page.closeCalls).toBe(0);
    expect(page.reloadCalls).toBe(0);

    page.mode = "clear";
    await vi.advanceTimersByTimeAsync(5000);

    await expect(pending).resolves.toBe(true);
    expect(isManualVerificationPending(page as never)).toBe(false);
  });

  it("switches drag-to-end captcha to manual wait after the configured auto attempts", async () => {
    const page = new FakePage("drag");

    const pending = waitForVerificationClear(page as never, {
      logPrefix: "[test]",
      autoAttemptLimit: 1,
      dragDurationMs: 10,
      postDragSettleMs: 100,
      postDragSettleIntervalMs: 10,
    });
    let resolved = false;
    pending.then(() => {
      resolved = true;
    });

    await waitForRealCondition(
      () => page.mouse.move.mock.calls.length > 0,
      "drag attempt did not start",
    );
    await waitForRealCondition(
      () => isManualVerificationWaiting(page as never),
      "manual verification wait was not reached",
    );

    expect(page.mouse.move).toHaveBeenCalled();
    expect(isManualVerificationWaiting(page as never)).toBe(true);
    expect(page.reloadCalls).toBe(0);
    expect(resolved).toBe(false);

    page.mode = "clear";

    await expect(pending).resolves.toBe(true);
  });

  it("waits for slider controls when a drag-to-end page first appears as blocked-only text", async () => {
    const page = new FakePage("delayedDrag");

    const pending = waitForVerificationClear(page as never, {
      logPrefix: "[test]",
      autoAttemptLimit: 1,
      dragDurationMs: 10,
      handleWaitMs: 1000,
      postDragSettleMs: 100,
      postDragSettleIntervalMs: 10,
    });

    await waitForRealCondition(
      () => page.mouse.move.mock.calls.length > 0,
      "delayed drag controls were not used",
    );
    expect(page.detectCalls).toBeGreaterThanOrEqual(2);
    expect(page.mouse.move).toHaveBeenCalled();

    await waitForRealCondition(
      () => isManualVerificationWaiting(page as never),
      "manual verification wait was not reached after delayed drag attempt",
    );

    page.mode = "clear";
    await expect(pending).resolves.toBe(true);
  });

  it("keeps the default drag-to-end attempt limit at two and retries through the slider surface", async () => {
    const page = new FakePage("drag");

    const pending = waitForVerificationClear(page as never, {
      logPrefix: "[test]",
      dragDurationMs: 10,
      postDragSettleMs: 100,
      postDragSettleIntervalMs: 10,
      handleWaitMs: 500,
    });
    let resolved = false;
    pending.then(() => {
      resolved = true;
    });

    await waitForRealCondition(
      () => page.mouse.up.mock.calls.length >= 2,
      "default drag-to-end retry did not execute the second drag attempt",
    );
    await waitForRealCondition(
      () => isManualVerificationWaiting(page as never),
      "manual verification wait was not reached after default attempts",
    );

    expect(page.mouse.up).toHaveBeenCalledTimes(2);
    expect(page.mouse.click).toHaveBeenCalledTimes(1);
    expect(isManualVerificationWaiting(page as never)).toBe(true);
    expect(resolved).toBe(false);

    page.mode = "clear";
    await expect(pending).resolves.toBe(true);
  }, 10000);

  it("resolves after auto drag when only non-captcha slider DOM remains", async () => {
    const page = new FakePage("drag");
    page.transitionAfterMouseUp = "weakSlider";

    await expect(waitForVerificationClear(page as never, {
      logPrefix: "[test]",
      autoAttemptLimit: 1,
      dragDurationMs: 10,
      postDragSettleMs: 200,
      postDragSettleIntervalMs: 10,
    })).resolves.toBe(true);

    expect(page.mouse.move).toHaveBeenCalled();
    expect(isManualVerificationWaiting(page as never)).toBe(false);
    expect(page.mode).toBe("weakSlider");
  });

  it("waits for transient punish state to clear after auto drag", async () => {
    const page = new FakePage("drag");
    page.transitionAfterMouseUp = "transientPunish";

    await expect(waitForVerificationClear(page as never, {
      logPrefix: "[test]",
      autoAttemptLimit: 1,
      dragDurationMs: 10,
      postDragSettleMs: 1000,
      postDragSettleIntervalMs: 10,
    })).resolves.toBe(true);

    expect(page.mouse.move).toHaveBeenCalled();
    expect(isManualVerificationWaiting(page as never)).toBe(false);
    expect(page.mode).toBe("weakSlider");
  });

  it("routes instruction-based slider captcha to manual wait without auto dragging", async () => {
    vi.useFakeTimers();
    const page = new FakePage("instruction");

    const pending = waitForVerificationClear(page as never, {
      logPrefix: "[test]",
      autoAttemptLimit: 5,
      dragDurationMs: 10,
    });
    await waitUntilManualPending(page);

    expect(page.mouse.move).not.toHaveBeenCalled();
    expect(isManualVerificationWaiting(page as never)).toBe(true);

    page.mode = "clear";
    await vi.advanceTimersByTimeAsync(5000);

    await expect(pending).resolves.toBe(true);
  });

  it("keeps drag-to-end slider movement in the configured duration window", () => {
    const duration = resolveDragToEndDurationMs({ dragDurationMs: 2400 });
    const pauses = buildDragSegmentPauses(duration, 9);

    expect(duration).toBe(2400);
    expect(pauses.reduce((sum, pause) => sum + pause, 0)).toBe(2400);
    expect(pauses.every((pause) => pause >= 0)).toBe(true);
  });

  it("uses a 1.5-2s default drag-to-end duration window", () => {
    for (let index = 0; index < 50; index += 1) {
      const duration = resolveDragToEndDurationMs();
      expect(duration).toBeGreaterThanOrEqual(1500);
      expect(duration).toBeLessThanOrEqual(2000);
    }
  });
});
