import promiseLimit from "promise-limit";
import * as mfm from "mfm-js";
import config from "../../../config/index.js";
import Resolver from "../resolver.js";
import post from "../../../services/note/create.js";
import { extractMentionedUsers } from "../../../services/note/create.js";
import { resolvePerson } from "./person.js";
import { resolveImage } from "./image.js";
import { htmlToMfm } from "../misc/html-to-mfm.js";
import { extractApHashtags } from "./tag.js";
import { unique, toArray, toSingle } from "../../../prelude/array.js";
import { extractPollFromQuestion } from "./question.js";
import vote from "../../../services/note/polls/vote.js";
import { apLogger } from "../logger.js";
import { extractDbHost, toPuny } from "../../../misc/convert-host.js";
import { Emojis, Polls, MessagingMessages, Notes, NoteEdits, DriveFiles, PollVotes } from "../../../models/index.js";
import { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType } from "../type.js";
import { genId } from "../../../misc/gen-id.js";
import { getApLock } from "../../../misc/app-lock.js";
import { createMessage } from "../../../services/messages/create.js";
import { parseAudience } from "../audience.js";
import { extractApMentions } from "./mention.js";
import DbResolver from "../db-resolver.js";
import { StatusError } from "../../../misc/fetch.js";
import { shouldBlockInstance } from "../../../misc/should-block-instance.js";
import { publishNoteStream, publishNoteUpdatesStream } from "../../../services/stream.js";
import { extractHashtags } from "../../../misc/extract-hashtags.js";
import { UserProfiles } from "../../../models/index.js";
import { In } from "typeorm";
import { DB_MAX_IMAGE_COMMENT_LENGTH } from "../../../misc/hard-limits.js";
import { truncate } from "../../../misc/truncate.js";
import { getEmojiSize } from "../../../misc/emoji-meta.js";
import { RecursionLimiter } from "../../../models/repositories/user-profile.js";
const logger = apLogger;
export function validateNote(object, uri) {
    const expectHost = extractDbHost(uri);
    if (object == null) {
        return new Error("invalid Note: object is null");
    }
    if (!validPost.includes(getApType(object))) {
        return new Error(`invalid Note: invalid object type ${getApType(object)}`);
    }
    if (object.id && extractDbHost(object.id) !== expectHost) {
        return new Error(`invalid Note: id has different host. expected: ${expectHost}, actual: ${extractDbHost(object.id)}`);
    }
    if (object.attributedTo && extractDbHost(getOneApId(object.attributedTo)) !== expectHost) {
        return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${extractDbHost(object.attributedTo)}`);
    }
    return null;
}
/**
 * Fetch Notes.
 *
 * If the target Note is registered in Iceshrimp, it will be returned.
 */ export async function fetchNote(object) {
    const dbResolver = new DbResolver();
    return await dbResolver.getNoteFromApId(object);
}
/**
 * Create a Note.
 */ export async function createNote(value, resolver, silent = false, limiter = new RecursionLimiter()) {
    if (resolver == null) resolver = new Resolver();
    const object = await resolver.resolve(value);
    const entryUri = getApId(value);
    const err = validateNote(object, entryUri);
    if (err) {
        logger.error(`${err.message}`, {
            resolver: {
                history: resolver.getHistory()
            },
            value: value,
            object: object
        });
        throw new Error("invalid note");
    }
    const note = object;
    if (note.id == null) {
        throw new Error('Note must have an id');
    }
    const idUrl = new URL(note.id);
    if (idUrl.protocol != 'https:') {
        throw new Error(`unexpected schema of note.id: ${note.id}`);
    }
    let url = getOneApHrefNullable(note.url);
    const urlUrl = url != null ? new URL(url) : null;
    if (urlUrl != null && urlUrl.protocol != 'https:') {
        throw new Error(`unexpected schema of note url: ${url}`);
    }
    logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
    logger.info(`Creating the Note: ${note.id}`);
    // Skip if note is made before 2007 (1yr before Fedi was created)
    // OR skip if note is made 3 days in advance
    if (note.published) {
        const DateChecker = new Date(note.published);
        const FutureCheck = new Date();
        FutureCheck.setDate(FutureCheck.getDate() + 3); // Allow some wiggle room for misconfigured hosts
        if (DateChecker.getFullYear() < 2007) {
            logger.warn("Note somehow made before Activitypub was created; discarding");
            return null;
        }
        if (DateChecker > FutureCheck) {
            logger.warn("Note somehow made after today; discarding");
            return null;
        }
    }
    // Fetch author
    const actor = await resolvePerson(getOneApId(note.attributedTo), resolver, limiter);
    if (actor.uri == null) {
        logger.warn('Note actor uri is null, discarding');
        return null;
    }
    const actorUri = new URL(actor.uri);
    if (idUrl.host != actorUri.host) {
        logger.warn("Note id host doesn't match actor host, discarding");
        return null;
    }
    if (urlUrl != null && urlUrl.host != actorUri.host) {
        logger.debug("Note url host doesn't match actor host, clearing variable");
        url = undefined;
    }
    // Skip if author is suspended.
    if (actor.isSuspended) {
        logger.debug(`User ${actor.usernameLower}@${actor.host} suspended; discarding.`);
        return null;
    }
    const noteAudience = await parseAudience(actor, note.to, note.cc, undefined, limiter);
    let visibility = noteAudience.visibility;
    const visibleUsers = noteAudience.visibleUsers;
    // If Audience (to, cc) was not specified
    if (visibility === "specified" && visibleUsers.length === 0) {
        if (typeof value === "string") {
            // If the input is a string, GET occurs in resolver
            // Public if you can GET anonymously from here
            visibility = "public";
        }
    }
    let isTalk = note._misskey_talk && visibility === "specified";
    const apMentions = await extractApMentions(note.tag, limiter);
    const apHashtags = extractApHashtags(note.tag);
    // Attachments
    // TODO: attachmentは必ずしもImageではない
    // TODO: attachmentは必ずしも配列ではない
    // Noteがsensitiveなら添付もsensitiveにする
    const limit = promiseLimit(2);
    note.attachment = Array.isArray(note.attachment) ? note.attachment : note.attachment ? [
        note.attachment
    ] : [];
    note.attachment = note.attachment.filter((attach)=>[
            "Document",
            "Image",
            "Audio",
            "Video"
        ].includes(attach.type));
    const files = note.attachment.map((attach)=>attach.sensitive = note.sensitive) ? (await Promise.all(note.attachment.map((x)=>limit(()=>resolveImage(actor, x))))).filter((image)=>image != null) : [];
    // Reply
    const reply = note.inReplyTo ? await resolveNote(note.inReplyTo, resolver, limiter).then((x)=>{
        if (x == null) {
            logger.warn("Specified inReplyTo, but nout found");
            throw new Error("inReplyTo not found");
        } else {
            return x;
        }
    }).catch(async (e)=>{
        // トークだったらinReplyToのエラーは無視
        const uri = getApId(note.inReplyTo);
        if (uri.startsWith(`${config.url}/`)) {
            const id = uri.split("/").pop();
            const talk = await MessagingMessages.findOneBy({
                id
            });
            if (talk) {
                isTalk = true;
                return null;
            }
        }
        logger.warn(`Error in inReplyTo ${note.inReplyTo} - ${e.statusCode || e}`);
        throw e;
    }) : null;
    // Quote
    let quote;
    if (note._misskey_quote || note.quoteUrl || note.quoteUri || note.quote) {
        const tryResolveNote = async (uri)=>{
            if (typeof uri !== "string" || !uri.match(/^https?:/)) return {
                status: "permerror"
            };
            try {
                const res = await resolveNote(uri, undefined, limiter);
                if (res) {
                    return {
                        status: "ok",
                        res
                    };
                } else {
                    return {
                        status: "permerror"
                    };
                }
            } catch (e) {
                return {
                    status: e instanceof StatusError && !e.isRetryable ? "permerror" : "temperror"
                };
            }
        };
        const uris = unique([
            note._misskey_quote,
            note.quoteUrl,
            note.quoteUri,
            note.quote
        ].filter((x)=>typeof x === "string"));
        const results = await Promise.all(uris.map((uri)=>tryResolveNote(uri)));
        quote = results.filter((x)=>x.status === "ok").map((x)=>x.res).find((x)=>x);
        if (!quote) {
            if (results.some((x)=>x.status === "temperror")) {
                throw new Error("quote resolve failed");
            }
        }
    }
    const cw = note.summary === "" ? null : note.summary;
    // Text parsing
    let text = null;
    if (note.source?.mediaType === "text/x.misskeymarkdown" && typeof note.source?.content === "string") {
        text = note.source.content;
    } else if (typeof note._misskey_content !== "undefined") {
        text = note._misskey_content;
    } else if (typeof note.content === "string") {
        text = await htmlToMfm(note.content, note.tag);
    }
    // vote
    if (reply?.hasPoll) {
        const poll = await Polls.findOneByOrFail({
            noteId: reply.id
        });
        const tryCreateVote = async (name, index)=>{
            if (poll.expiresAt && Date.now() > new Date(poll.expiresAt).getTime()) {
                logger.warn(`vote to expired poll from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`);
            } else if (index >= 0) {
                logger.info(`vote from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`);
                await vote(actor, reply, index);
            }
            return null;
        };
        if (note.name) {
            return await tryCreateVote(note.name, poll.choices.findIndex((x)=>x === note.name));
        }
    }
    const emojis = await extractEmojis(note.tag || [], actor.host).catch((e)=>{
        logger.info(`extractEmojis: ${e}`);
        return [];
    });
    const apEmojis = emojis.map((emoji)=>emoji.name);
    const poll = await extractPollFromQuestion(note, resolver).catch(()=>undefined);
    if (isTalk) {
        for (const recipient of visibleUsers){
            await createMessage(actor, recipient, undefined, text || undefined, files && files.length > 0 ? files[0] : null, object.id);
            return null;
        }
    }
    return await post(actor, {
        createdAt: note.published ? new Date(note.published) : null,
        files,
        reply,
        renote: quote,
        name: note.name,
        cw,
        text,
        localOnly: false,
        visibility,
        visibleUsers,
        apMentions,
        apHashtags,
        apEmojis,
        poll,
        uri: note.id,
        url: url,
        canQuote: !!note.interactionPolicy?.canQuote
    }, silent, limiter);
}
/**
 * Resolve Note.
 *
 * If the target Note is registered in Iceshrimp, return it, otherwise
 * Fetch from remote server, register with Iceshrimp and return it.
 */ export async function resolveNote(value, resolver, limiter = new RecursionLimiter()) {
    const uri = typeof value === "string" ? value : value.id;
    if (uri == null) throw new Error("missing uri");
    // Abort if origin host is blocked
    if (await shouldBlockInstance(extractDbHost(uri))) throw new StatusError("host blocked", 451, `host ${extractDbHost(uri)} is blocked`);
    const unlock = await getApLock(uri);
    try {
        //#region Returns if already registered with this server
        const exist = await fetchNote(uri);
        if (exist) {
            return exist;
        }
        //#endregion
        if (extractDbHost(uri) === toPuny(config.host)) {
            throw new StatusError("cannot resolve local note", 400, "cannot resolve local note");
        }
        // Fetch from remote server and register
        // If the attached `Note` Object is specified here instead of the uri, the note will be generated without going through the server fetch.
        // Since the attached Note Object may be disguised, always specify the uri and fetch it from the server.
        return await createNote(uri, resolver, true, limiter);
    } finally{
        unlock();
    }
}
export async function extractEmojis(tags, host) {
    host = toPuny(host);
    if (!tags) return [];
    const eomjiTags = toArray(tags).filter(isEmoji);
    return await Promise.all(eomjiTags.map(async (tag)=>{
        const name = tag.name.replace(/^:/, "").replace(/:$/, "");
        tag.icon = toSingle(tag.icon);
        const exists = await Emojis.findOneBy({
            host,
            name
        });
        if (exists) {
            if (tag.updated != null && exists.updatedAt == null || tag.id != null && exists.uri == null || tag.updated != null && exists.updatedAt != null && new Date(tag.updated) > exists.updatedAt || tag.icon.url !== exists.originalUrl || !(exists.width && exists.height)) {
                let size = {
                    width: 0,
                    height: 0
                };
                try {
                    size = await getEmojiSize(tag.icon.url);
                } catch  {
                /* skip if any error happens */ }
                await Emojis.update({
                    host,
                    name
                }, {
                    uri: tag.id,
                    originalUrl: tag.icon.url,
                    publicUrl: tag.icon.url,
                    updatedAt: new Date(),
                    width: size.width || null,
                    height: size.height || null
                });
                return await Emojis.findOneBy({
                    host,
                    name
                });
            }
            return exists;
        }
        logger.info(`register emoji host=${host}, name=${name}`);
        let size = {
            width: 0,
            height: 0
        };
        try {
            size = await getEmojiSize(tag.icon.url);
        } catch  {
        /* skip if any error happens */ }
        return await Emojis.insert({
            id: genId(),
            host,
            name,
            uri: tag.id,
            originalUrl: tag.icon.url,
            publicUrl: tag.icon.url,
            updatedAt: new Date(),
            aliases: [],
            glyph: tag.icon?.type === "image/svg+xml",
            width: size.width || null,
            height: size.height || null
        }).then((x)=>Emojis.findOneByOrFail(x.identifiers[0]));
    }));
}
function notEmpty(partial) {
    return Object.keys(partial).length > 0;
}
export async function updateNote(value, actor, resolver) {
    const uri = typeof value === "string" ? value : value.id;
    if (!uri) throw new Error("Missing note uri");
    // Skip if URI points to this server
    if (extractDbHost(uri) === toPuny(config.host)) throw new Error("uri points local");
    // A new resolver is created if not specified
    if (resolver == null) resolver = new Resolver();
    // Resolve the updated Note object
    const post1 = await resolver.resolve(value);
    if (getOneApId(post1.attributedTo) !== actor.uri || actor.uri == null) {
        throw new Error('Refusing to ingest update for note with mismatching actor');
    }
    // Already registered with this server?
    const note = await Notes.findOneBy({
        uri
    });
    if (note == null) {
        return await createNote(post1, resolver);
    }
    if (note.userId !== actor.id) {
        throw new Error('Refusing to ingest update for note of different user');
    }
    // Whether to tell clients the note has been updated and requires refresh.
    let updating = false;
    // Text parsing
    let text = null;
    if (post1.source?.mediaType === "text/x.misskeymarkdown" && typeof post1.source?.content === "string") {
        text = post1.source.content;
    } else if (typeof post1._misskey_content !== "undefined") {
        text = post1._misskey_content;
    } else if (typeof post1.content === "string") {
        text = await htmlToMfm(post1.content, post1.tag);
    }
    const cw = post1.summary === "" ? null : post1.summary;
    // File parsing
    const fileList = post1.attachment ? Array.isArray(post1.attachment) ? post1.attachment : [
        post1.attachment
    ] : [];
    // Fetch files
    const limit = promiseLimit(2);
    const driveFiles = (await Promise.all(fileList.map((x)=>limit(async ()=>{
            const file = await resolveImage(actor, x);
            const update = {};
            const altText = truncate(x.name, DB_MAX_IMAGE_COMMENT_LENGTH) ?? null;
            if (file.comment !== altText) {
                update.comment = altText;
            }
            // Don't unmark previously marked sensitive files,
            // but if edited post contains sensitive marker, update it.
            if (post1.sensitive && !file.isSensitive) {
                update.isSensitive = post1.sensitive;
            }
            if (notEmpty(update)) {
                await DriveFiles.update(file.id, update);
                updating = true;
            }
            return file;
        })))).filter((file)=>file != null);
    const fileIds = driveFiles.map((file)=>file.id);
    const fileTypes = driveFiles.map((file)=>file.type);
    const apEmojis = (await extractEmojis(post1.tag || [], actor.host).catch((e)=>[])).map((emoji)=>emoji.name);
    const apMentions = await extractApMentions(post1.tag);
    const apHashtags = await extractApHashtags(post1.tag);
    const poll = await extractPollFromQuestion(post1, resolver).catch(()=>undefined);
    const choices = poll?.choices.flatMap((choice)=>mfm.parse(choice)) ?? [];
    const tokens = mfm.parse(text || "").concat(mfm.parse(cw || "")).concat(choices);
    const hashTags = apHashtags || extractHashtags(tokens);
    const mentionUsers = apMentions || await extractMentionedUsers(actor, tokens);
    const mentionUserIds = mentionUsers.map((user)=>user.id);
    const remoteUsers = mentionUsers.filter((user)=>user.host != null);
    const remoteUserIds = remoteUsers.map((user)=>user.id);
    const remoteProfiles = await UserProfiles.findBy({
        userId: In(remoteUserIds)
    });
    const mentionedRemoteUsers = remoteUsers.map((user)=>{
        const profile = remoteProfiles.find((profile)=>profile.userId === user.id);
        return {
            username: user.username,
            host: user.host ?? null,
            uri: user.uri,
            url: profile ? profile.url : undefined
        };
    });
    const update = {};
    if (text && text !== note.text) {
        update.text = text;
    }
    if (cw !== note.cw) {
        update.cw = cw ? cw : null;
    }
    if (fileIds.sort().join(",") !== note.fileIds.sort().join(",")) {
        update.fileIds = fileIds;
        update.attachedFileTypes = fileTypes;
    }
    if (hashTags.sort().join(",") !== note.tags.sort().join(",")) {
        update.tags = hashTags;
    }
    if (mentionUserIds.sort().join(",") !== note.mentions.sort().join(",")) {
        update.mentions = mentionUserIds;
        update.mentionedRemoteUsers = JSON.stringify(mentionedRemoteUsers);
    }
    if (apEmojis.sort().join(",") !== note.emojis.sort().join(",")) {
        update.emojis = apEmojis;
    }
    if (note.hasPoll !== !!poll) {
        update.hasPoll = !!poll;
    }
    if (poll) {
        const dbPoll = await Polls.findOneBy({
            noteId: note.id
        });
        if (poll?.votes != null && poll.votes.find((p)=>!Number.isInteger(p) || p < 0) !== undefined) {
            throw new Error('Refusing to ingest poll with non-integer or negative vote count');
        }
        if (dbPoll == null) {
            await Polls.insert({
                noteId: note.id,
                choices: poll?.choices,
                multiple: poll?.multiple,
                votes: poll?.votes,
                expiresAt: poll?.expiresAt,
                noteVisibility: note.visibility === "hidden" ? "home" : note.visibility,
                userId: actor.id,
                userHost: actor.host
            });
            updating = true;
        } else {
            const choicesChanged = JSON.stringify(dbPoll.choices) !== JSON.stringify(poll.choices);
            if (dbPoll.multiple !== poll.multiple || dbPoll.expiresAt !== poll.expiresAt || dbPoll.noteVisibility !== note.visibility || choicesChanged) {
                await Polls.update({
                    noteId: note.id
                }, {
                    choices: poll?.choices,
                    multiple: poll?.multiple,
                    votes: poll?.votes,
                    expiresAt: poll?.expiresAt,
                    noteVisibility: note.visibility === "hidden" ? "home" : note.visibility
                });
                // Reset votes
                if (choicesChanged) {
                    await PollVotes.delete({
                        noteId: dbPoll.noteId
                    });
                }
                updating = true;
            } else {
                for(let i = 0; i < poll.choices.length; i++){
                    if (dbPoll.votes[i] !== poll.votes?.[i]) {
                        await Polls.update({
                            noteId: note.id
                        }, {
                            votes: poll?.votes
                        });
                        updating = true;
                        break;
                    }
                }
            }
        }
    }
    // Update Note
    if (notEmpty(update)) {
        update.updatedAt = new Date();
        // Save updated note to the database
        await Notes.update({
            uri
        }, update);
        // Save an edit history for the previous note
        await NoteEdits.insert({
            id: genId(),
            noteId: note.id,
            text: note.text,
            cw: note.cw,
            fileIds: note.fileIds,
            updatedAt: update.updatedAt
        });
        updating = true;
    }
    if (updating) {
        // Publish update event for the updated note details
        publishNoteStream(note.id, "updated", {
            updatedAt: update.updatedAt
        });
        const updatedNote = {
            ...note,
            ...update
        };
        publishNoteUpdatesStream("updated", updatedNote);
    }
    return null;
}
