import { beforeEach, describe, expect, vi, test } from "vitest";

import {
  CollectionReference,
  DocumentReference,
  FakeFirestore,
  mockCollection,
  mockDoc,
  Timestamp,
} from "../mocks/firestore";

describe("Queries", () => {
  beforeEach(() => {
    vi.resetModules();
    vi.clearAllMocks();
  });

  const db = (simulateQueryFilters = false): FakeFirestore =>
    new FakeFirestore(
      {
        characters: [
          {
            id: "homer",
            name: "Homer",
            occupation: "technician",
            address: { street: "742 Evergreen Terrace" },
            // May 12, 1956.  Conveniently, a negative number
            birthdate: {
              seconds: -430444800,
              nanoseconds: 0,
            },
            // Test a pre-constructed Timestamp
            timestamp: new Timestamp(123, 456),
          },
          { id: "krusty", name: "Krusty", occupation: "clown" },
          {
            id: "bob",
            name: "Bob",
            occupation: "insurance agent",
            _collections: {
              family: [
                { id: "violet", name: "Violet", relation: "daughter" },
                { id: "dash", name: "Dash", relation: "son" },
                { id: "jackjack", name: "Jackjack", relation: "son" },
                { id: "helen", name: "Helen", relation: "wife" },
              ],
            },
          },
        ],
        checkEmpty: [
          {
            id: "emptyDocument",
            _collections: {
              validChildren: {
                family: [
                  { id: "1", name: "One" },
                  { id: "2", name: "Two" },
                  { id: "3", name: "Three" },
                ],
              },
            },
          },
        ],
      },
      { simulateQueryFilters }
    );

  describe("Single records versus queries", () => {
    test("it can fetch a single record", async () => {
      expect.assertions(6);
      const record = await db().collection("characters").doc("krusty").get();
      expect(mockCollection).toHaveBeenCalledWith("characters");
      expect(mockDoc).toHaveBeenCalledWith("krusty");
      expect(record.exists).toBe(true);
      expect(record.id).toBe("krusty");
      const data = record.data();
      expect(data).toHaveProperty("name", "Krusty");
      expect(data).toHaveProperty("occupation", "clown");
    });

    test("it flags records do not exist", async () => {
      expect.assertions(4);
      const record = await db().collection("animals").doc("monkey").get();
      expect(mockCollection).toHaveBeenCalledWith("animals");
      expect(mockDoc).toHaveBeenCalledWith("monkey");
      expect(record.id).toBe("monkey");
      expect(record.exists).toBe(false);
    });

    test("it can fetch a single record with a promise", async () => {
      await db()
        .collection("characters")
        .doc("homer")
        .get()
        .then((record) => {
          expect(record.exists).toBe(true);
          expect(record.id).toBe("homer");
          expect(mockCollection).toHaveBeenCalledWith("characters");
          const data = record.data();
          expect(record).toHaveProperty("exists", true);
          expect(data).toBeDefined();
          expect(data).toHaveProperty("name", "Homer");
          expect(data).toHaveProperty("occupation", "technician");

          expect(record.get("name")).toEqual("Homer");
          expect(record.get("address.street")).toEqual("742 Evergreen Terrace");
          expect(record.get("address.street.doesntExist")).toBeNull();
        });
    });

    test("it can fetch a single record with a promise without a specified collection", async () => {
      await db()
        .doc("characters/homer")
        .get()
        .then((record) => {
          expect(record.exists).toBe(true);
          expect(record.id).toBe("homer");
          expect(mockCollection).not.toHaveBeenCalled();
          const data = record.data();
          expect(record).toHaveProperty("exists", true);
          expect(data).toBeDefined();
          expect(data).toHaveProperty("name", "Homer");
          expect(data).toHaveProperty("occupation", "technician");
        });
    });

    test("it can fetch multiple records and returns documents", async () => {
      const records = await db()
        .collection("characters")
        .where("name", "==", "Homer")
        .get();

      expect(records.empty).toBe(false);
      expect(records).toHaveProperty("docs", expect.any(Array));
      const doc = records.docs[0];
      expect(doc).toHaveProperty("id", "homer");
      expect(doc).toHaveProperty("exists", true);
      const data = doc.data();
      expect(data).toBeDefined();
      expect(data).toHaveProperty("name", "Homer");
    });

    test("it throws an error if the collection path ends at a document", () => {
      expect(() => db().collection("")).toThrow(Error);
      expect(db().collection("foo")).toBeInstanceOf(CollectionReference);
      expect(() => db().collection("foo/bar")).toThrow(Error);
      expect(db().collection("foo/bar/baz")).toBeInstanceOf(
        CollectionReference
      );
    });

    test("it throws an error if the document path ends at a collection", () => {
      expect(() => db().doc("")).toThrow(Error);
      expect(() => db().doc("characters")).toThrow(Error);
      expect(db().doc("characters/bob")).toBeInstanceOf(DocumentReference);
      expect(() => db().doc("characters/bob/family")).toThrow(Error);
    });

    test("it can fetch nonexistent documents from a root collection", async () => {
      const nope = await db().doc("characters/joe").get();
      expect(nope).toHaveProperty("exists", false);
      expect(nope).toHaveProperty("id", "joe");
      expect(nope).toHaveProperty("ref");
      expect(nope.ref).toHaveProperty("path", "characters/joe");
    });

    test("it can fetch nonexistent documents from extant subcollections", async () => {
      const nope = await db().doc("characters/bob/family/thing3").get();
      expect(nope).toHaveProperty("exists", false);
      expect(nope).toHaveProperty("id", "thing3");
      expect(nope).toHaveProperty("ref");
      expect(nope.ref).toHaveProperty("path", "characters/bob/family/thing3");
    });

    test("it can fetch nonexistent documents from nonexistent subcollections", async () => {
      const nope = await db().doc("characters/sam/family/phil").get();
      expect(nope).toHaveProperty("exists", false);
      expect(nope).toHaveProperty("id", "phil");
      expect(nope).toHaveProperty("ref");
      expect(nope.ref).toHaveProperty("path", "characters/sam/family/phil");
    });

    test("it can fetch nonexistent documents from nonexistent root collections", async () => {
      const nope = await db().doc("foo/bar/baz/bin").get();
      expect(nope).toHaveProperty("exists", false);
      expect(nope).toHaveProperty("id", "bin");
      expect(nope).toHaveProperty("ref");
      expect(nope.ref).toHaveProperty("path", "foo/bar/baz/bin");
    });

    test("it flags when a collection is empty", async () => {
      expect.assertions(1);
      const records = await db()
        .collection("animals")
        .where("type", "==", "mammal")
        .get();
      expect(records).toHaveProperty("empty", true);
    });

    test.each`
      simulateQueryFilters | expectedSize
      ${true}              | ${1}
      ${false}             | ${3}
    `(
      "it can fetch multiple records as a promise",
      async ({ simulateQueryFilters, expectedSize }) => {
        await db(simulateQueryFilters)
          .collection("characters")
          .where("name", "==", "Homer")
          .get()
          .then((records) => {
            expect(records).toHaveProperty("empty", false);
            expect(records).toHaveProperty("docs", expect.any(Array));
            expect(records).toHaveProperty("size", expectedSize);
            expect(records.docs[0]).toHaveProperty("id", "homer");
            expect(records.docs[0]).toHaveProperty("exists", true);
            expect(records.docs[0].data()).toHaveProperty("name", "Homer");
          });
      }
    );

    test("it can return all root records", async () => {
      expect.assertions(4);
      const firstRecord = db().collection("characters").doc("homer");
      const secondRecord = db().collection("characters").doc("krusty");

      const records = await db().getAll(firstRecord, secondRecord);
      expect(records.length).toBe(2);
      expect(records[0]).toHaveProperty("id", "homer");
      expect(records[0]).toHaveProperty("exists", true);
      expect(records[0].data()).toHaveProperty("name", "Homer");
    });

    test("it does not fetch subcollections unless we tell it to", async () => {
      expect.assertions(4);
      const record = await db().collection("characters").doc("bob").get();
      expect(record.exists).toBe(true);
      expect(record.id).toBe("bob");
      expect(record.data()).toHaveProperty("name", "Bob");
      expect(record.data()).not.toHaveProperty("_collections");
    });

    test("it can fetch records from subcollections", async () => {
      expect.assertions(8);
      const family = db()
        .collection("characters")
        .doc("bob")
        .collection("family");
      expect(family.path).toBe("characters/bob/family");

      const allFamilyMembers = await family.get();
      expect(allFamilyMembers.docs.length).toBe(4);
      expect(allFamilyMembers.forEach).toBeTruthy();

      const ref = family.doc("violet");
      expect(ref).toHaveProperty("path", "characters/bob/family/violet");

      const record = await ref.get();
      expect(record).toHaveProperty("exists", true);
      expect(record).toHaveProperty("id", "violet");
      expect(record).toHaveProperty("data");
      expect(record.data()).toHaveProperty("name", "Violet");
    });

    test.each`
      simulateQueryFilters | expectedSize
      ${true}              | ${2}
      ${false}             | ${4}
    `(
      "it can fetch records from subcollections with query parameters",
      async ({ simulateQueryFilters, expectedSize }) => {
        const family = db(simulateQueryFilters)
          .collection("characters")
          .doc("bob")
          .collection("family")
          .where("relation", "==", "son"); // should return only sons
        expect(family).toHaveProperty("path", "characters/bob/family");

        const docs = await family.get();
        expect(docs).toHaveProperty("size", expectedSize);
      }
    );
  });

  describe("Multiple records versus queries", () => {
    test("it fetches all records from a root collection", async () => {
      expect.assertions(4);
      const characters = await db().collection("characters").get();
      expect(characters).toHaveProperty("empty", false);
      expect(characters).toHaveProperty("size", 3);
      expect(Array.isArray(characters.docs)).toBe(true);
      expect(characters.forEach).toBeTruthy();
    });

    test("it fetches no records from nonexistent collection", async () => {
      expect.assertions(4);
      const nope = await db().collection("foo").get();
      expect(nope).toHaveProperty("empty", true);
      expect(nope).toHaveProperty("size", 0);
      expect(Array.isArray(nope.docs)).toBe(true);
      expect(nope.forEach).toBeTruthy();
    });

    test("it fetches all records from subcollection", async () => {
      expect.assertions(4);
      const familyRef = db()
        .collection("characters")
        .doc("bob")
        .collection("family");
      const family = await familyRef.get();
      expect(family).toHaveProperty("empty", false);
      expect(family).toHaveProperty("size", 4);
      expect(Array.isArray(family.docs)).toBe(true);
      expect(family.forEach).toBeTruthy();
    });

    test("it fetches no records from nonexistent subcollection", async () => {
      expect.assertions(4);
      const nope = await db()
        .collection("characters")
        .doc("bob")
        .collection("not-here")
        .get();
      expect(nope).toHaveProperty("empty", true);
      expect(nope).toHaveProperty("size", 0);
      expect(Array.isArray(nope.docs)).toBe(true);
      expect(nope.forEach).toBeTruthy();
    });

    test("it fetches no records from nonexistent root collection", async () => {
      expect.assertions(4);
      const nope = await db()
        .collection("foo")
        .doc("bar")
        .collection("baz")
        .get();
      expect(nope).toHaveProperty("empty", true);
      expect(nope).toHaveProperty("size", 0);
      expect(Array.isArray(nope.docs)).toBe(true);
      expect(nope.forEach).toBeTruthy();
    });
  });

  test("it returns all results from listDocuments", async () => {
    const [emptyDoc] = await db().collection("checkEmpty").listDocuments();
    expect(emptyDoc).toBeDefined();
    const data = await emptyDoc.get();
    expect(data.exists).toBeTruthy();
    // Contains no data
    expect(Object.keys(data.data())).toHaveLength(0);
  });

  test("New documents with random ID", async () => {
    expect.assertions(2);
    // See https://firebase.google.com/docs/reference/js/firebase.firestore.CollectionReference#doc
    // "If no path is specified, an automatically-generated unique ID will be used for the returned DocumentReference."
    const col = db().collection("foo");
    const newDoc = col.doc();
    const otherIds = col._records().map((doc) => doc.id);
    expect(otherIds).not.toContainEqual(newDoc.id);
    expect(newDoc).toHaveProperty("path", `foo/${newDoc.id}`);
  });

  test("it properly converts timestamps", async () => {
    await db()
      .doc("characters/homer")
      .get()
      .then((record) => {
        expect(record.id).toEqual("homer");
        const data = record.data();
        expect(typeof data.birthdate.toDate).toEqual("function");
      });
  });
});
