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

import type { GuidanceEvent, MapmapNavModule } from "./MapmapNav.types";
import { distanceMeters } from "./geo";
import { createMapmapNavMock } from "./mock";

const CONTRACT_METHODS: (keyof MapmapNavModule)[] = [
  "init",
  "listAvailableTerritories",
  "listInstalledTerritories",
  "installTerritory",
  "removeTerritory",
  "getTerritoryStyle",
  "search",
  "computeRoute",
  "startGuidance",
  "stopGuidance",
  "setVoiceMuted",
  "setVoiceVolume",
];

describe("MapmapNavMock — contract shape conformance", () => {
  it("implements every method on MapmapNavModule as a function", () => {
    const mock = createMapmapNavMock();
    for (const method of CONTRACT_METHODS) {
      expect(typeof mock[method]).toBe("function");
    }
  });

  it("exposes addListener for typed event subscription", () => {
    const mock = createMapmapNavMock();
    expect(typeof mock.addListener).toBe("function");
  });

  it("rejects with E_NOT_INITIALISED before init() resolves", async () => {
    const mock = createMapmapNavMock();
    await expect(mock.listAvailableTerritories()).rejects.toMatchObject({ code: "E_NOT_INITIALISED" });
  });

  it("is idempotent to call init() twice", async () => {
    const mock = createMapmapNavMock();
    await mock.init("key");
    await expect(mock.init("key")).resolves.toBeUndefined();
  });

  it("rejects installTerritory for an unknown id with a stable BridgeErrorCode", async () => {
    const mock = createMapmapNavMock();
    await mock.init("key");
    await expect(mock.installTerritory("nowhere")).rejects.toMatchObject({ code: "E_TERRITORY_NOT_FOUND" });
  });

  it("rejects computeRoute/search before any territory is installed", async () => {
    const mock = createMapmapNavMock();
    await mock.init("key");
    await expect(
      mock.computeRoute({
        profile: "car",
        origin: { latitude: 51.5, longitude: -0.1 },
        destination: { latitude: 51.51, longitude: -0.11 },
      })
    ).rejects.toMatchObject({ code: "E_NO_TERRITORY" });
    await expect(mock.search("cafe")).rejects.toMatchObject({ code: "E_NO_TERRITORY" });
  });
});

describe("MapmapNavMock — territory install", () => {
  it("emits phased territoryProgress events over ~territoryInstallDurationMs and settles as installed", async () => {
    const mock = createMapmapNavMock({ territoryInstallDurationMs: 20 });
    await mock.init("key");

    const phases: string[] = [];
    mock.addListener("territoryProgress", (event) => {
      expect(event.territoryId).toBe("uk");
      phases.push(event.phase);
    });

    await mock.installTerritory("uk");

    expect(phases).toContain("downloading");
    expect(phases).toContain("verifying");
    expect(phases).toContain("activating");
    expect(phases[phases.length - 1]).toBe("done");

    const installed = await mock.listInstalledTerritories();
    expect(installed).toHaveLength(1);
    expect(installed[0]).toMatchObject({ id: "uk", active: true });
  });
});

describe("MapmapNavMock — computeRoute", () => {
  it("returns a plausible multi-route result with interpolated geometry ending at the destination", async () => {
    const mock = createMapmapNavMock({ territoryInstallDurationMs: 5 });
    await mock.init("key");
    await mock.installTerritory("uk");

    const origin = { latitude: 51.5074, longitude: -0.1278 };
    const destination = { latitude: 51.515, longitude: -0.135 };
    const result = await mock.computeRoute({ profile: "car", origin, destination });

    expect(result.routes.length).toBeGreaterThanOrEqual(2);
    for (const [i, route] of result.routes.entries()) {
      expect(route.index).toBe(i);
      expect(route.geometry.length).toBeGreaterThan(2);
      expect(route.geometry[0]).toEqual(origin);
      expect(distanceMeters(route.geometry[route.geometry.length - 1], destination)).toBeLessThan(0.01);
      expect(route.distanceMeters).toBeGreaterThan(0);
      expect(route.durationSeconds).toBeGreaterThan(0);
      expect(route.maneuvers[0].type).toBe("depart");
      expect(route.maneuvers[route.maneuvers.length - 1].type).toBe("arrive");
    }
  });

  it("rejects when origin and destination are the same point", async () => {
    const mock = createMapmapNavMock({ territoryInstallDurationMs: 5 });
    await mock.init("key");
    await mock.installTerritory("uk");
    const point = { latitude: 51.5, longitude: -0.1 };
    await expect(mock.computeRoute({ profile: "car", origin: point, destination: point })).rejects.toMatchObject({
      code: "E_NO_ROUTE",
    });
  });
});

describe("MapmapNavMock — guidance", () => {
  async function installedMockWithRoute(destinationOffset = 0.0006) {
    const mock = createMapmapNavMock({ territoryInstallDurationMs: 5, tickIntervalMs: 5 });
    await mock.init("key");
    await mock.installTerritory("uk");
    const origin = { latitude: 51.5074, longitude: -0.1278 };
    const destination = { latitude: 51.5074 + destinationOffset, longitude: -0.1278 + destinationOffset };
    await mock.computeRoute({ profile: "car", origin, destination, alternatives: 0 });
    return mock;
  }

  it("streams ~1Hz-paced guidance ticks through banner changes and a reroute blip, and reaches 'arrived'", async () => {
    const mock = await installedMockWithRoute();

    const events: GuidanceEvent[] = [];
    let notifyArrived: () => void;
    const arrived = new Promise<void>((resolve) => {
      notifyArrived = resolve;
    });
    mock.addListener("guidance", (event) => {
      events.push(event);
      if (event.state === "arrived") notifyArrived();
    });

    await mock.startGuidance({
      routeIndex: 0,
      // Large speedMultiplier fast-forwards the simulated replay so the
      // test doesn't wait out a real ~1Hz session.
      source: { kind: "replay", corpusPath: "unused.drive.jsonl", speedMultiplier: 300 },
    });

    await arrived;

    expect(events.length).toBeGreaterThan(2);
    expect(events[events.length - 1].state).toBe("arrived");
    expect(events.some((e) => e.state === "rerouting")).toBe(true);
    expect(events.filter((e) => e.state === "navigating").length).toBeGreaterThan(0);

    for (const event of events) {
      if (event.state === "rerouting") {
        expect(event.banner).toBeUndefined();
        expect(event.isOffRoute).toBe(true);
      } else {
        expect(event.banner).toBeDefined();
        expect(event.isOffRoute).toBe(false);
      }
    }
  }, 10_000);

  it("rejects stopGuidance when no session is running", async () => {
    const mock = createMapmapNavMock();
    await mock.init("key");
    await expect(mock.stopGuidance()).rejects.toMatchObject({ code: "E_NO_GUIDANCE" });
  });

  it("rejects a second startGuidance while one is active, and allows a fresh one after stopGuidance", async () => {
    const mock = await installedMockWithRoute(0.01); // long route: won't finish mid-test
    await mock.startGuidance({ routeIndex: 0, source: { kind: "live" } });

    await expect(mock.startGuidance({ routeIndex: 0, source: { kind: "live" } })).rejects.toMatchObject({
      code: "E_GUIDANCE_ACTIVE",
    });

    await mock.stopGuidance();
    await expect(mock.startGuidance({ routeIndex: 0, source: { kind: "live" } })).resolves.toBeUndefined();
    await mock.stopGuidance();
  });

  it("rejects startGuidance with E_NO_ROUTE for an out-of-range routeIndex", async () => {
    const mock = await installedMockWithRoute();
    await expect(mock.startGuidance({ routeIndex: 5, source: { kind: "live" } })).rejects.toMatchObject({
      code: "E_NO_ROUTE",
    });
  });
});

describe("MapmapNavMock — search", () => {
  async function installedMock() {
    const mock = createMapmapNavMock({ territoryInstallDurationMs: 5 });
    await mock.init("key");
    await mock.installTerritory("uk");
    return mock;
  }

  it("ranks the city itself above its landmarks for a city-name query", async () => {
    const mock = await installedMock();
    const results = await mock.search("Manchester");

    expect(results.length).toBeGreaterThan(1);
    expect(results[0].name).toBe("Manchester");
    expect(results[0].address).toBe("City centre, Manchester");
    // Landmarks are still present, just ranked below the city.
    expect(results.some((r) => r.name === "Manchester Piccadilly")).toBe(true);
    expect(results.some((r) => r.name === "Old Trafford")).toBe(true);
  });

  it("still finds landmarks by keyword/address substring when the query isn't a city name", async () => {
    const mock = await installedMock();
    const results = await mock.search("stadium");

    expect(results.some((r) => r.name === "Old Trafford")).toBe(true);
    expect(results.some((r) => r.name === "Wembley Stadium")).toBe(true);
  });

  // Every search category chip must return results — regression guard for the
  // chips previously matching zero fixtures (petrol/coffee/parking/hotels/atm).
  it.each(["petrol", "food", "coffee", "parking", "hotels", "atm"])(
    "returns results for the '%s' category chip",
    async (term) => {
      const mock = await installedMock();
      const results = await mock.search(term);
      expect(results.length).toBeGreaterThan(0);
    }
  );
});

describe("MapmapNavMock — voice", () => {
  it("emits voiceState on mute/volume changes", async () => {
    const mock = createMapmapNavMock();
    await mock.init("key");

    const events: { muted: boolean; volume: number }[] = [];
    mock.addListener("voiceState", (event) => events.push(event));

    await mock.setVoiceMuted(true);
    await mock.setVoiceVolume(0.5);

    expect(events).toContainEqual(expect.objectContaining({ muted: true }));
    expect(events).toContainEqual(expect.objectContaining({ volume: 0.5 }));
  });
});
