import {
    Value,
    Dictionary,
    decode, decodeWithAnnotations, encode, encodeWithAnnotations, canonicalEncode,
    DecodeError, ShortPacket,
    Bytes, Record,
    annotate,
    strip, peel,
    preserves,
    fromJS,
    Constants,
    Encoder,
    GenericEmbedded,
    EncoderState,
    EmbeddedType,
    DecoderState,
    Decoder,
    Embedded,
    embed,
    genericEmbeddedTypeDecode,
    genericEmbeddedTypeEncode,
} from '../src/index';
const { Tag } = Constants;
import './test-utils';

import * as fs from 'fs';

const _discard = Symbol.for('discard');
const _capture = Symbol.for('capture');
const _observe = Symbol.for('observe');
const Discard = Record.makeConstructor<{}, GenericEmbedded>()(_discard, []);
const Capture = Record.makeConstructor<{pattern: Value<GenericEmbedded>}, GenericEmbedded>()(_capture, ['pattern']);
const Observe = Record.makeConstructor<{pattern: Value<GenericEmbedded>}, GenericEmbedded>()(_observe, ['pattern']);

describe('record constructors', () => {
    it('should have constructorInfo', () => {
        expect(Discard.constructorInfo.label).toEqual(Symbol.for('discard'));
        expect(Capture.constructorInfo.label).toEqual(Symbol.for('capture'));
        expect(Observe.constructorInfo.label).toEqual(Symbol.for('observe'));
        expect(Discard.constructorInfo.arity).toEqual(0);
        expect(Capture.constructorInfo.arity).toEqual(1);
        expect(Observe.constructorInfo.arity).toEqual(1);
    });
})

describe('RecordConstructorInfo', () => {
    const C1 = Record.makeConstructor<{x: number, y: number}>()([1], ['x', 'y']);
    const C2 = Record.makeConstructor<{z: number, w: number}>()([1], ['z', 'w']);
    it('instance comparison should ignore embedded and fieldname differences', () => {
        expect(C1(9,9)).is(C2(9,9));
        expect(C1(9,9)).not.is(C2(9,8));
    });
    it('comparison based on embedded equality should not work', () => {
        expect(C1.constructorInfo).not.toBe(C2.constructorInfo);
    });
    it('comparison based on .equals should work', () => {
        expect(C1.constructorInfo).toEqual(C2.constructorInfo);
    });
});

describe('records', () => {
    it('should have correct getConstructorInfo', () => {
        expect(Record.constructorInfo(Discard())).toEqual(Discard.constructorInfo);
        expect(Record.constructorInfo(Capture(Discard()))).toEqual(Capture.constructorInfo);
        expect(Record.constructorInfo(Observe(Capture(Discard())))).toEqual(Observe.constructorInfo);
    });
});

describe('parsing from subarray', () => {
    it('should maintain alignment of nextbytes', () => {
        const u = Uint8Array.of(1, 1, 1, 1, 0xb1, 0x03, 0x33, 0x33, 0x33);
        const bs = Bytes.from(u.subarray(4));
        expect(decode(bs)).is("333");
    });
});

describe('reusing buffer space', () => {
    it('should be done safely, even with nested dictionaries', () => {
        expect(canonicalEncode(fromJS(['aaa', Dictionary.fromJS({a: 1}), 'zzz'])).toHex()).is(
            `b5
               b103616161
               b7
                 b10161 91
               84
               b1037a7a7a
             84`.replace(/\s+/g, ''));
    });
});

describe('encoding and decoding embeddeds', () => {
    class LookasideEmbeddedType implements EmbeddedType<object> {
        readonly objects: object[];

        constructor(objects: object[]) {
            this.objects = objects;
        }

        decode(d: DecoderState): object {
            return this.fromValue(new Decoder<GenericEmbedded>(d).next());
        }

        encode(e: EncoderState, v: object): void {
            new Encoder(e).push(this.toValue(v));
        }

        equals(a: object, b: object): boolean {
            return Object.is(a, b);
        }

        fromValue(v: Value<GenericEmbedded>): object {
            if (typeof v !== 'number' || v < 0 || v >= this.objects.length) {
                throw new Error("Unknown embedded target");
            }
            return this.objects[v];
        }

        toValue(v: object): number {
            let i = this.objects.indexOf(v);
            if (i !== -1) return i;
            this.objects.push(v);
            return this.objects.length - 1;
        }
    }

    it('should encode using embeddedId when no function has been supplied', () => {
        const A1 = embed({a: 1});
        const A2 = embed({a: 1});
        const bs1 = canonicalEncode(A1);
        const bs2 = canonicalEncode(A2);
        const bs3 = canonicalEncode(A1);
        expect(bs1.get(0)).toBe(Tag.Embedded);
        expect(bs2.get(0)).toBe(Tag.Embedded);
        expect(bs3.get(0)).toBe(Tag.Embedded);
        // Can't really check the value assigned to the object. But we
        // can check that it's different to a similar object!
        expect(bs1).not.is(bs2);
        expect(bs1).is(bs3);
    });
    it('should refuse to decode embeddeds when no function has been supplied', () => {
        expect(() => decode(Bytes.from([Tag.Embedded, Tag.SmallInteger_lo])))
            .toThrow("Embeddeds not permitted at this point in Preserves document");
    });
    it('should encode properly', () => {
        const objects: object[] = [];
        const pt = new LookasideEmbeddedType(objects);
        const A = embed({a: 1});
        const B = embed({b: 2});
        expect(encode([A, B], { embeddedEncode: pt })).is(
            Bytes.from([Tag.Sequence,
                        Tag.Embedded, Tag.SmallInteger_lo,
                        Tag.Embedded, Tag.SmallInteger_lo + 1,
                        Tag.End]));
        expect(objects).toEqual([A.embeddedValue, B.embeddedValue]);
    });
    it('should decode properly', () => {
        const objects: object[] = [];
        const pt = new LookasideEmbeddedType(objects);
        const X: Embedded<object> = embed({x: 123});
        const Y: Embedded<object> = embed({y: 456});
        objects.push(X.embeddedValue);
        objects.push(Y.embeddedValue);
        expect(decode(Bytes.from([
            Tag.Sequence,
            Tag.Embedded, Tag.SmallInteger_lo,
            Tag.Embedded, Tag.SmallInteger_lo + 1,
            Tag.End
        ]), { embeddedDecode: pt })).is([X, Y]);
    });
    it('should store embeddeds embedded in map keys correctly', () => {
        const A1a = {a: 1};
        const A1: Embedded<object> = embed(A1a);
        const A2: Embedded<object> = embed({a: 1});
        const m = new Dictionary<object, number>();
        m.set([A1], 1);
        m.set([A2], 2);
        expect(m.get(A1)).toBeUndefined();
        expect(m.get([A1])).toBe(1);
        expect(m.get([A2])).toBe(2);
        expect(m.get([embed({a: 1})])).toBeUndefined();
        A1a.a = 3;
        expect(m.get([A1])).toBe(1);
    });
});

describe('common test suite', () => {
    const samples_bin = fs.readFileSync(__dirname + '/../../../../../tests/samples.bin');
    const samples = decodeWithAnnotations(samples_bin, { embeddedDecode: genericEmbeddedTypeDecode });

    const TestCases = Record.makeConstructor<{
        cases: Dictionary<GenericEmbedded>
    }>()(Symbol.for('TestCases'), ['cases']);
    type TestCases = ReturnType<typeof TestCases>;

    function DS(bs: Bytes) {
        return decode(bs, { embeddedDecode: genericEmbeddedTypeDecode });
    }
    function D(bs: Bytes) {
        return decodeWithAnnotations(bs, { embeddedDecode: genericEmbeddedTypeDecode });
    }
    function E(v: Value<GenericEmbedded>) {
        return encodeWithAnnotations(v, { embeddedEncode: genericEmbeddedTypeEncode });
    }

    interface ExpectedValues {
        [testName: string]: ({
            value: Value<GenericEmbedded>;
        } | {
            forward: Value<GenericEmbedded>;
            back: Value<GenericEmbedded>;
        });
    }

    const expectedValues: ExpectedValues = {
        annotation1: { forward: annotate<GenericEmbedded>(9, "abc"),
                       back: 9 },
        annotation2: { forward: annotate<GenericEmbedded>([[], annotate<GenericEmbedded>([], "x")],
                                                         "abc",
                                                         "def"),
                       back: [[], []] },
        annotation3: { forward: annotate<GenericEmbedded>(5,
                                                         annotate<GenericEmbedded>(2, 1),
                                                         annotate<GenericEmbedded>(4, 3)),
                       back: 5 },
        annotation5: {
            forward: annotate<GenericEmbedded>(
                Record<symbol, any>(Symbol.for('R'),
                                    [annotate<GenericEmbedded>(Symbol.for('f'),
                                                              Symbol.for('af'))]),
                Symbol.for('ar')),
            back: Record<Value<GenericEmbedded>, any>(Symbol.for('R'), [Symbol.for('f')])
        },
        annotation6: {
            forward: Record<Value<GenericEmbedded>, any>(
                annotate<GenericEmbedded>(Symbol.for('R'),
                                         Symbol.for('ar')),
                [annotate<GenericEmbedded>(Symbol.for('f'),
                                          Symbol.for('af'))]),
            back: Record<symbol, any>(Symbol.for('R'), [Symbol.for('f')])
        },
        annotation7: {
            forward: annotate<GenericEmbedded>([], Symbol.for('a'), Symbol.for('b'), Symbol.for('c')),
            back: []
        },
        list1: {
            forward: [1, 2, 3, 4],
            back: [1, 2, 3, 4]
        },
        record2: {
            value: Observe(Record(Symbol.for("speak"), [
                Discard(),
                Capture(Discard())
            ]))
        },
    };

    type Variety = 'normal' | 'nondeterministic' | 'decode';

    function runTestCase(variety: Variety,
                         tName: string,
                         binaryForm: Bytes,
                         annotatedTextForm: Value<GenericEmbedded>)
    {
        describe(tName, () => {
            const textForm = strip(annotatedTextForm);
            const {forward, back} = (function () {
                const entry = expectedValues[tName] ?? {value: textForm};
                if ('value' in entry) {
                    return {forward: entry.value, back: entry.value};
                } else if ('forward' in entry && 'back' in entry) {
                    return entry;
                } else {
                    throw new Error('Invalid expectedValues entry for ' + tName);
                }
            })();
            it('should match the expected value', () => expect(textForm).is(back));
            it('should round-trip', () => expect(DS(E(textForm))).is(back));
            it('should go forward', () => expect(DS(E(forward))).is(back));
            it('should go back', () => expect(DS(binaryForm)).is(back));
            it('should go back with annotations',
               () => expect(D(E(annotatedTextForm))).is(annotatedTextForm));
            if (variety !== 'decode' && variety !== 'nondeterministic') {
                it('should encode correctly', () => expect(E(forward)).is(binaryForm));
                it('should encode correctly with annotations',
                   () => expect(E(annotatedTextForm)).is(binaryForm));
            }
        });
    }

    const tests = (peel(TestCases._.cases(peel(samples) as TestCases)) as
                   Dictionary<GenericEmbedded>);
    tests.forEach((t0: Value<GenericEmbedded>, tName0: Value<GenericEmbedded>) => {
        const tName = Symbol.keyFor(strip(tName0) as symbol)!;
        const t = peel(t0) as Record<symbol, any, GenericEmbedded>;
        switch (t.label) {
            case Symbol.for('Test'):
                runTestCase('normal', tName, strip(t[0]) as Bytes, t[1]);
                break;
            case Symbol.for('NondeterministicTest'):
                runTestCase('nondeterministic', tName, strip(t[0]) as Bytes, t[1]);
                break;
            case Symbol.for('DecodeTest'):
                runTestCase('decode', tName, strip(t[0]) as Bytes, t[1]);
                break;
            case Symbol.for('DecodeError'):
                describe(tName, () => {
                    it('should fail with DecodeError', () => {
                        expect(() => D(strip(t[0]) as Bytes))
                            .toThrowFilter(e =>
                                DecodeError.isDecodeError(e) &&
                                !ShortPacket.isShortPacket(e));
                    });
                });
                break;
            case Symbol.for('DecodeEOF'): // fall through
            case Symbol.for('DecodeShort'):
                describe(tName, () => {
                    it('should fail with ShortPacket', () => {
                        expect(() => D(strip(t[0]) as Bytes))
                            .toThrowFilter(e => ShortPacket.isShortPacket(e));
                    });
                });
                break;
            case Symbol.for('ParseError'):
            case Symbol.for('ParseEOF'):
            case Symbol.for('ParseShort'):
                /* Skipped for now, until we have an implementation of text syntax */
                break;
            default:{
                const e = new Error(preserves`Unsupported test kind ${t}`);
                console.error(e);
                throw e;
            }
        }
    });
});
