/*
Copyright 2021 The Matrix.org Foundation C.I.C.

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 {EventEntry} from "../entries/EventEntry.js";
import {REDACTION_TYPE, isRedacted} from "../../common";
import {ANNOTATION_RELATION_TYPE, getRelation} from "../relations.js";
import {redactEvent} from "../common.js";

export class RelationWriter {
    constructor({roomId, ownUserId, fragmentIdComparer}) {
        this._roomId = roomId;
        this._ownUserId = ownUserId;
        this._fragmentIdComparer = fragmentIdComparer;
    }

    // this needs to happen again after decryption too for edits
    async writeRelation(sourceEntry, txn, log) {
        const {relatedEventId} = sourceEntry;
        if (relatedEventId) {
            const relation = getRelation(sourceEntry.event);
            if (relation && relation.rel_type) {
                // we don't consider replies (which aren't relations in the MSC2674 sense)
                txn.timelineRelations.add(this._roomId, relation.event_id, relation.rel_type, sourceEntry.id);
            }
            const target = await txn.timelineEvents.getByEventId(this._roomId, relatedEventId);
            if (target) {
                const updatedStorageEntries = await this._applyRelation(sourceEntry, target, txn, log);
                if (updatedStorageEntries) {
                    return updatedStorageEntries.map(e => {
                        txn.timelineEvents.update(e);
                        return new EventEntry(e, this._fragmentIdComparer);
                    });
                }
            }
        }
        return null;
    }

    /**
     * @param {Object} storageEntry the event object, as it will be stored in storage.
     *        Will be modified (but not written to storage) in case this event is
     *        a relation target for which we've previously received relations.
     * @param {Direction} direction of the gap fill
     * */
    async writeGapRelation(storageEntry, direction, txn, log) {
        const sourceEntry = new EventEntry(storageEntry, this._fragmentIdComparer);
        const result = await this.writeRelation(sourceEntry, txn, log);
        // when back-paginating, it can also happen that we've received relations
        // for this event before, which now upon receiving the target need to be aggregated.
        if (direction.isBackward && !isRedacted(storageEntry.event)) {
            const relations = await txn.timelineRelations.getAllForTarget(this._roomId, sourceEntry.id);
            if (relations.length) {
                for (const r of relations) {
                    const relationStorageEntry = await txn.timelineEvents.getByEventId(this._roomId, r.sourceEventId);
                    if (relationStorageEntry) {
                        const relationEntry = new EventEntry(relationStorageEntry, this._fragmentIdComparer);
                        await this._applyRelation(relationEntry, storageEntry, txn, log);
                    }
                }
            }
        }

        return result;
    }

    /**
     * @param {EventEntry} sourceEntry
     * @param {Object} targetStorageEntry event entry as stored in the timelineEvents store
     * @return {[Object]} array of event storage entries that have been updated
     * */
    async _applyRelation(sourceEntry, targetStorageEntry, txn, log) {
        if (sourceEntry.eventType === REDACTION_TYPE) {
            return log.wrap("redact", async log => {
                const redactedEvent = targetStorageEntry.event;
                const relation = getRelation(redactedEvent); // get this before redacting
                const redacted = this._applyRedaction(sourceEntry.event, targetStorageEntry, txn, log);
                if (redacted) {
                    const updated = [targetStorageEntry];
                    if (relation) {
                        const relationTargetStorageEntry = await this._reaggregateRelation(redactedEvent, relation, txn, log);
                        if (relationTargetStorageEntry) {
                            updated.push(relationTargetStorageEntry);
                        }
                    }
                    return updated;
                }
                return null;
            });
        } else {
            const relation = getRelation(sourceEntry.event);
            if (relation && !isRedacted(targetStorageEntry.event)) {
                const relType = relation.rel_type;
                if (relType === ANNOTATION_RELATION_TYPE) {
                    const aggregated = log.wrap("react", log => {
                        return this._aggregateAnnotation(sourceEntry.event, targetStorageEntry, log);
                    });
                    if (aggregated) {
                        return [targetStorageEntry];
                    }
                }
            }
        }
        return null;
    }

    _applyRedaction(redactionEvent, redactedStorageEntry, txn, log) {
        const redactedEvent = redactedStorageEntry.event;
        log.set("redactionId", redactionEvent.event_id);
        log.set("id", redactedEvent.event_id);

        const relation = getRelation(redactedEvent);
        if (relation && relation.rel_type) {
            txn.timelineRelations.remove(this._roomId, relation.event_id, relation.rel_type, redactedEvent.event_id);
        }
        // check if we're the target of a relation and remove all relations then as well
        txn.timelineRelations.removeAllForTarget(this._roomId, redactedEvent.event_id);

        redactEvent(redactionEvent, redactedEvent);
        delete redactedStorageEntry.annotations;

        return true;
    }

    _aggregateAnnotation(annotationEvent, targetStorageEntry/*, log*/) {
        // TODO: do we want to verify it is a m.reaction event somehow?
        const relation = getRelation(annotationEvent);
        if (!relation) {
            return false;
        }

        let {annotations} = targetStorageEntry;
        if (!annotations) {
            targetStorageEntry.annotations = annotations = {};
        }
        let annotation = annotations[relation.key];
        if (!annotation) {
            annotations[relation.key] = annotation = {
                count: 0,
                me: false,
                firstTimestamp: Number.MAX_SAFE_INTEGER
            };
        }
        const sentByMe = annotationEvent.sender === this._ownUserId;

        annotation.me = annotation.me || sentByMe;
        annotation.count += 1;
        annotation.firstTimestamp = Math.min(
            annotation.firstTimestamp,
            annotationEvent.origin_server_ts
        );

        return true;
    }

    async _reaggregateRelation(redactedRelationEvent, redactedRelation, txn, log) {
        if (redactedRelation.rel_type === ANNOTATION_RELATION_TYPE) {
            return log.wrap("reaggregate annotations", log => this._reaggregateAnnotation(
                redactedRelation.event_id,
                redactedRelation.key,
                txn, log
            ));
        }
        return null;
    }

    async _reaggregateAnnotation(targetId, key, txn, log) {
        const target = await txn.timelineEvents.getByEventId(this._roomId, targetId);
        if (!target || !target.annotations) { // unknown or redacted event
            return null;
        }
        log.set("id", targetId);
        const relations = await txn.timelineRelations.getForTargetAndType(this._roomId, targetId, ANNOTATION_RELATION_TYPE);
        log.set("relations", relations.length);
        delete target.annotations[key];
        if (isObjectEmpty(target.annotations)) {
            delete target.annotations;
        }
        await Promise.all(relations.map(async relation => {
            const annotation = await txn.timelineEvents.getByEventId(this._roomId, relation.sourceEventId);
            if (!annotation) {
                log.log({l: "missing annotation", id: relation.sourceEventId});
            }
            if (getRelation(annotation.event).key === key) {
                this._aggregateAnnotation(annotation.event, target, log);
            }
        }));
        return target;
    }
}

function isObjectEmpty(obj) {
    for (const key in obj) {
        if (obj.hasOwnProperty(key)) {
            return false;
        }
    }
    return true;
}


import {createMockStorage} from "../../../../mocks/Storage";
import {createEvent, withTextBody, withRedacts, withContent} from "../../../../mocks/event.js";
import {createAnnotation} from "../relations.js";
import {FragmentIdComparer} from "../FragmentIdComparer.js";
import {NullLogItem} from "../../../../logging/NullLogger";

export function tests() {
    const fragmentIdComparer = new FragmentIdComparer([]);
    const roomId = "$abc";
    const alice = "@alice:hs.tld";
    const bob = "@bob:hs.tld";

    return {
        "apply redaction": async assert => {
            const event = withTextBody("Dogs > Cats", createEvent("m.room.message", "!abc", bob));
            const reason = "nonsense, cats are the best!";
            const redaction = withRedacts(event.event_id, reason, createEvent("m.room.redaction", "!def", alice));
            const redactionEntry = new EventEntry({fragmentId: 1, eventIndex: 3, event: redaction, roomId}, fragmentIdComparer);
            const relationWriter = new RelationWriter({roomId, ownUserId: bob, fragmentIdComparer});

            const storage = await createMockStorage();
            const txn = await storage.readWriteTxn([storage.storeNames.timelineEvents, storage.storeNames.timelineRelations]);
            txn.timelineEvents.tryInsert({fragmentId: 1, eventIndex: 2, event, roomId}, new NullLogItem());
            const updatedEntries = await relationWriter.writeRelation(redactionEntry, txn, new NullLogItem());
            await txn.complete();

            assert.equal(updatedEntries.length, 1);
            const redactedMessage = updatedEntries[0];
            assert.equal(redactedMessage.id, "!abc");
            assert.equal(redactedMessage.content.body, undefined);
            assert.equal(redactedMessage.redactionReason, reason);
            
            const readTxn = await storage.readTxn([storage.storeNames.timelineEvents]);
            const storedMessage = await readTxn.timelineEvents.getByEventId(roomId, "!abc");
            await readTxn.complete();
            assert.equal(storedMessage.event.content.body, undefined);
            assert.equal(storedMessage.event.unsigned.redacted_because.content.reason, reason);
        },
        "aggregate reaction": async assert => {
            const event = withTextBody("Dogs > Cats", createEvent("m.room.message", "!abc", bob));
            const reaction = withContent(createAnnotation(event.event_id, "🐶"), createEvent("m.reaction", "!def", alice));
            reaction.origin_server_ts = 5;
            const reactionEntry = new EventEntry({event: reaction, roomId}, fragmentIdComparer);
            const relationWriter = new RelationWriter({roomId, ownUserId: alice, fragmentIdComparer});

            const storage = await createMockStorage();
            const txn = await storage.readWriteTxn([storage.storeNames.timelineEvents, storage.storeNames.timelineRelations]);
            txn.timelineEvents.tryInsert({fragmentId: 1, eventIndex: 2, event, roomId}, new NullLogItem());
            const updatedEntries = await relationWriter.writeRelation(reactionEntry, txn, new NullLogItem());
            await txn.complete();

            assert.equal(updatedEntries.length, 1);
            const reactedMessage = updatedEntries[0];
            assert.equal(reactedMessage.id, "!abc");
            const annotation = reactedMessage.annotations["🐶"];
            assert.equal(annotation.me, true);
            assert.equal(annotation.count, 1);
            assert.equal(annotation.firstTimestamp, 5);
            
            const readTxn = await storage.readTxn([storage.storeNames.timelineEvents]);
            const storedMessage = await readTxn.timelineEvents.getByEventId(roomId, "!abc");
            await readTxn.complete();
            assert(storedMessage.annotations["🐶"]);
        },
        "aggregate second reaction": async assert => {
            const event = withTextBody("Dogs > Cats", createEvent("m.room.message", "!abc", bob));
            const reaction1 = withContent(createAnnotation(event.event_id, "🐶"), createEvent("m.reaction", "!def", alice));
            reaction1.origin_server_ts = 5;
            const reaction1Entry = new EventEntry({event: reaction1, roomId}, fragmentIdComparer);
            const reaction2 = withContent(createAnnotation(event.event_id, "🐶"), createEvent("m.reaction", "!hij", bob));
            reaction2.origin_server_ts = 10;
            const reaction2Entry = new EventEntry({event: reaction2, roomId}, fragmentIdComparer);
            const relationWriter = new RelationWriter({roomId, ownUserId: alice, fragmentIdComparer});

            const storage = await createMockStorage();
            const txn = await storage.readWriteTxn([storage.storeNames.timelineEvents, storage.storeNames.timelineRelations]);
            txn.timelineEvents.tryInsert({fragmentId: 1, eventIndex: 2, event, roomId}, new NullLogItem());
            await relationWriter.writeRelation(reaction1Entry, txn, new NullLogItem());
            const updatedEntries = await relationWriter.writeRelation(reaction2Entry, txn, new NullLogItem());
            await txn.complete();

            assert.equal(updatedEntries.length, 1);

            const reactedMessage = updatedEntries[0];
            assert.equal(reactedMessage.id, "!abc");
            const annotation = reactedMessage.annotations["🐶"];
            assert.equal(annotation.me, true);
            assert.equal(annotation.count, 2);
            assert.equal(annotation.firstTimestamp, 5);
        },
        "redact second reaction": async assert => {
            const event = withTextBody("Dogs > Cats", createEvent("m.room.message", "!abc", bob));
            const myReaction = withContent(createAnnotation(event.event_id, "🐶"), createEvent("m.reaction", "!def", alice));
            myReaction.origin_server_ts = 5;
            const bobReaction = withContent(createAnnotation(event.event_id, "🐶"), createEvent("m.reaction", "!hij", bob));
            bobReaction.origin_server_ts = 10;
            const myReactionRedaction = withRedacts(myReaction.event_id, "", createEvent("m.room.redaction", "!pol", alice));

            const myReactionEntry = new EventEntry({event: myReaction, roomId}, fragmentIdComparer);
            const bobReactionEntry = new EventEntry({event: bobReaction, roomId}, fragmentIdComparer);
            const myReactionRedactionEntry = new EventEntry({event: myReactionRedaction, roomId}, fragmentIdComparer);
            const relationWriter = new RelationWriter({roomId, ownUserId: alice, fragmentIdComparer});

            const storage = await createMockStorage();
            const txn = await storage.readWriteTxn([storage.storeNames.timelineEvents, storage.storeNames.timelineRelations]);
            txn.timelineEvents.tryInsert({fragmentId: 1, eventIndex: 2, event, roomId}, new NullLogItem());
            txn.timelineEvents.tryInsert({fragmentId: 1, eventIndex: 3, event: myReaction, roomId}, new NullLogItem());
            await relationWriter.writeRelation(myReactionEntry, txn, new NullLogItem());
            txn.timelineEvents.tryInsert({fragmentId: 1, eventIndex: 4, event: bobReaction, roomId}, new NullLogItem());
            await relationWriter.writeRelation(bobReactionEntry, txn, new NullLogItem());
            const updatedEntries = await relationWriter.writeRelation(myReactionRedactionEntry, txn, new NullLogItem());
            await txn.complete();

            assert.equal(updatedEntries.length, 2);

            const redactedReaction = updatedEntries[0];
            assert.equal(redactedReaction.id, "!def");
            const reaggregatedMessage = updatedEntries[1];
            assert.equal(reaggregatedMessage.id, "!abc");
            const annotation = reaggregatedMessage.annotations["🐶"];
            assert.equal(annotation.me, false);
            assert.equal(annotation.count, 1);
            assert.equal(annotation.firstTimestamp, 10);

            const readTxn = await storage.readTxn([storage.storeNames.timelineEvents]);
            const storedMessage = await readTxn.timelineEvents.getByEventId(roomId, "!abc");
            await readTxn.complete();
            assert.equal(storedMessage.annotations["🐶"].count, 1);
        },
        
    }
}
