import assert from "node:assert";
import { describe, test } from "node:test";
import MapTree from "../src/drivers/MapTree.js";
import { DeepObjectTree, ObjectTree, Tree } from "../src/internal.js";
import * as symbols from "../src/symbols.js";

describe("Tree", () => {
  test("assign applies one tree to another", async () => {
    const target = new DeepObjectTree({
      a: 1,
      b: 2,
      more: {
        d: 3,
      },
    });

    const source = new DeepObjectTree({
      a: 4, // Overwrite existing value
      b: undefined, // Delete
      c: 5, // Add
      more: {
        // Should leave existing `more` keys alone.
        e: 6, // Add
      },
      // Add new subtree
      extra: {
        f: 7,
      },
    });

    // Apply changes.
    const result = await Tree.assign(target, source);

    assert.equal(result, target);
    const plain = await Tree.plain(target);
    assert.deepEqual(plain, {
      a: 4,
      c: 5,
      more: {
        d: 3,
        e: 6,
      },
      extra: {
        f: 7,
      },
    });
  });

  test("assign() can apply updates to an array", async () => {
    const target = new ObjectTree(["a", "b", "c"]);
    await Tree.assign(target, ["d", "e"]);
    assert.deepEqual(await Tree.plain(target), ["d", "e", "c"]);
  });

  test("clear() removes all values", async () => {
    const fixture = createFixture();
    await Tree.clear(fixture);
    assert.deepEqual(Array.from(await Tree.entries(fixture)), []);
  });

  test("entries() returns the [key, value] pairs", async () => {
    const fixture = createFixture();
    assert.deepEqual(Array.from(await Tree.entries(fixture)), [
      ["Alice.md", "Hello, **Alice**."],
      ["Bob.md", "Hello, **Bob**."],
      ["Carol.md", "Hello, **Carol**."],
    ]);
  });

  test("forEach() invokes a callback for each entry", async () => {
    const fixture = createFixture();
    const results = {};
    await Tree.forEach(fixture, async (value, key) => {
      results[key] = value;
    });
    assert.deepEqual(results, {
      "Alice.md": "Hello, **Alice**.",
      "Bob.md": "Hello, **Bob**.",
      "Carol.md": "Hello, **Carol**.",
    });
  });

  test("from() returns an async tree as is", async () => {
    const tree1 = new ObjectTree({
      a: "Hello, a.",
    });
    const tree2 = Tree.from(tree1);
    assert.equal(tree2, tree1);
  });

  test("from() uses an object's unpack() method if defined", async () => {
    const obj = new String();
    /** @type {any} */ (obj).unpack = () => ({
      a: "Hello, a.",
    });
    const tree = Tree.from(obj);
    assert.deepEqual(await Tree.plain(tree), {
      a: "Hello, a.",
    });
  });

  test("from returns a deep object tree if deep option is true", async () => {
    const obj = {
      sub: {
        a: 1,
      },
    };
    const tree = Tree.from(obj, { deep: true });
    assert(tree instanceof DeepObjectTree);
  });

  test("from returns a deep object tree if object has [deep] symbol set", async () => {
    const obj = {
      sub: {
        a: 1,
      },
    };
    Object.defineProperty(obj, symbols.deep, { value: true });
    const tree = Tree.from(obj);
    assert(tree instanceof DeepObjectTree);
  });

  test("from() creates a deferred tree if unpack() returns a promise", async () => {
    const obj = new String();
    /** @type {any} */ (obj).unpack = async () => ({
      a: "Hello, a.",
    });
    const tree = Tree.from(obj);
    assert.deepEqual(await Tree.plain(tree), {
      a: "Hello, a.",
    });
  });

  test("from() autoboxes primitive values", async () => {
    const tree = Tree.from("Hello, world.");
    const slice = await tree.get("slice");
    const result = await slice(0, 5);
    assert.equal(result, "Hello");
  });

  test("has returns true if the key exists", async () => {
    const fixture = createFixture();
    assert.equal(await Tree.has(fixture, "Alice.md"), true);
    assert.equal(await Tree.has(fixture, "David.md"), false);
  });

  test("isAsyncTree returns true if the object is a tree", () => {
    const missingGetAndKeys = {};
    assert(!Tree.isAsyncTree(missingGetAndKeys));

    const missingIterator = {
      async get() {},
    };
    assert(!Tree.isAsyncTree(missingIterator));

    const missingGet = {
      async keys() {},
    };
    assert(!Tree.isAsyncTree(missingGet));

    const hasGetAndKeys = {
      async get() {},
      async keys() {},
    };
    assert(Tree.isAsyncTree(hasGetAndKeys));
  });

  test("isAsyncMutableTree returns true if the object is a mutable tree", () => {
    assert.equal(
      Tree.isAsyncMutableTree({
        get() {},
        keys() {},
      }),
      false
    );
    assert.equal(Tree.isAsyncMutableTree(createFixture()), true);
  });

  test("isTreelike() returns true if the argument can be cast to an async tree", () => {
    assert(!Tree.isTreelike(null));
    assert(Tree.isTreelike({}));
    assert(Tree.isTreelike([]));
    assert(Tree.isTreelike(new Map()));
    assert(Tree.isTreelike(new Set()));
  });

  test("map() maps values", async () => {
    const tree = new DeepObjectTree({
      a: "Alice",
      more: {
        b: "Bob",
      },
    });
    const mapped = Tree.map(tree, {
      deep: true,
      value: (value) => value.toUpperCase(),
    });
    assert.deepEqual(await Tree.plain(mapped), {
      a: "ALICE",
      more: {
        b: "BOB",
      },
    });
  });

  test("mapReduce() can map values and reduce them", async () => {
    const tree = new DeepObjectTree({
      a: 1,
      b: 2,
      more: {
        c: 3,
      },
      d: 4,
    });
    const reduced = await Tree.mapReduce(
      tree,
      (value) => value,
      async (values) => String.prototype.concat(...values)
    );
    assert.deepEqual(reduced, "1234");
  });

  test("paths returns an array of paths to the values in the tree", async () => {
    const tree = new DeepObjectTree({
      a: 1,
      b: 2,
      c: {
        d: 3,
        e: 4,
      },
    });
    assert.deepEqual(await Tree.paths(tree), ["a", "b", "c/d", "c/e"]);
  });

  test("plain() produces a plain object version of a tree", async () => {
    const tree = new ObjectTree({
      a: 1,
      // Slashes should be normalized
      "sub1/": {
        b: 2,
      },
      sub2: {
        c: 3,
      },
    });
    const plain = await Tree.plain(tree);
    assert.deepEqual(plain, {
      a: 1,
      sub1: {
        b: 2,
      },
      sub2: {
        c: 3,
      },
    });
  });

  test("plain() produces an array for an array-like tree", async () => {
    const original = ["a", "b", "c"];
    const tree = new ObjectTree(original);
    const plain = await Tree.plain(tree);
    assert.deepEqual(plain, original);
  });

  test("plain() leaves an array-like tree as an object if keys aren't consecutive", async () => {
    const original = {
      0: "a",
      1: "b",
      // missing
      3: "c",
    };
    const tree = new ObjectTree(original);
    const plain = await Tree.plain(tree);
    assert.deepEqual(plain, original);
  });

  test("plain() returns empty array or object for ObjectTree as necessary", async () => {
    const tree = new ObjectTree({});
    assert.deepEqual(await Tree.plain(tree), {});
    const arrayTree = new ObjectTree([]);
    assert.deepEqual(await Tree.plain(arrayTree), []);
  });

  test("plain() awaits async properties", async () => {
    const object = {
      get name() {
        return Promise.resolve("Alice");
      },
    };
    assert.deepEqual(await Tree.plain(object), { name: "Alice" });
  });

  test("plain() coerces TypedArray values to strings", async () => {
    const tree = new ObjectTree({
      a: new TextEncoder().encode("Hello, world."),
    });
    const plain = await Tree.plain(tree);
    assert.equal(plain.a, "Hello, world.");
  });

  test("remove method removes a value", async () => {
    const fixture = createFixture();
    await Tree.remove(fixture, "Alice.md");
    assert.deepEqual(Array.from(await Tree.entries(fixture)), [
      ["Bob.md", "Hello, **Bob**."],
      ["Carol.md", "Hello, **Carol**."],
    ]);
  });

  test("toFunction returns a function that invokes a tree's get() method", async () => {
    const tree = new ObjectTree({
      a: 1,
      b: 2,
    });
    const fn = Tree.toFunction(tree);
    assert.equal(await fn("a"), 1);
    assert.equal(await fn("b"), 2);
  });

  test("traverse() a path of keys", async () => {
    const tree = new ObjectTree({
      a1: 1,
      a2: {
        b1: 2,
        b2: {
          c1: 3,
          c2: 4,
        },
      },
    });
    assert.equal(await Tree.traverse(tree), tree);
    assert.equal(await Tree.traverse(tree, "a1"), 1);
    assert.equal(await Tree.traverse(tree, "a2", "b2", "c2"), 4);
    assert.equal(
      await Tree.traverse(tree, "a2", "doesntexist", "c2"),
      undefined
    );
  });

  test("traverse() a function with fixed number of arguments", async () => {
    const tree = (a, b) => ({
      c: "Result",
    });
    assert.equal(await Tree.traverse(tree, "a", "b", "c"), "Result");
  });

  test("traverse() from one tree into another", async () => {
    const tree = new ObjectTree({
      a: {
        b: new MapTree([
          ["c", "Hello"],
          ["d", "Goodbye"],
        ]),
      },
    });
    assert.equal(await Tree.traverse(tree, "a", "b", "c"), "Hello");
  });

  test("traversePath() traverses a slash-separated path", async () => {
    const tree = new ObjectTree({
      a: {
        b: {
          c: "Hello",
        },
      },
    });
    assert.equal(await Tree.traversePath(tree, "a/b/c"), "Hello");
  });

  test("values() returns the store's values", async () => {
    const fixture = createFixture();
    assert.deepEqual(Array.from(await Tree.values(fixture)), [
      "Hello, **Alice**.",
      "Hello, **Bob**.",
      "Hello, **Carol**.",
    ]);
  });
});

function createFixture() {
  return new ObjectTree({
    "Alice.md": "Hello, **Alice**.",
    "Bob.md": "Hello, **Bob**.",
    "Carol.md": "Hello, **Carol**.",
  });
}
