import 'mocha';
import expect from 'expect';
import { context, ROOT_CONTEXT, SpanStatusCode, trace } from '@opentelemetry/api';
import { Neo4jInstrumentation } from '../src';
import { assertSpan } from './assert';
import { SemanticAttributes } from '@opentelemetry/semantic-conventions';
import { normalizeResponse } from './test-utils';
import { map, mergeMap } from 'rxjs/operators';
import { concat } from 'rxjs';
import { getTestSpans, registerInstrumentationTesting } from '@opentelemetry/contrib-test-utils';

const instrumentation = registerInstrumentationTesting(new Neo4jInstrumentation());
instrumentation.enable();
instrumentation.disable();

import neo4j, { Driver } from 'neo4j-driver';
import { ReadableSpan } from '@opentelemetry/sdk-trace-base';

/**
 * Tests require neo4j to run, and expose bolt port of 11011
 *
 * Use this command to run the required neo4j using docker:
 * docker run --name testneo4j -p7474:7474 -p11011:7687 -d --env NEO4J_AUTH=neo4j/test neo4j:4.2.3
 * */

describe('neo4j instrumentation', function () {
    this.timeout(10000);
    let driver: Driver;

    const getSingleSpan = () => {
        const spans = getTestSpans();
        expect(spans.length).toBe(1);
        return spans[0];
    };

    before(async () => {
        driver = neo4j.driver('bolt://localhost:11011', neo4j.auth.basic('neo4j', 'test'), {
            disableLosslessIntegers: true,
        });

        let keepChecking = true;
        const timeoutId = setTimeout(() => {
            keepChecking = false;
        }, 8000);
        while (keepChecking) {
            try {
                await driver.verifyConnectivity();
                clearTimeout(timeoutId);
                return;
            } catch (err) {
                await new Promise((res) => setTimeout(res, 1000));
            }
        }
        throw new Error('Could not connect to neo4j in allowed time frame');
    });

    after(async () => {
        await driver.close();
    });

    beforeEach(async () => {
        await driver.session().run('MATCH (n) DETACH DELETE n');
        instrumentation.enable();
    });

    afterEach(async () => {
        instrumentation.disable();
        instrumentation.setConfig({});
    });

    describe('session', () => {
        it('instruments "run" with promise', async () => {
            const res = await driver.session().run('CREATE (n:MyLabel) RETURN n');

            expect(res.records.length).toBe(1);
            expect((res.records[0].toObject() as any).n.labels).toEqual(['MyLabel']);

            const span = getSingleSpan();
            assertSpan(span as ReadableSpan);
            expect(span.name).toBe('CREATE neo4j');
            expect(span.attributes[SemanticAttributes.DB_OPERATION]).toBe('CREATE');
            expect(span.attributes[SemanticAttributes.DB_STATEMENT]).toBe('CREATE (n:MyLabel) RETURN n');
        });

        it('instruments "run" with subscribe', (done) => {
            driver
                .session()
                .run('CREATE (n:MyLabel) RETURN n')
                .subscribe({
                    onCompleted: () => {
                        const span = getSingleSpan();
                        assertSpan(span as ReadableSpan);
                        expect(span.attributes[SemanticAttributes.DB_OPERATION]).toBe('CREATE');
                        expect(span.attributes[SemanticAttributes.DB_STATEMENT]).toBe('CREATE (n:MyLabel) RETURN n');
                        done();
                    },
                });
        });

        it('handles "run" exceptions with promise', async () => {
            try {
                await driver.session().run('NOT_EXISTS_OPERATION');
            } catch (err) {
                const span = getSingleSpan();
                expect(span.status.code).toBe(SpanStatusCode.ERROR);
                expect(span.status.message).toBe(err.message);
                return;
            }
            throw Error('should not be here');
        });

        it('handles "run" exceptions with subscribe', (done) => {
            driver
                .session()
                .run('NOT_EXISTS_OPERATION')
                .subscribe({
                    onError: (err) => {
                        const span = getSingleSpan();
                        expect(span.status.code).toBe(SpanStatusCode.ERROR);
                        expect(span.status.message).toBe(err.message);
                        done();
                    },
                });
        });

        it('closes span when on "onKeys" event', (done) => {
            driver
                .session()
                .run('MATCH (n) RETURN n')
                .subscribe({
                    onKeys: (keys) => {
                        const span = getSingleSpan();
                        assertSpan(span as ReadableSpan);
                        expect(keys).toEqual(['n']);
                        done();
                    },
                });
        });

        it('when passing "onKeys" and onCompleted, span is closed in onCompleted, and response hook is called', (done) => {
            instrumentation.disable();
            instrumentation.setConfig({ responseHook: (span) => span.setAttribute('test', 'cool') });
            instrumentation.enable();

            driver
                .session()
                .run('MATCH (n) RETURN n')
                .subscribe({
                    onKeys: () => {
                        const spans = getTestSpans();
                        expect(spans.length).toBe(0);
                    },
                    onCompleted: () => {
                        const span = getSingleSpan();
                        assertSpan(span as ReadableSpan);
                        expect(span.attributes['test']).toBe('cool');
                        done();
                    },
                });
        });

        it('handles multiple promises', async () => {
            await Promise.all([
                driver.session().run('MATCH (n) RETURN n'),
                driver.session().run('MATCH (k) RETURN k'),
                driver.session().run('MATCH (d) RETURN d'),
            ]);
            const spans = getTestSpans();
            expect(spans.length).toBe(3);
            for (let span of spans) {
                assertSpan(span as ReadableSpan);
                expect(span.attributes[SemanticAttributes.DB_OPERATION]).toBe('MATCH');
            }
        });

        it('captures operation with trailing white spaces', async () => {
            await driver.session().run('  MATCH (k) RETURN k ');
            const span = getSingleSpan();
            expect(span.attributes[SemanticAttributes.DB_OPERATION]).toBe('MATCH');
        });

        it('set module versions when config is set', async () => {
            instrumentation.disable();
            instrumentation.setConfig({ moduleVersionAttributeName: 'module.version' });
            instrumentation.enable();
            await driver.session().run('CREATE (n:MyLabel) RETURN n');

            const span = getSingleSpan();
            expect(span.attributes['module.version']).toMatch(/\d{1,4}\.\d{1,4}\.\d{1,5}.*/);
        });

        it('does not capture any span when ignoreOrphanedSpans is set to true', async () => {
            instrumentation.disable();
            instrumentation.setConfig({ ignoreOrphanedSpans: true });
            instrumentation.enable();
            await context.with(ROOT_CONTEXT, async () => {
                await driver.session().run('CREATE (n:MyLabel) RETURN n');
            });

            const spans = getTestSpans();
            expect(spans.length).toBe(0);
        });

        it('does capture span when ignoreOrphanedSpans is set to true and has parent span', async () => {
            instrumentation.disable();
            instrumentation.setConfig({ ignoreOrphanedSpans: true });
            instrumentation.enable();
            const parent = trace.getTracerProvider().getTracer('test-tracer').startSpan('main');
            await context.with(trace.setSpan(context.active(), parent), () =>
                driver.session().run('CREATE (n:MyLabel) RETURN n')
            );

            const spans = getTestSpans();
            expect(spans.length).toBe(1);
        });

        it('responseHook works with promise', async () => {
            instrumentation.disable();
            instrumentation.setConfig({
                responseHook: (span, response) => {
                    span.setAttribute('db.response', normalizeResponse(response));
                },
            });
            instrumentation.enable();

            const res = await driver
                .session()
                .run('CREATE (n:Rick), (b:Meeseeks { purpose: "help"}), (c:Morty) RETURN *');
            expect(res.records.length).toBe(1);

            const span = getSingleSpan();
            assertSpan(span as ReadableSpan);
            expect(JSON.parse(span.attributes['db.response'] as string)).toEqual([
                {
                    b: { labels: ['Meeseeks'], properties: { purpose: 'help' } },
                    c: { labels: ['Morty'], properties: {} },
                    n: { labels: ['Rick'], properties: {} },
                },
            ]);
        });

        it('responseHook works with subscribe', (done) => {
            instrumentation.disable();
            instrumentation.setConfig({
                responseHook: (span, response) => {
                    span.setAttribute('db.response', normalizeResponse(response));
                },
            });
            instrumentation.enable();

            driver
                .session()
                .run('CREATE (n:Rick), (b:Meeseeks { purpose: "help"}), (c:Morty) RETURN *')
                .subscribe({
                    onCompleted: () => {
                        const span = getSingleSpan();
                        assertSpan(span as ReadableSpan);
                        expect(JSON.parse(span.attributes['db.response'] as string)).toEqual([
                            {
                                b: { labels: ['Meeseeks'], properties: { purpose: 'help' } },
                                c: { labels: ['Morty'], properties: {} },
                                n: { labels: ['Rick'], properties: {} },
                            },
                        ]);
                        done();
                    },
                });
        });

        it('does not fail when responseHook throws', async () => {
            instrumentation.disable();
            instrumentation.setConfig({
                responseHook: () => {
                    throw new Error('I throw..');
                },
            });
            instrumentation.enable();
            await driver.session().run('CREATE (n:MyLabel) RETURN n');
            const span = getSingleSpan();
            assertSpan(span as ReadableSpan);
        });
    });

    describe('transaction', async () => {
        it('instruments session readTransaction', async () => {
            await driver.session().readTransaction((txc) => {
                return txc.run('MATCH (person:Person) RETURN person.name AS name');
            });
            const span = getSingleSpan();
            assertSpan(span as ReadableSpan);
            expect(span.attributes[SemanticAttributes.DB_OPERATION]).toBe('MATCH');
            expect(span.attributes[SemanticAttributes.DB_STATEMENT]).toBe(
                'MATCH (person:Person) RETURN person.name AS name'
            );
        });

        it('instruments session writeTransaction', async () => {
            await driver.session().writeTransaction((txc) => {
                return txc.run('MATCH (person:Person) RETURN person.name AS name');
            });
            const span = getSingleSpan();
            assertSpan(span as ReadableSpan);
            expect(span.attributes[SemanticAttributes.DB_OPERATION]).toBe('MATCH');
            expect(span.attributes[SemanticAttributes.DB_STATEMENT]).toBe(
                'MATCH (person:Person) RETURN person.name AS name'
            );
        });

        it('instruments explicit transactions', async () => {
            const txc = driver.session().beginTransaction();
            await txc.run('MERGE (bob:Person {name: "Bob"}) RETURN bob.name AS name');
            await txc.run('MERGE (adam:Person {name: "Adam"}) RETURN adam.name AS name');
            await txc.commit();

            const spans = getTestSpans();
            expect(spans.length).toBe(2);
        });
    });

    describe('rxSession', () => {
        it('instruments "run"', (done) => {
            driver
                .rxSession()
                .run('MERGE (n:MyLabel) RETURN n')
                .records()
                .subscribe({
                    complete: () => {
                        const span = getSingleSpan();
                        assertSpan(span as ReadableSpan);
                        done();
                    },
                });
        });

        it('works when piping response', (done) => {
            const rxSession = driver.rxSession();
            rxSession
                .run('MERGE (james:Person {name: $nameParam}) RETURN james.name AS name', {
                    nameParam: 'Bob',
                })
                .records()
                .pipe(map((record) => record.get('name')))
                .subscribe({
                    next: () => {},
                    complete: () => {
                        const span = getSingleSpan();
                        assertSpan(span as ReadableSpan);
                        expect(span.attributes[SemanticAttributes.DB_STATEMENT]).toBe(
                            'MERGE (james:Person {name: $nameParam}) RETURN james.name AS name'
                        );
                        done();
                    },
                    error: () => {},
                });
        });

        it('works with response hook', (done) => {
            instrumentation.disable();
            instrumentation.setConfig({
                responseHook: (span, response) => {
                    span.setAttribute('db.response', normalizeResponse(response));
                },
            });
            instrumentation.enable();

            driver
                .rxSession()
                .run('MERGE (n:MyLabel) RETURN n')
                .records()
                .subscribe({
                    complete: () => {
                        const span = getSingleSpan();
                        assertSpan(span as ReadableSpan);
                        expect(span.attributes['db.response']).toBe(`[{"n":{"labels":["MyLabel"],"properties":{}}}]`);
                        done();
                    },
                });
        });
    });

    describe('reactive transaction', () => {
        it('instruments rx session readTransaction', (done) => {
            driver
                .rxSession()
                .readTransaction((txc) =>
                    txc
                        .run('MATCH (person:Person) RETURN person.name AS name')
                        .records()
                        .pipe(map((record) => record.get('name')))
                )
                .subscribe({
                    next: () => {},
                    complete: () => {
                        const span = getSingleSpan();
                        assertSpan(span as ReadableSpan);
                        expect(span.attributes[SemanticAttributes.DB_STATEMENT]).toBe(
                            'MATCH (person:Person) RETURN person.name AS name'
                        );
                        done();
                    },
                    error: () => {},
                });
        });

        it('instruments rx session writeTransaction', (done) => {
            driver
                .rxSession()
                .writeTransaction((txc) =>
                    txc
                        .run('MATCH (person:Person) RETURN person.name AS name')
                        .records()
                        .pipe(map((record) => record.get('name')))
                )
                .subscribe({
                    next: () => {},
                    complete: () => {
                        const span = getSingleSpan();
                        assertSpan(span as ReadableSpan);
                        expect(span.attributes[SemanticAttributes.DB_STATEMENT]).toBe(
                            'MATCH (person:Person) RETURN person.name AS name'
                        );
                        done();
                    },
                    error: () => {},
                });
        });

        it('instruments rx explicit transactions', (done) => {
            driver
                .rxSession()
                .beginTransaction()
                .pipe(
                    mergeMap((txc) =>
                        concat(
                            txc
                                .run('MERGE (bob:Person {name: $nameParam}) RETURN bob.name AS name', {
                                    nameParam: 'Bob',
                                })
                                .records()
                                .pipe(map((r: any) => r.get('name'))),
                            txc
                                .run('MERGE (adam:Person {name: $nameParam}) RETURN adam.name AS name', {
                                    nameParam: 'Adam',
                                })
                                .records()
                                .pipe(map((r: any) => r.get('name'))),
                            txc.commit()
                        )
                    )
                )
                .subscribe({
                    next: () => {},
                    complete: () => {
                        const spans = getTestSpans();
                        expect(spans.length).toBe(2);
                        done();
                    },
                    error: () => {},
                });
        });
    });

    describe('routing mode', () => {
        // When the connection string starts with "neo4j" routing mode is used
        let routingDriver: Driver;
        const version = require('neo4j-driver/package.json').version;
        const shouldCheck = !['4.0.0', '4.0.1', '4.0.2'].includes(version);

        before(() => {
            if (shouldCheck) {
                routingDriver = neo4j.driver('neo4j://localhost:11011', neo4j.auth.basic('neo4j', 'test'));
            }
        });

        after(async () => {
            shouldCheck && (await routingDriver.close());
        });

        it('instruments as expected in routing mode', async () => {
            if (!shouldCheck) {
                // Versions 4.0.0, 4.0.1 and 4.0.2 of neo4j-driver don't allow connection to local neo4j in routing mode.
                console.log(`Skipping unsupported test for version ${version}`);
                return;
            }

            await routingDriver.session().run('CREATE (n:MyLabel) RETURN n');

            const span = getSingleSpan();
            assertSpan(span as ReadableSpan);
        });
    });
});