import fs from "fs";
import assert from "node:assert";
import { afterEach, beforeEach, describe, it, test } from "node:test";
import { mockServer } from "./utils";

import {
  BufferPosition,
  Entry,
  PMTiles,
  RangeResponse,
  SharedPromiseCache,
  Source,
  TileType,
  findTile,
  getUint64,
  readVarint,
  tileIdToZxy,
  tileTypeExt,
  zxyToTileId,
} from "../src/index";

describe("pmtiles v3", () => {
  test("varint", () => {
    let b: BufferPosition = {
      buf: new Uint8Array([0, 1, 127, 0xe5, 0x8e, 0x26]),
      pos: 0,
    };
    assert.strictEqual(readVarint(b), 0);
    assert.strictEqual(readVarint(b), 1);
    assert.strictEqual(readVarint(b), 127);
    assert.strictEqual(readVarint(b), 624485);
    b = {
      buf: new Uint8Array([0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f]),
      pos: 0,
    };
    assert.strictEqual(readVarint(b), 9007199254740991);
  });

  test("zxy to tile id", () => {
    assert.strictEqual(zxyToTileId(0, 0, 0), 0);
    assert.strictEqual(zxyToTileId(1, 0, 0), 1);
    assert.strictEqual(zxyToTileId(1, 0, 1), 2);
    assert.strictEqual(zxyToTileId(1, 1, 1), 3);
    assert.strictEqual(zxyToTileId(1, 1, 0), 4);
    assert.strictEqual(zxyToTileId(2, 0, 0), 5);
  });

  test("tile id to zxy", () => {
    assert.deepEqual(tileIdToZxy(0), [0, 0, 0]);
    assert.deepEqual(tileIdToZxy(1), [1, 0, 0]);
    assert.deepEqual(tileIdToZxy(2), [1, 0, 1]);
    assert.deepEqual(tileIdToZxy(3), [1, 1, 1]);
    assert.deepEqual(tileIdToZxy(4), [1, 1, 0]);
    assert.deepEqual(tileIdToZxy(5), [2, 0, 0]);
  });

  test("a lot of tiles", () => {
    for (let z = 0; z < 9; z++) {
      for (let x = 0; x < 1 << z; x++) {
        for (let y = 0; y < 1 << z; y++) {
          const result = tileIdToZxy(zxyToTileId(z, x, y));
          if (result[0] !== z || result[1] !== x || result[2] !== y) {
            assert.fail("roundtrip failed");
          }
        }
      }
    }
  });

  test("tile extremes", () => {
    for (let z = 0; z < 27; z++) {
      const dim = 2 ** z - 1;
      const tl = tileIdToZxy(zxyToTileId(z, 0, 0));
      assert.deepEqual([z, 0, 0], tl);
      const tr = tileIdToZxy(zxyToTileId(z, dim, 0));
      assert.deepEqual([z, dim, 0], tr);
      const bl = tileIdToZxy(zxyToTileId(z, 0, dim));
      assert.deepEqual([z, 0, dim], bl);
      const br = tileIdToZxy(zxyToTileId(z, dim, dim));
      assert.deepEqual([z, dim, dim], br);
    }
  });

  test("invalid tiles", () => {
    assert.throws(() => {
      tileIdToZxy(Number.MAX_SAFE_INTEGER);
    });

    assert.throws(() => {
      zxyToTileId(27, 0, 0);
    });

    assert.throws(() => {
      zxyToTileId(0, 1, 1);
    });
  });

  test("tile search for missing entry", () => {
    const entries: Entry[] = [];
    assert.strictEqual(findTile(entries, 101), null);
  });

  test("tile search for first entry == id", () => {
    const entries: Entry[] = [
      { tileId: 100, offset: 1, length: 1, runLength: 1 },
    ];
    const entry = findTile(entries, 100);
    assert.strictEqual(entry?.offset, 1);
    assert.strictEqual(entry?.length, 1);
    assert.strictEqual(findTile(entries, 101), null);
  });

  test("tile search with runlength", () => {
    const entries: Entry[] = [
      { tileId: 3, offset: 3, length: 1, runLength: 2 },
      { tileId: 5, offset: 5, length: 1, runLength: 2 },
    ];
    const entry = findTile(entries, 4);
    assert.strictEqual(entry?.offset, 3);
  });

  test("tile search with multiple tile entries", () => {
    let entries: Entry[] = [
      { tileId: 100, offset: 1, length: 1, runLength: 2 },
    ];
    let entry = findTile(entries, 101);
    assert.strictEqual(entry?.offset, 1);
    assert.strictEqual(entry?.length, 1);

    entries = [
      { tileId: 100, offset: 1, length: 1, runLength: 1 },
      { tileId: 150, offset: 2, length: 2, runLength: 2 },
    ];
    entry = findTile(entries, 151);
    assert.strictEqual(entry?.offset, 2);
    assert.strictEqual(entry?.length, 2);

    entries = [
      { tileId: 50, offset: 1, length: 1, runLength: 2 },
      { tileId: 100, offset: 2, length: 2, runLength: 1 },
      { tileId: 150, offset: 3, length: 3, runLength: 1 },
    ];
    entry = findTile(entries, 51);
    assert.strictEqual(entry?.offset, 1);
    assert.strictEqual(entry?.length, 1);
  });

  test("leaf search", () => {
    const entries: Entry[] = [
      { tileId: 100, offset: 1, length: 1, runLength: 0 },
    ];
    const entry = findTile(entries, 150);
    assert.strictEqual(entry?.offset, 1);
    assert.strictEqual(entry?.length, 1);
  });

  // inefficient method only for testing
  class TestNodeFileSource implements Source {
    buffer: ArrayBuffer;
    path: string;
    key: string;
    etag?: string;

    constructor(path: string, key: string) {
      this.path = path;
      this.buffer = fs.readFileSync(path);
      this.key = key;
    }

    getKey() {
      return this.key;
    }

    replaceData(path: string) {
      this.path = path;
      this.buffer = fs.readFileSync(path);
    }

    async getBytes(offset: number, length: number): Promise<RangeResponse> {
      const slice = new Uint8Array(this.buffer.slice(offset, offset + length))
        .buffer;
      return { data: slice, etag: this.etag };
    }
  }

  // echo '{"type":"Polygon","coordinates":[[[0,0],[0,1],[1,1],[1,0],[0,0]]]}' | ./tippecanoe -zg -o test_fixture_1.pmtiles
  test("cache getHeader", async () => {
    const source = new TestNodeFileSource(
      "test/data/test_fixture_1.pmtiles",
      "1"
    );
    const cache = new SharedPromiseCache();
    const header = await cache.getHeader(source);
    assert.strictEqual(header.rootDirectoryOffset, 127);
    assert.strictEqual(header.rootDirectoryLength, 25);
    assert.strictEqual(header.jsonMetadataOffset, 152);
    assert.strictEqual(header.jsonMetadataLength, 247);
    assert.strictEqual(header.leafDirectoryOffset, 0);
    assert.strictEqual(header.leafDirectoryLength, 0);
    assert.strictEqual(header.tileDataOffset, 399);
    assert.strictEqual(header.tileDataLength, 69);
    assert.strictEqual(header.numAddressedTiles, 1);
    assert.strictEqual(header.numTileEntries, 1);
    assert.strictEqual(header.numTileContents, 1);
    assert.strictEqual(header.clustered, false);
    assert.strictEqual(header.internalCompression, 2);
    assert.strictEqual(header.tileCompression, 2);
    assert.strictEqual(header.tileType, 1);
    assert.strictEqual(header.minZoom, 0);
    assert.strictEqual(header.maxZoom, 0);
    assert.strictEqual(header.minLon, 0);
    assert.strictEqual(header.minLat, 0);
    // assert.strictEqual(header.maxLon,1); // TODO fix me
    assert.strictEqual(header.maxLat, 1);
  });

  test("getUint64", async () => {
    const view = new DataView(new ArrayBuffer(8));
    view.setBigUint64(0, 0n, true);
    assert.strictEqual(getUint64(view, 0), 0);
    view.setBigUint64(0, 1n, true);
    assert.strictEqual(getUint64(view, 0), 1);
    view.setBigUint64(0, 9007199254740991n, true);
    assert.strictEqual(getUint64(view, 0), 9007199254740991);
  });

  test("cache check against empty", async () => {
    const source = new TestNodeFileSource("test/data/empty.pmtiles", "1");
    const cache = new SharedPromiseCache();
    assert.rejects(async () => {
      await cache.getHeader(source);
    });
  });

  test("cache check magic number", async () => {
    const source = new TestNodeFileSource("test/data/invalid.pmtiles", "1");
    const cache = new SharedPromiseCache();
    assert.rejects(async () => {
      await cache.getHeader(source);
    });
  });

  test("cache check future spec version", async () => {
    const source = new TestNodeFileSource("test/data/invalid_v4.pmtiles", "1");
    const cache = new SharedPromiseCache();
    assert.rejects(async () => {
      await cache.getHeader(source);
    });
  });

  test("cache getDirectory", async () => {
    const source = new TestNodeFileSource(
      "test/data/test_fixture_1.pmtiles",
      "1"
    );

    const cache = new SharedPromiseCache(6400);
    const header = await cache.getHeader(source);

    // prepopulates the root directory
    assert.strictEqual(cache.cache.size, 2);

    const directory = await cache.getDirectory(
      source,
      header.rootDirectoryOffset,
      header.rootDirectoryLength,
      header
    );
    assert.strictEqual(directory.length, 1);
    assert.strictEqual(directory[0].tileId, 0);
    assert.strictEqual(directory[0].offset, 0);
    assert.strictEqual(directory[0].length, 69);
    assert.strictEqual(directory[0].runLength, 1);

    for (const v of cache.cache.values()) {
      assert.ok(v.lastUsed > 0);
    }
  });

  test("multiple sources in a single cache", async () => {
    const cache = new SharedPromiseCache();
    const source1 = new TestNodeFileSource(
      "test/data/test_fixture_1.pmtiles",
      "1"
    );
    const source2 = new TestNodeFileSource(
      "test/data/test_fixture_1.pmtiles",
      "2"
    );
    await cache.getHeader(source1);
    assert.strictEqual(cache.cache.size, 2);
    await cache.getHeader(source2);
    assert.strictEqual(cache.cache.size, 4);
  });

  test("etag change", async () => {
    mockServer.reset();
    const p = new PMTiles("http://localhost:1337/example.pmtiles");
    await p.getZxy(0, 0, 0);
    // header + tile
    assert.strictEqual(2, mockServer.numRequests);
    mockServer.etag = "etag_2";
    await p.getZxy(0, 0, 0);
    // tile + header again + tile
    assert.strictEqual(5, mockServer.numRequests);
  });

  test("weak etags", async () => {
    mockServer.reset();
    const p = new PMTiles("http://localhost:1337/example.pmtiles");
    await p.getZxy(0, 0, 0);
    // header + tile
    assert.strictEqual(2, mockServer.numRequests);
    mockServer.etag = "W/weak_etag";
    await p.getZxy(0, 0, 0);
    assert.strictEqual(3, mockServer.numRequests);
  });

  // handle < 16384 bytes archive case
  // handle DigitalOcean case returning 200 instead of 206

  test("cache pruning by byte size", async () => {
    const cache = new SharedPromiseCache(2);
    cache.cache.set("0", { lastUsed: 0, data: Promise.resolve([]) });
    cache.cache.set("1", { lastUsed: 1, data: Promise.resolve([]) });
    cache.cache.set("2", { lastUsed: 2, data: Promise.resolve([]) });
    cache.prune();
    assert.strictEqual(cache.cache.size, 2);
    assert.ok(cache.cache.get("2"));
    assert.ok(cache.cache.get("1"));
    assert.ok(!cache.cache.get("0"));
  });

  test("pmtiles get metadata", async () => {
    const source = new TestNodeFileSource(
      "test/data/test_fixture_1.pmtiles",
      "1"
    );
    const p = new PMTiles(source);
    const metadata = await p.getMetadata();
    assert.ok((metadata as { name: string }).name);
  });

  // echo '{"type":"Polygon","coordinates":[[[0,0],[0,1],[1,0],[0,0]]]}' | ./tippecanoe -zg -o test_fixture_2.pmtiles

  test("get file extension", async () => {
    assert.equal("", tileTypeExt(TileType.Unknown));
    assert.equal(".mvt", tileTypeExt(TileType.Mvt));
    assert.equal(".png", tileTypeExt(TileType.Png));
    assert.equal(".jpg", tileTypeExt(TileType.Jpeg));
    assert.equal(".webp", tileTypeExt(TileType.Webp));
    assert.equal(".avif", tileTypeExt(TileType.Avif));
    assert.equal(".mlt", tileTypeExt(TileType.Mlt));
  });

  interface TileJsonLike {
    tilejson: string;
    scheme: string;
    tiles: string[];
    description?: string;
    name?: string;
    attribution?: string;
    version?: string;
  }

  test("pmtiles get TileJSON", async () => {
    const source = new TestNodeFileSource(
      "test/data/test_fixture_1.pmtiles",
      "1"
    );
    const p = new PMTiles(source);
    const tilejson = (await p.getTileJson(
      "https://example.com/foo"
    )) as TileJsonLike;
    assert.equal("3.0.0", tilejson.tilejson);
    assert.equal("xyz", tilejson.scheme);
    assert.equal("https://example.com/foo/{z}/{x}/{y}.mvt", tilejson.tiles[0]);
    assert.equal(undefined, tilejson.attribution);
    assert.equal("test_fixture_1.pmtiles", tilejson.description);
    assert.equal("test_fixture_1.pmtiles", tilejson.name);
    assert.equal("2", tilejson.version);
  });

  const originalUserAgent = navigator.userAgent;

  describe("user agent", async () => {
    beforeEach(() => {
      Object.defineProperty(global, "navigator", {
        value: { userAgent: "Windows Chrome" },
        configurable: true,
        writable: true,
      });
    });

    afterEach(() => {
      Object.defineProperty(global, "navigator", {
        value: undefined,
        configurable: true,
        writable: true,
      });
    });

    it("works around caching bug on chrome on windows", async () => {
      const p = new PMTiles("http://localhost:1337/example.pmtiles");
      await p.getZxy(0, 0, 0);
      assert.equal("no-store", mockServer.lastCache);
    });
  });
});
