import { describe, expect, test, beforeAll } from "vitest";

import {
  mockCollection,
  mockDoc,
  mockGet,
  mockWhere,
  mockOffset,
  Timestamp,
  Query,
  DocumentReference,
  FieldPath,
} from "../mocks/firestore";
import { mockFirebase } from "..";
import { type FirebaseUser } from "../mocks/auth";

describe("Queries", () => {
  mockFirebase(
    {
      database: {
        animals: [
          {
            id: "monkey",
            name: "monkey",
            type: "mammal",
            legCount: 2,
            food: ["banana", "mango"],
            foodCount: 1,
            foodEaten: [500, 20],
            createdAt: new Timestamp(1628939119, 0),
            house: {
              street: {
                name: 'Jungle Street'
              }
            }
          },
          {
            id: "elephant",
            name: "elephant",
            type: "mammal",
            legCount: 4,
            food: ["banana", "peanut"],
            foodCount: 0,
            foodEaten: [0, 500],
            createdAt: new Timestamp(1628939129, 0),
          },
          {
            id: "chicken",
            name: "chicken",
            type: "bird",
            legCount: 2,
            food: ["leaf", "nut", "ant"],
            foodCount: 4,
            foodEaten: [80, 20, 16],
            createdAt: new Timestamp(1628939139, 0),
            _collections: {
              foodSchedule: [
                {
                  id: "nut",
                  interval: "whenever",
                },
                {
                  id: "leaf",
                  interval: "hourly",
                },
              ],
            },
          },
          {
            id: "ant",
            name: "ant",
            type: "insect",
            legCount: 6,
            food: ["leaf", "bread"],
            foodCount: 2,
            foodEaten: [80, 12],
            createdAt: new Timestamp(1628939149, 0),
            _collections: {
              foodSchedule: [
                {
                  id: "leaf",
                  interval: "daily",
                },
                {
                  id: "peanut",
                  interval: "weekly",
                },
              ],
            },
          },
          {
            id: "worm",
            name: "worm",
            legCount: null,
          },
          {
            id: "pogo-stick",
            name: "pogo-stick",
            food: false,
          },
        ],
        foodSchedule: [
          { id: "ants", interval: "daily" },
          { id: "cows", interval: "twice daily" },
        ],
        nested: [
          {
            id: "collections",
            _collections: {
              have: [
                {
                  id: "lots",
                  _collections: {
                    of: [
                      {
                        id: "applications",
                        _collections: {
                          foodSchedule: [
                            {
                              id: "layer4_a",
                              interval: "daily",
                            },
                            {
                              id: "layer4_b",
                              interval: "weekly",
                            },
                          ],
                        },
                      },
                    ],
                  },
                },
              ],
            },
          },
        ],
      },
      currentUser: { uid: "homer-user" } as FirebaseUser,
    },
    { simulateQueryFilters: true }
  );

  let firebase;
  let db;

  beforeAll(async () => {
    firebase = await import("firebase");
    firebase.initializeApp({
      apiKey: "### FIREBASE API KEY ###",
      authDomain: "### FIREBASE AUTH DOMAIN ###",
      projectId: "### CLOUD FIRESTORE PROJECT ID ###",
    });

    db = firebase.firestore();
  });

  test("it can query a single document", async () => {
    const monkey = await db.collection("animals").doc("monkey").get();

    expect(monkey).toHaveProperty("exists", true);
    expect(mockCollection).toHaveBeenCalledWith("animals");
    expect(mockDoc).toHaveBeenCalledWith("monkey");
    expect(mockGet).toHaveBeenCalled();
  });

  test("it can query null values", async () => {
    const noLegs = await db
      .collection("animals")
      .where("legCount", "==", null)
      .get();

    expect(noLegs).toHaveProperty("size", 1);
    const worm = noLegs.docs[0];
    expect(worm).toBeDefined();
    expect(worm).toHaveProperty("id", "worm");
  });

  test("it can query by document id", async () => {
    const monkeys = await db.collection("animals").where(FieldPath.documentId(), "==", "monkey").get();

    expect(monkeys).toHaveProperty("size", 1);
    const monkey = monkeys.docs[0];
    expect(monkey).toBeDefined();
    expect(monkey).toHaveProperty("id", "monkey")
  });

  test("it can query false values", async () => {
    const noFood = await db
      .collection("animals")
      .where("food", "==", false)
      .get();

    expect(noFood).toHaveProperty("size", 1);
    const pogoStick = noFood.docs[0];
    expect(pogoStick).toBeDefined();
    expect(pogoStick).toHaveProperty("id", "pogo-stick");
  });

  test('it can query nested values', async () => {
    const street = await db
      .collection("animals")
      .where("house.street.name", "==", "Jungle Street")
      .get();

    expect(street).toHaveProperty("size", 1);
    const monkey = street.docs[0];
    expect(monkey).toBeDefined();
    expect(monkey).toHaveProperty("id", "monkey");
    expect(monkey.data()).toHaveProperty("house.street.name", "Jungle Street");
  })

  test("it can query date values for equality", async () => {
    const elephant = await db
      .collection("animals")
      .where("createdAt", "==", new Date(1628939129 * 1000))
      .get();

    expect(elephant).toHaveProperty("size", 1);
    expect(elephant.docs[0].id).toEqual("elephant");
  });

  test("it can query date values for greater than condition", async () => {
    const res = await db
      .collection("animals")
      .where("createdAt", ">", new Date(1628939129 * 1000))
      .get();

    expect(res).toHaveProperty("size", 2);
    expect(res.docs[0].id).toEqual("chicken");
    expect(res.docs[1].id).toEqual("ant");
  });

  test("it can query multiple documents", async () => {
    expect.assertions(9);
    const animals = await db
      .collection("animals")
      .where("type", "==", "mammal")
      .get();

    expect(animals).toHaveProperty("docs", expect.any(Array));
    expect(mockCollection).toHaveBeenCalledWith("animals");

    // Make sure that the filter behaves appropriately
    expect(animals.docs.length).toBe(2);

    // Make sure that forEach works properly
    expect(animals).toHaveProperty("forEach", expect.any(Function));
    animals.forEach((doc) => {
      // this should run 4 times, as asserted by `expect.assertions` above
      expect(doc).toHaveProperty("exists", true);
    });

    expect(mockWhere).toHaveBeenCalledWith("type", "==", "mammal");
    expect(mockGet).toHaveBeenCalled();
    expect(animals).toHaveProperty("size", 2); // Returns 2 of 4 documents
  });

  test("it can filter firestore equality queries in subcollections", async () => {
    const antSchedule = await db
      .collection("animals")
      .doc("ant")
      .collection("foodSchedule")
      .where("interval", "==", "daily")
      .get();

    expect(mockCollection).toHaveBeenCalledWith("animals");
    expect(mockCollection).toHaveBeenCalledWith("foodSchedule");
    expect(mockWhere).toHaveBeenCalledWith("interval", "==", "daily");
    expect(mockGet).toHaveBeenCalled();
    expect(antSchedule).toHaveProperty("docs", expect.any(Array));
    expect(antSchedule).toHaveProperty("size", 1); // Returns 1 of 2 documents
  });

  test("in a transaction, it can filter firestore equality queries in subcollections", async () => {
    mockGet.mockReset();

    const antSchedule = db
      .collection("animals")
      .doc("ant")
      .collection("foodSchedule")
      .where("interval", "==", "daily");

    expect.assertions(6);
    await db.runTransaction(async (transaction) => {
      const scheduleItems = await transaction.get(antSchedule);
      expect(mockCollection).toHaveBeenCalledWith("animals");
      expect(mockCollection).toHaveBeenCalledWith("foodSchedule");
      expect(mockWhere).toHaveBeenCalledWith("interval", "==", "daily");
      expect(mockGet).not.toHaveBeenCalled();
      expect(scheduleItems).toHaveProperty("docs", expect.any(Array));
      expect(scheduleItems).toHaveProperty("size", 1); // Returns 1 of 2 documents
    });
  });

  test("it can filter firestore comparison queries in subcollections", async () => {
    const chickenSchedule = db
      .collection("animals")
      .doc("chicken")
      .collection("foodSchedule")
      .where("interval", "<=", "hourly"); // should have 1 result

    const scheduleItems = await chickenSchedule.get();
    expect(scheduleItems).toHaveProperty("docs", expect.any(Array));
    expect(scheduleItems).toHaveProperty("size", 1); // Returns 1 document
    expect(scheduleItems.docs[0]).toHaveProperty(
      "ref",
      expect.any(DocumentReference)
    );
    expect(scheduleItems.docs[0]).toHaveProperty("id", "leaf");
    expect(scheduleItems.docs[0].data()).toHaveProperty("interval", "hourly");
    expect(scheduleItems.docs[0].ref).toHaveProperty(
      "path",
      "animals/chicken/foodSchedule/leaf"
    );
  });

  test("in a transaction, it can filter firestore comparison queries in subcollections", async () => {
    const chickenSchedule = db
      .collection("animals")
      .doc("chicken")
      .collection("foodSchedule")
      .where("interval", "<=", "hourly"); // should have 1 result

    expect.assertions(6);
    await db.runTransaction(async (transaction) => {
      const scheduleItems = await transaction.get(chickenSchedule);
      expect(scheduleItems).toHaveProperty("docs", expect.any(Array));
      expect(scheduleItems).toHaveProperty("size", 1); // Returns 1 document
      expect(scheduleItems.docs[0]).toHaveProperty(
        "ref",
        expect.any(DocumentReference)
      );
      expect(scheduleItems.docs[0]).toHaveProperty("id", "leaf");
      expect(scheduleItems.docs[0].data()).toHaveProperty("interval", "hourly");
      expect(scheduleItems.docs[0].ref).toHaveProperty(
        "path",
        "animals/chicken/foodSchedule/leaf"
      );
    });
  });

  test("it can query collection groups", async () => {
    const allSchedules = await db.collectionGroup("foodSchedule").get();

    expect(allSchedules).toHaveProperty("size", 8); // Returns all 8
    const paths = allSchedules.docs.map((doc) => doc.ref.path).sort();
    const expectedPaths = [
      "nested/collections/have/lots/of/applications/foodSchedule/layer4_a",
      "nested/collections/have/lots/of/applications/foodSchedule/layer4_b",
      "animals/ant/foodSchedule/leaf",
      "animals/ant/foodSchedule/peanut",
      "animals/chicken/foodSchedule/leaf",
      "animals/chicken/foodSchedule/nut",
      "foodSchedule/ants",
      "foodSchedule/cows",
    ].sort();
    expect(paths).toStrictEqual(expectedPaths);
  });

  test("it returns the same instance from query methods", () => {
    const ref = db.collection("animals");
    const notThisRef = db.collection("elsewise");
    expect(ref.where("type", "==", "mammal")).toBe(ref);
    expect(ref.where("type", "==", "mammal")).not.toBe(notThisRef);
    expect(ref.limit(1)).toBe(ref);
    expect(ref.limit(1)).not.toBe(notThisRef);
    expect(ref.orderBy("type")).toBe(ref);
    expect(ref.orderBy("type")).not.toBe(notThisRef);
    expect(ref.startAfter(null)).toBe(ref);
    expect(ref.startAfter(null)).not.toBe(notThisRef);
    expect(ref.startAt(null)).toBe(ref);
    expect(ref.startAt(null)).not.toBe(notThisRef);
  });

  test("it returns a Query from query methods", () => {
    const ref = db.collection("animals");
    expect(ref.where("type", "==", "mammal")).toBeInstanceOf(Query);
    expect(ref.limit(1)).toBeInstanceOf(Query);
    expect(ref.orderBy("type")).toBeInstanceOf(Query);
    expect(ref.startAfter(null)).toBeInstanceOf(Query);
    expect(ref.startAt(null)).toBeInstanceOf(Query);
  });

  test("it throws an error when comparing to null", () => {
    expect(() =>
      db.collection("animals").where("legCount", ">", null)
    ).toThrow();
    expect(() =>
      db.collection("animals").where("legCount", ">=", null)
    ).toThrow();
    expect(() =>
      db.collection("animals").where("legCount", "<", null)
    ).toThrow();
    expect(() =>
      db.collection("animals").where("legCount", "<=", null)
    ).toThrow();
    expect(() =>
      db.collection("animals").where("legCount", "array-contains", null)
    ).toThrow();
    expect(() =>
      db.collection("animals").where("legCount", "array-contains-any", null)
    ).toThrow();
    expect(() =>
      db.collection("animals").where("legCount", "in", null)
    ).toThrow();
    expect(() =>
      db.collection("animals").where("legCount", "not-in", null)
    ).toThrow();
  });

  test("it allows equality comparisons with null", () => {
    expect(() =>
      db.collection("animals").where("legCount", "==", null)
    ).not.toThrow();
    expect(() =>
      db.collection("animals").where("legCount", "!=", null)
    ).not.toThrow();
  });

  test("it permits mocking the results of a where clause", async () => {
    expect.assertions(2);
    const ref = db.collection("animals");

    let result = await ref.where("type", "==", "mammal").get();
    expect(result.docs.length).toBe(2);

    // There's got to be a better way to mock like this, but at least it works.
    mockWhere.mockReturnValueOnce({
      async get() {
        return await Promise.resolve({
          docs: [
            { id: "monkey", name: "monkey", type: "mammal" },
            { id: "elephant", name: "elephant", type: "mammal" },
          ],
        });
      },
    });
    result = await ref.where("type", "==", "mammal").get();
    expect(result.docs.length).toBe(2);
  });

  test("it can offset query", async () => {
    const firstTwoMammals = await db
      .collection("animals")
      .where("type", "==", "mammal")
      .offset(2)
      .get();

    expect(firstTwoMammals).toHaveProperty("docs", expect.any(Array));
    expect(mockWhere).toHaveBeenCalledWith("type", "==", "mammal");
    expect(mockCollection).toHaveBeenCalledWith("animals");
    expect(mockGet).toHaveBeenCalled();
    expect(mockOffset).toHaveBeenCalledWith(2);
  });

  describe("Query Operations", () => {
    test.each`
      comp        | value     | count
      ${"=="}     | ${2}      | ${2}
      ${"=="}     | ${4}      | ${1}
      ${"=="}     | ${6}      | ${1}
      ${"=="}     | ${7}      | ${0}
      ${"!="}     | ${7}      | ${5}
      ${"!="}     | ${4}      | ${4}
      ${">"}      | ${1000}   | ${0}
      ${">"}      | ${1}      | ${4}
      ${">"}      | ${6}      | ${0}
      ${">="}     | ${1000}   | ${0}
      ${">="}     | ${6}      | ${1}
      ${">="}     | ${0}      | ${4}
      ${"<"}      | ${-10000} | ${0}
      ${"<"}      | ${10000}  | ${4}
      ${"<"}      | ${2}      | ${0}
      ${"<"}      | ${6}      | ${3}
      ${"<="}     | ${-10000} | ${0}
      ${"<="}     | ${10000}  | ${4}
      ${"<="}     | ${2}      | ${2}
      ${"<="}     | ${6}      | ${4}
      ${"in"}     | ${[6, 2]} | ${3}
      ${"not-in"} | ${[6, 2]} | ${2}
      ${"not-in"} | ${[4]}    | ${4}
      ${"not-in"} | ${[7]}    | ${5}
    `(
      // eslint-disable-next-line quotes
      "it performs '$comp' queries on number values ($count doc(s) where legCount $comp $value)",
      async ({ comp, value, count }) => {
        const results = await db
          .collection("animals")
          .where("legCount", comp, value)
          .get();
        expect(results.size).toBe(count);
      }
    );

    test.each`
      comp        | value     | count
      ${"=="}     | ${0}      | ${1}
      ${"=="}     | ${1}      | ${1}
      ${"=="}     | ${2}      | ${1}
      ${"=="}     | ${4}      | ${1}
      ${"=="}     | ${6}      | ${0}
      ${">"}      | ${-1}     | ${4}
      ${">"}      | ${0}      | ${3}
      ${">"}      | ${1}      | ${2}
      ${">"}      | ${4}      | ${0}
      ${">="}     | ${6}      | ${0}
      ${">="}     | ${4}      | ${1}
      ${">="}     | ${0}      | ${4}
      ${"<"}      | ${2}      | ${2}
      ${"<"}      | ${6}      | ${4}
      ${"<="}     | ${2}      | ${3}
      ${"<="}     | ${6}      | ${4}
      ${"in"}     | ${[2, 0]} | ${2}
      ${"not-in"} | ${[2, 0]} | ${2}
    `(
      // eslint-disable-next-line quotes
      "it performs '$comp' queries on number values that may be zero ($count doc(s) where foodCount $comp $value)",
      async ({ comp, value, count }) => {
        const results = await db
          .collection("animals")
          .where("foodCount", comp, value)
          .get();
        expect(results.size).toBe(count);
      }
    );

    test.each`
      comp        | value                      | count
      ${"=="}     | ${"mammal"}                | ${2}
      ${"=="}     | ${"bird"}                  | ${1}
      ${"=="}     | ${"fish"}                  | ${0}
      ${"!="}     | ${"bird"}                  | ${3}
      ${"!="}     | ${"fish"}                  | ${4}
      ${">"}      | ${"insect"}                | ${2}
      ${">"}      | ${"z"}                     | ${0}
      ${">="}     | ${"mammal"}                | ${2}
      ${">="}     | ${"insect"}                | ${3}
      ${"<"}      | ${"bird"}                  | ${0}
      ${"<"}      | ${"mammal"}                | ${2}
      ${"<="}     | ${"mammal"}                | ${4}
      ${"<="}     | ${"bird"}                  | ${1}
      ${"<="}     | ${"a"}                     | ${0}
      ${"in"}     | ${["a", "bird", "mammal"]} | ${3}
      ${"not-in"} | ${["a", "bird", "mammal"]} | ${1}
    `(
      // eslint-disable-next-line quotes
      "it performs '$comp' queries on string values ($count doc(s) where type $comp '$value')",
      async ({ comp, value, count }) => {
        const results = await db
          .collection("animals")
          .where("type", comp, value)
          .get();
        expect(results.size).toBe(count);
      }
    );

    test.each`
      comp                    | value                            | count
      ${"=="}                 | ${["banana", "mango"]}           | ${1}
      ${"=="}                 | ${["mango", "banana"]}           | ${0}
      ${"=="}                 | ${["banana", "peanut"]}          | ${1}
      ${"!="}                 | ${["banana", "peanut"]}          | ${4}
      ${"array-contains"}     | ${"banana"}                      | ${2}
      ${"array-contains"}     | ${"leaf"}                        | ${2}
      ${"array-contains"}     | ${"bread"}                       | ${1}
      ${"array-contains-any"} | ${["banana", "mango", "peanut"]} | ${2}
    `(
      // eslint-disable-next-line quotes
      "it performs '$comp' queries on array values ($count doc(s) where food $comp '$value')",
      async ({ comp, value, count }) => {
        const results = await db
          .collection("animals")
          .where("food", comp, value)
          .get();
        expect(results.size).toBe(count);
      }
    );

    test.each`
      comp                    | value           | count
      ${"=="}                 | ${[500, 20]}    | ${1}
      ${"=="}                 | ${[20, 500]}    | ${0}
      ${"=="}                 | ${[0, 500]}     | ${1}
      ${"!="}                 | ${[20, 500]}    | ${4}
      ${"array-contains"}     | ${500}          | ${2}
      ${"array-contains"}     | ${80}           | ${2}
      ${"array-contains"}     | ${12}           | ${1}
      ${"array-contains"}     | ${0}            | ${1}
      ${"array-contains-any"} | ${[0, 11, 500]} | ${2}
    `(
      // eslint-disable-next-line quotes
      "it performs '$comp' queries on array values that may be zero ($count doc(s) where foodEaten $comp '$value')",
      async ({ comp, value, count }) => {
        const results = await db
          .collection("animals")
          .where("foodEaten", comp, value)
          .get();
        expect(results.size).toBe(count);
      }
    );
  });
});
