import {delay} from 'app/common/delay';
import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
import {FlexServer} from 'app/server/lib/FlexServer';
import log from 'app/server/lib/log';
import {main as mergedServerMain} from 'app/server/mergedServerMain';
import axios from 'axios';
import {assert} from 'chai';
import * as fse from 'fs-extra';
import {tmpdir} from 'os';
import * as path from 'path';
import * as sinon from 'sinon';
import {TestSession} from 'test/gen-server/apiUtils';
import {createInitialDb, removeConnection, setUpDB} from 'test/gen-server/seed';
import {configForUser, getGristConfig} from 'test/gen-server/testUtils';
import {openClient} from 'test/server/gristClient';
import * as testUtils from 'test/server/testUtils';

async function createTestDir(ident: string): Promise<string> {
  // Create a testDir of the form grist_test_{USER}_{SERVER_NAME}, removing any previous one.
  const username = process.env.USER || "nobody";
  const testDir = path.join(tmpdir(), `grist_test_${username}_${ident}`);
  await fse.remove(testDir);
  return testDir;
}

const chimpy = configForUser('Chimpy');
const kiwi = configForUser('Kiwi');
const charon = configForUser('Charon');
const chimpyEmail = 'chimpy@getgrist.com';
const kiwiEmail = 'kiwi@getgrist.com';
const charonEmail = 'charon@getgrist.com';


describe('AuthCaching', function() {
  this.timeout(10000);
  testUtils.setTmpLogLevel('error');

  let homeServer: FlexServer, docsServer: FlexServer;
  let session: TestSession;
  let homeUrl: string;
  let helloDocId: string;

  const sandbox = sinon.createSandbox();

  before(async function() {
    const testDir = process.env.TESTDIR || await createTestDir('authcaching');
    const testDocDir = path.join(testDir, "data");
    await fse.mkdirs(testDocDir);
    log.warn(`Test logs and data are at: ${testDir}/`);
    setUpDB();
    await createInitialDb();
    process.env.GRIST_DATA_DIR = testDocDir;
    homeServer = await mergedServerMain(0, ['home'],
      {logToConsole: false, externalStorage: false});
    homeUrl = homeServer.getOwnUrl();
    process.env.APP_HOME_URL = homeUrl;
    docsServer = await mergedServerMain(0, ['docs'],
      {logToConsole: false, externalStorage: false});

    // Helpers for getting cookie-based logins.
    session = new TestSession(homeServer);

    // Copy a fixture doc to make it accessible with the given docId.
    helloDocId = (await homeServer.getHomeDBManager().testGetId('Jupiter')) as string;
    const srcPath = path.resolve(testUtils.fixturesRoot, 'docs', 'Hello.grist');
    await fse.copy(srcPath, path.resolve(docsServer.docsRoot, `${helloDocId}.grist`),
                   { dereference: true });

    // Add Kiwi to 'viewers' for this doc.
    const resp = await axios.patch(`${homeUrl}/api/docs/${helloDocId}/access`,
      {delta: {users: {[kiwiEmail]: 'viewers'}}},
      chimpy);
    assert.equal(resp.status, 200);
  });

  after(async function() {
    delete process.env.GRIST_DATA_DIR;
    delete process.env.APP_HOME_URL;
    sandbox.restore();
    await testUtils.captureLog('warn', async () => {
      await docsServer.close();
      await homeServer.close();
      await removeConnection();
    });
  });

  afterEach(async function() {
    sandbox.restore();
  });

  function getDocTracker(dbManager: HomeDBManager) {
    const forced = sandbox.spy(dbManager, "getDoc");
    const cached = sandbox.spy(dbManager, "getDocAuthCached");
    const impl = sandbox.spy(dbManager, "getDocImpl");
    function getCallCounts() {
      return {
        forced: forced.callCount,
        misses: impl.callCount - forced.callCount,
        hits: cached.callCount - (impl.callCount - forced.callCount),
      };
    }
    function reset() {
      forced.resetHistory();
      cached.resetHistory();
      impl.resetHistory();
    }
    function getAndReset() {
      const res = getCallCounts();
      reset();
      return res;
    }
    return {getCallCounts, reset, getAndReset};
  }

  function flushCache() {
    homeServer.getHomeDBManager().flushDocAuthCache();
    docsServer.getHomeDBManager().flushDocAuthCache();
  }

  function getDocCallTracker() {
    return {
      home: getDocTracker(homeServer.getHomeDBManager()),
      docs: getDocTracker(docsServer.getHomeDBManager()),
    };
  }

  it('should not cache direct call for doc metadata', async function() {
    flushCache();
    const getDocCalls = getDocCallTracker();

    const resp = await axios.get(`${homeUrl}/api/docs/${helloDocId}`, chimpy);
    assert.equal(resp.data.name, 'Jupiter');

    // This is a metadata-only call, so only home server is involved.
    assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 1, misses: 0, hits: 0});
    assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 0});

    const resp2 = await axios.get(`${homeUrl}/api/docs/${helloDocId}`, chimpy);
    assert.deepEqual(resp2.data, resp.data);
    assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 1, misses: 0, hits: 0});
    assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 0});
  });

  it('should cache DocApi + DocApiForwarder calls', async function() {
    flushCache();
    const getDocCalls = getDocCallTracker();
    const resp = await axios.get(`${homeUrl}/api/docs/${helloDocId}/tables/Table1/data`, chimpy);
    assert.deepInclude(resp.data, {E: ["HELLO", "", "", ""]});

    assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 1, hits: 0});
    assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 1, hits: 0});

    // Try an endpoint requiring editing permissions.
    const resp2 = await axios.post(`${homeUrl}/api/docs/${helloDocId}/tables/Table1/data`, {A: ['Foo']}, chimpy);
    assert.equal(resp2.status, 200);
    assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 0, hits: 1});
    assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 1});

    const resp3 = await axios.get(`${homeUrl}/api/docs/${helloDocId}/tables/Table1/data`, chimpy);
    assert.deepInclude(resp3.data, {E: ["HELLO", "", "", "", "FOO"]});

    assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 0, hits: 1});
    assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 1});
  });

  it('should cache DocAPI + DocApiForwarder no-access calls', async function() {
    flushCache();
    const getDocCalls = getDocCallTracker();

    // Kiwi has view-only access. Check that it's checked, and is cached too.
    let resp = await axios.post(`${homeUrl}/api/docs/${helloDocId}/tables/Table1/data`, {A: ['Bar']}, kiwi);
    assert.equal(resp.status, 403);
    assert.match(resp.data.error, /No write access/);
    assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 1, hits: 0});
    assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 1, hits: 0});

    // Second call is cached, but otherwise identical.
    resp = await axios.post(`${homeUrl}/api/docs/${helloDocId}/tables/Table1/data`, {A: ['Bar']}, kiwi);
    assert.equal(resp.status, 403);
    assert.match(resp.data.error, /No write access/);
    // The read/write distinction isn't checked by DocApiForwarder, so docsServer sees the request.
    assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 0, hits: 1});
    assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 1});

    // View access works.
    resp = await axios.get(`${homeUrl}/api/docs/${helloDocId}/tables/Table1/data`, kiwi);
    assert.deepInclude(resp.data, {E: ["HELLO", "", "", "", "FOO"]});
    assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 0, hits: 1});
    assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 1});

    // Charon has no access.
    resp = await axios.get(`${homeUrl}/api/docs/${helloDocId}/tables/Table1/data`, charon);
    assert.equal(resp.status, 403);
    assert.match(resp.data.error, /No view access/);
    assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 1, hits: 0});
    assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 0});

    // ...or write access (but the check is cached).
    resp = await axios.post(`${homeUrl}/api/docs/${helloDocId}/tables/Table1/data`, {A: ['Bar']}, charon);
    assert.equal(resp.status, 403);
    assert.match(resp.data.error, /No view access/);
    assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 0, hits: 1});
    // docsServer never sees the request.
    assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 0});
  });

  it('should not cache app.html endpoint', async function() {
    flushCache();
    const getDocCalls = getDocCallTracker();
    const cookie = await session.getCookieLogin('nasa', {email: chimpyEmail, name: 'Chimpy'});

    const resp1 = await axios.get(`${homeUrl}/o/nasa/doc/${helloDocId}`, cookie);

    // gristConfig should include results of the getDoc call.
    const gristConfig = getGristConfig(resp1.data);
    assert.hasAnyKeys(gristConfig.getDoc, [helloDocId]);
    assert.deepInclude(gristConfig.getDoc![helloDocId], {name: 'Jupiter', id: helloDocId});

    // All authentication and getDoc() call are made by homeServer, docsServer not yet in play
    assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 1, misses: 0, hits: 1});
    assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 0});

    // No caching on subsequent call because we force a fresh fetch for this endpoint.
    const resp2 = await axios.get(`${homeUrl}/o/nasa/doc/${helloDocId}`, cookie);
    assert.deepEqual(getGristConfig(resp2.data).getDoc, gristConfig.getDoc);
    assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 1, misses: 0, hits: 1});
    assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 0});
  });

  it('should cache openDoc and websocket methods', async function() {
    flushCache();
    const getDocCalls = getDocCallTracker();

    const cli = await openClient(docsServer, chimpyEmail, 'nasa');
    assert.equal((await cli.readMessage()).type, 'clientConnect');
    const openDoc = await cli.send("openDoc", helloDocId);
    assert.equal(openDoc.error, undefined);
    assert.match(JSON.stringify(openDoc.data), /Table1/);

    assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 1, hits: 0});

    // Read access
    const table = await cli.send("fetchTable", 0, "Table1");
    assert.includeMembers(table.data.tableData, ['TableData', 'Table1']);
    assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 1});

    // Write access
    const auaResult = await cli.send("applyUserActions", 0,
      [["UpdateRecord", "Table1", 1, {A: "auth-caching1"}]]);
    await delay(200); // give a little time for change broadcast.
    assert.isNumber(auaResult.data.actionNum);
    assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 2});

    await cli.close();
  });

  it('should cache openDoc and websocket methods with access failures', async function() {
    flushCache();
    const getDocCalls = getDocCallTracker();

    // Repeat with a view-only user (Kiwi)
    let cli = await openClient(docsServer, kiwiEmail, 'nasa');
    assert.equal((await cli.readMessage()).type, 'clientConnect');
    let openDoc = await cli.send("openDoc", helloDocId);
    assert.equal(openDoc.error, undefined);
    assert.match(JSON.stringify(openDoc.data), /Table1/);

    assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 1, hits: 0});

    // Kiwi has read access
    const table = await cli.send("fetchTable", 0, "Table1");
    assert.includeMembers(table.data.tableData, ['TableData', 'Table1']);
    assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 1});

    // Kiwi has NO write access.
    const auaResult = await cli.send("applyUserActions", 0,
      [["UpdateRecord", "Table1", 1, {A: "auth-caching2"}]]);
    assert.deepEqual(auaResult.error, 'No write access');
    assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 1});

    // Charon has no access at all
    cli = await openClient(docsServer, charonEmail, 'nasa');
    assert.equal((await cli.readMessage()).type, 'clientConnect');
    openDoc = await cli.send("openDoc", helloDocId);
    assert.equal(openDoc.error, 'No view access');

    assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 1, hits: 0});
    await cli.send("openDoc", helloDocId);
    assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 1});

    // Home server wasn't involved in this test case at all.
    assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 0, hits: 0});
  });

  it('should cache across different kinds of calls', async function() {
    // Fetch the document endpoint and follow with openDoc. Caching should apply.
    flushCache();
    const getDocCalls = getDocCallTracker();
    const cookie = await session.getCookieLogin('nasa', {email: chimpyEmail, name: 'Chimpy'});

    // app.html endpoint warms the cache for the home server.
    const resp1 = await axios.get(`${homeUrl}/o/nasa/doc/${helloDocId}`, cookie);
    const gristConfig = getGristConfig(resp1.data);
    assert.hasAnyKeys(gristConfig.getDoc, [helloDocId]);
    assert.deepInclude(gristConfig.getDoc![helloDocId], {name: 'Jupiter', id: helloDocId});

    assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 1, misses: 0, hits: 1});
    assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 0});

    // openDoc call warms the cache for the doc-worker.
    const cli = await openClient(docsServer, chimpyEmail, 'nasa');
    assert.equal((await cli.readMessage()).type, 'clientConnect');
    const openDoc = await cli.send("openDoc", helloDocId);
    assert.equal(openDoc.error, undefined);
    assert.match(JSON.stringify(openDoc.data), /Table1/);

    assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 0, hits: 0});
    assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 1, hits: 0});

    // the caching applies to API calls for the same doc/user/org combination.
    const resp = await axios.get(`${homeUrl}/o/nasa/api/docs/${helloDocId}/tables/Table1/data`, chimpy);
    assert.equal(resp.status, 200);
    assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 0, hits: 1});
    assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 1});
  });

  it('should expire the cache after a timeout', async function() {
    this.timeout(10000);

    // Make an API call; change access; check that after a while, the change is noticed.
    flushCache();
    const getDocCalls = getDocCallTracker();

    // Connect up websockets for Kiwi and Charon.
    const kiwiCli = await openClient(docsServer, kiwiEmail, 'nasa');
    assert.equal((await kiwiCli.readMessage()).type, 'clientConnect');
    const charonCli = await openClient(docsServer, charonEmail, 'nasa');
    assert.equal((await charonCli.readMessage()).type, 'clientConnect');

    // Kiwi has access, Charon doesn't.
    let resp1 = await axios.get(`${homeUrl}/o/nasa/api/docs/${helloDocId}/tables/Table1/data`, kiwi);
    let resp2 = await axios.get(`${homeUrl}/o/nasa/api/docs/${helloDocId}/tables/Table1/data`, charon);
    assert.equal(resp1.status, 200);
    assert.equal(resp2.status, 403);

    // home server sees both calls, but only forwards one to the doc-worker.
    assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 2, hits: 0});
    assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 1, hits: 0});

    assert.equal((await kiwiCli.send("openDoc", helloDocId)).error, undefined);
    assert.equal((await charonCli.send("openDoc", helloDocId)).error, 'No view access');
    assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 0, hits: 0});
    assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 1, hits: 1});

    // Use Chimpy's access to change access for both.
    const resp = await axios.patch(`${homeUrl}/o/nasa/api/docs/${helloDocId}/access`,
      {delta: {users: {[kiwiEmail]: null, [charonEmail]: 'viewers'}}},
      chimpy);
    assert.equal(resp.status, 200);

    // Home's UserAPI methods don't call to getDoc() to check doc-level access, so access checks
    // for Chimpy's patch-access call do not affect our counts.
    assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 0, hits: 0});
    assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 0});

    // The change isn't visible immediately.
    resp1 = await axios.get(`${homeUrl}/o/nasa/api/docs/${helloDocId}/tables/Table1/data`, kiwi);
    resp2 = await axios.get(`${homeUrl}/o/nasa/api/docs/${helloDocId}/tables/Table1/data`, charon);
    assert.equal(resp1.status, 200);
    assert.equal(resp2.status, 403);

    // But eventually it is. Should be within 5 seconds, we try up to 10.
    let passed = false;
    for (let i = 0; i < 50; i++) {
      await delay(200);
      try {
        // Check if access changes are visible yet.
        resp1 = await axios.get(`${homeUrl}/o/nasa/api/docs/${helloDocId}/tables/Table1/data`, kiwi);
        resp2 = await axios.get(`${homeUrl}/o/nasa/api/docs/${helloDocId}/tables/Table1/data`, charon);
        assert.equal(resp1.status, 403);
        assert.equal(resp2.status, 200);
        assert.equal((await kiwiCli.send("openDoc", helloDocId)).error, 'No view access');
        assert.equal((await charonCli.send("openDoc", helloDocId)).error, undefined);
        passed = true;
        break;
      } catch (err) {
        continue;
      }
    }
    assert.isTrue(passed);

    const homeCalls = getDocCalls.home.getAndReset();
    const docsCalls = getDocCalls.docs.getAndReset();
    // There are many cache hits, but one set of misses that discovers the access changes.
    assert.deepInclude(homeCalls, {forced: 0, misses: 2});
    assert.deepInclude(docsCalls, {forced: 0, misses: 2});
    assert.isAbove(homeCalls.hits, 10);
    assert.isAbove(docsCalls.hits, 10);
  });
});
