/**
 * @license
 * Copyright 2017 Google LLC
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import { expect } from 'chai';

import {
  FieldPath,
  getDoc,
  getDocs,
  orderBy,
  query,
  setDoc,
  startAt,
  Timestamp,
  updateDoc,
  where
} from '../util/firebase_export';
import {
  apiDescribe,
  toDataArray,
  withTestCollection,
  withTestCollectionSettings,
  withTestDoc,
  withTestDocAndSettings
} from '../util/helpers';
import { DEFAULT_SETTINGS } from '../util/settings';

// Allow custom types for testing.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyTestData = any;

apiDescribe('Nested Fields', (persistence: boolean) => {
  const testData = (n?: number): AnyTestData => {
    n = n || 1;
    return {
      name: 'room ' + n,
      metadata: {
        createdAt: n,
        deep: {
          field: 'deep-field-' + n
        }
      }
    };
  };

  it('can be written with set()', () => {
    return withTestDoc(persistence, doc => {
      return setDoc(doc, testData())
        .then(() => getDoc(doc))
        .then(docSnap => {
          expect(docSnap.data()).to.deep.equal(testData());
        });
    });
  });

  it('can be read directly with .get(<string>)', () => {
    return withTestDoc(persistence, doc => {
      const obj = testData();
      return setDoc(doc, obj)
        .then(() => getDoc(doc))
        .then(docSnap => {
          expect(docSnap.data()).to.deep.equal(obj);
          expect(docSnap.get('name')).to.deep.equal(obj.name);
          expect(docSnap.get('metadata')).to.deep.equal(obj.metadata);
          expect(docSnap.get('metadata.deep.field')).to.deep.equal(
            obj.metadata.deep.field
          );
          expect(docSnap.get('metadata.nofield')).to.be.undefined;
          expect(docSnap.get('nometadata.nofield')).to.be.undefined;
        });
    });
  });

  it('can be read directly with .get(<FieldPath>)', () => {
    return withTestDoc(persistence, doc => {
      const obj = testData();
      return setDoc(doc, obj)
        .then(() => getDoc(doc))
        .then(docSnap => {
          expect(docSnap.data()).to.deep.equal(obj);
          expect(docSnap.get(new FieldPath('name'))).to.deep.equal(obj.name);
          expect(docSnap.get(new FieldPath('metadata'))).to.deep.equal(
            obj.metadata
          );
          expect(
            docSnap.get(new FieldPath('metadata', 'deep', 'field'))
          ).to.deep.equal(obj.metadata.deep.field);
          expect(docSnap.get(new FieldPath('metadata', 'nofield'))).to.be
            .undefined;
          expect(docSnap.get(new FieldPath('nometadata', 'nofield'))).to.be
            .undefined;
        });
    });
  });

  it('can be updated with update(<string>)', () => {
    return withTestDoc(persistence, doc => {
      return setDoc(doc, testData())
        .then(() =>
          updateDoc(doc, {
            'metadata.deep.field': 100,
            'metadata.added': 200
          })
        )
        .then(() => getDoc(doc))
        .then(docSnap => {
          expect(docSnap.data()).to.deep.equal({
            name: 'room 1',
            metadata: {
              createdAt: 1,
              deep: {
                field: 100
              },
              added: 200
            }
          });
        });
    });
  });

  it('can be updated with update(<FieldPath>)', () => {
    return withTestDoc(persistence, doc => {
      return setDoc(doc, testData())
        .then(() =>
          updateDoc(
            doc,
            new FieldPath('metadata', 'deep', 'field'),
            100,
            new FieldPath('metadata', 'added'),
            200
          )
        )
        .then(() => getDoc(doc))
        .then(docSnap => {
          expect(docSnap.data()).to.deep.equal({
            name: 'room 1',
            metadata: {
              createdAt: 1,
              deep: {
                field: 100
              },
              added: 200
            }
          });
        });
    });
  });

  it('can be used with query.where(<string>).', () => {
    const testDocs = {
      '1': testData(300),
      '2': testData(100),
      '3': testData(200)
    };
    return withTestCollection(persistence, testDocs, coll => {
      return getDocs(query(coll, where('metadata.createdAt', '>=', 200))).then(
        results => {
          // inequality adds implicit sort on field
          expect(toDataArray(results)).to.deep.equal([
            testData(200),
            testData(300)
          ]);
        }
      );
    });
  });

  it('can be used with query.where(<FieldPath>).', () => {
    const testDocs = {
      '1': testData(300),
      '2': testData(100),
      '3': testData(200)
    };
    return withTestCollection(persistence, testDocs, coll => {
      return getDocs(
        query(coll, where(new FieldPath('metadata', 'createdAt'), '>=', 200))
      ).then(results => {
        // inequality adds implicit sort on field
        expect(toDataArray(results)).to.deep.equal([
          testData(200),
          testData(300)
        ]);
      });
    });
  });

  it('can be used with query.orderBy(<string>).', () => {
    const testDocs = {
      '1': testData(300),
      '2': testData(100),
      '3': testData(200)
    };
    return withTestCollection(persistence, testDocs, coll => {
      return getDocs(query(coll, orderBy('metadata.createdAt'))).then(
        results => {
          expect(toDataArray(results)).to.deep.equal([
            testData(100),
            testData(200),
            testData(300)
          ]);
        }
      );
    });
  });

  it('can be used with query.orderBy(<FieldPath>).', () => {
    const testDocs = {
      '1': testData(300),
      '2': testData(100),
      '3': testData(200)
    };
    return withTestCollection(persistence, testDocs, coll => {
      return getDocs(
        query(coll, orderBy(new FieldPath('metadata', 'createdAt')))
      ).then(results => {
        expect(toDataArray(results)).to.deep.equal([
          testData(100),
          testData(200),
          testData(300)
        ]);
      });
    });
  });
});

// NOTE(mikelehen): I originally combined these tests with the above ones, but
// Datastore currently prohibits having nested fields and fields with dots in
// the same entity, so I'm separating them.
apiDescribe('Fields with special characters', (persistence: boolean) => {
  const testData = (n?: number): AnyTestData => {
    n = n || 1;
    return {
      field: 'field ' + n,
      'field.dot': n,
      'field\\slash': n
    };
  };

  it('can be written with set()', () => {
    return withTestDoc(persistence, doc => {
      return setDoc(doc, testData())
        .then(() => getDoc(doc))
        .then(docSnap => {
          expect(docSnap.data()).to.deep.equal(testData());
        });
    });
  });

  it('can be read directly with .data(<field>)', () => {
    return withTestDoc(persistence, doc => {
      const obj = testData();
      return setDoc(doc, obj)
        .then(() => getDoc(doc))
        .then(docSnap => {
          expect(docSnap.data()).to.deep.equal(obj);
          expect(docSnap.get(new FieldPath('field.dot'))).to.deep.equal(
            obj['field.dot']
          );
          expect(docSnap.get('field\\slash')).to.deep.equal(
            obj['field\\slash']
          );
        });
    });
  });

  it('can be updated with update()', () => {
    return withTestDoc(persistence, doc => {
      return setDoc(doc, testData())
        .then(() => {
          return updateDoc(
            doc,
            new FieldPath('field.dot'),
            100,
            'field\\slash',
            200
          );
        })
        .then(() => getDoc(doc))
        .then(docSnap => {
          expect(docSnap.data()).to.deep.equal({
            field: 'field 1',
            'field.dot': 100,
            'field\\slash': 200
          });
        });
    });
  });

  it('can be used in query filters.', () => {
    const testDocs = {
      '1': testData(300),
      '2': testData(100),
      '3': testData(200)
    };
    return withTestCollection(persistence, testDocs, coll => {
      // inequality adds implicit sort on field
      const expected = [testData(200), testData(300)];
      return getDocs(query(coll, where(new FieldPath('field.dot'), '>=', 200)))
        .then(results => {
          expect(toDataArray(results)).to.deep.equal(expected);
        })
        .then(() => getDocs(query(coll, where('field\\slash', '>=', 200))))
        .then(results => {
          expect(toDataArray(results)).to.deep.equal(expected);
        });
    });
  });

  it('can be used in a query orderBy.', () => {
    const testDocs = {
      '1': testData(300),
      '2': testData(100),
      '3': testData(200)
    };
    return withTestCollection(persistence, testDocs, coll => {
      const expected = [testData(100), testData(200), testData(300)];
      return getDocs(query(coll, orderBy(new FieldPath('field.dot'))))
        .then(results => {
          expect(toDataArray(results)).to.deep.equal(expected);
        })
        .then(() => getDocs(query(coll, orderBy('field\\slash'))))
        .then(results => {
          expect(toDataArray(results)).to.deep.equal(expected);
        });
    });
  });
});

apiDescribe('Timestamp Fields in snapshots', (persistence: boolean) => {
  // Figure out how to pass in the Timestamp type
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const testDataWithTimestamps = (ts: any): AnyTestData => {
    return { timestamp: ts, nested: { timestamp2: ts } };
  };

  it('are returned as Timestamps', () => {
    const timestamp = new Timestamp(100, 123456000);
    // Timestamps are currently truncated to microseconds after being written to
    // the database, so a truncated version of the timestamp is needed for
    // comparisons.
    const truncatedTimestamp = new Timestamp(
      timestamp.seconds,
      Math.floor(timestamp.nanoseconds / 1000) * 1000
    );

    return withTestDoc(persistence, doc => {
      return setDoc(doc, testDataWithTimestamps(timestamp))
        .then(() => getDoc(doc))
        .then(docSnap => {
          expect(docSnap.get('timestamp'))
            .to.be.an.instanceof(Timestamp)
            .that.deep.equals(truncatedTimestamp);
          expect(docSnap.data()!['timestamp'])
            .to.be.an.instanceof(Timestamp)
            .that.deep.equals(truncatedTimestamp);

          expect(docSnap.get('nested.timestamp2'))
            .to.be.an.instanceof(Timestamp)
            .that.deep.equals(truncatedTimestamp);
          expect(docSnap.data()!['nested']['timestamp2'])
            .to.be.an.instanceof(Timestamp)
            .that.deep.equals(truncatedTimestamp);
        });
    });
  });
});

apiDescribe('`undefined` properties', (persistence: boolean) => {
  const settings = { ...DEFAULT_SETTINGS };
  settings.ignoreUndefinedProperties = true;

  it('are ignored in set()', () => {
    return withTestDocAndSettings(persistence, settings, async doc => {
      await setDoc(doc, { foo: 'foo', 'bar': undefined });
      const docSnap = await getDoc(doc);
      expect(docSnap.data()).to.deep.equal({ foo: 'foo' });
    });
  });

  it('are ignored in set({ merge: true })', () => {
    return withTestDocAndSettings(persistence, settings, async doc => {
      await setDoc(doc, { foo: 'foo', bar: 'unchanged' });
      await setDoc(doc, { foo: 'foo', bar: undefined }, { merge: true });
      const docSnap = await getDoc(doc);
      expect(docSnap.data()).to.deep.equal({ foo: 'foo', bar: 'unchanged' });
    });
  });

  it('are ignored in update()', () => {
    return withTestDocAndSettings(persistence, settings, async doc => {
      await setDoc(doc, {});
      await updateDoc(doc, { a: { foo: 'foo', 'bar': undefined } });
      await updateDoc(doc, 'b', { foo: 'foo', 'bar': undefined });
      const docSnap = await getDoc(doc);
      expect(docSnap.data()).to.deep.equal({
        a: { foo: 'foo' },
        b: { foo: 'foo' }
      });
    });
  });

  it('are ignored in Query.where()', () => {
    return withTestCollectionSettings(
      persistence,
      settings,
      { 'doc1': { nested: { foo: 'foo' } } },
      async coll => {
        const filteredQuery = query(
          coll,
          where('nested', '==', {
            foo: 'foo',
            'bar': undefined
          })
        );
        const querySnap = await getDocs(filteredQuery);
        expect(querySnap.size).to.equal(1);
      }
    );
  });

  it('are ignored in Query.startAt()', () => {
    return withTestCollectionSettings(
      persistence,
      settings,
      { 'doc1': { nested: { foo: 'foo' } } },
      async coll => {
        const orderedQuery = query(
          coll,
          orderBy('nested'),
          startAt({ foo: 'foo', 'bar': undefined })
        );
        const querySnap = await getDocs(orderedQuery);
        expect(querySnap.size).to.equal(1);
      }
    );
  });
});
