/**
 * @module:headless-shared-parsers
 */
import sizzle from 'sizzle';
import _converse from './_converse.js';
import api from './api/index.js';
import dayjs from 'dayjs';
import log from "@converse/log";
import { Strophe } from 'strophe.js';
import { decodeHTMLEntities } from '../utils/html.js';
import { getAttributes } from '../utils/stanza.js';
import { rejectMessage } from './actions.js';
import { XFORM_TYPE_MAP,  XFORM_VALIDATE_TYPE_MAP } from './constants.js';
import * as errors from './errors.js';


const { NS } = Strophe;

/**
 * @param {Element|Error|null} stanza - The stanza to be parsed. As a convenience,
 * an Error element can be passed in as well, so that this function can be
 * called in a catch block without first checking if a stanza or Error
 * element was received.
 * @returns {Promise<Error|errors.StanzaError|null>}
 */
export async function parseErrorStanza(stanza) {
    if (stanza === null) return null;
    if (stanza instanceof Error) return stanza;
    if (stanza.getAttribute('type') !== 'error') return null;

    const error = stanza.querySelector('error');
    if (!error) return null;

    const els = sizzle(`[xmlns="${Strophe.NS.STANZAS}"]`, error);
    const name = els.filter((el) => el.nodeName && el.nodeName !== 'text').pop()?.nodeName;

    /**
     * *Hook* which allows plugins to add application-specific error parsing
     * @event _converse#parseErrorStanza
     */
    const extra = await api.hook('parseErrorStanza', stanza, {});

    if (name === 'bad-request') {
        return new errors.BadRequestError(name, error, extra);
    } else if (name === 'conflict') {
        return new errors.ConflictError(name, error, extra);
    } else if (name === 'feature-not-implemented') {
        return new errors.FeatureNotImplementedError(name, error, extra);
    } else if (name === 'forbidden') {
        return new errors.ForbiddenError(name, error, extra);
    } else if (name === 'gone') {
        return new errors.GoneError(name, error, extra);
    } else if (name === 'internal-server-error') {
        return new errors.InternalServerError(name, error, extra);
    } else if (name === 'item-not-found') {
        return new errors.ItemNotFoundError(name, error, extra);
    } else if (name === 'jid-malformed') {
        return new errors.JIDMalformedError(name, error, extra);
    } else if (name === 'not-acceptable') {
        return new errors.NotAcceptableError(name, error, extra);
    } else if (name === 'not-allowed') {
        return new errors.NotAllowedError(name, error, extra);
    } else if (name === 'not-authorized') {
        return new errors.NotAuthorizedError(name, error, extra);
    } else if (name === 'payment-required') {
        return new errors.PaymentRequiredError(name, error, extra);
    } else if (name === 'recipient-unavailable') {
        return new errors.RecipientUnavailableError(name, error, extra);
    } else if (name === 'redirect') {
        return new errors.RedirectError(name, error, extra);
    } else if (name === 'registration-required') {
        return new errors.RegistrationRequiredError(name, error, extra);
    } else if (name === 'remote-server-not-found') {
        return new errors.RemoteServerNotFoundError(name, error, extra);
    } else if (name === 'remote-server-timeout') {
        return new errors.RemoteServerTimeoutError(name, error, extra);
    } else if (name === 'resource-constraint') {
        return new errors.ResourceConstraintError(name, error, extra);
    } else if (name === 'service-unavailable') {
        return new errors.ServiceUnavailableError(name, error, extra);
    } else if (name === 'subscription-required') {
        return new errors.SubscriptionRequiredError(name, error, extra);
    } else if (name === 'undefined-condition') {
        return new errors.UndefinedConditionError(name, error, extra);
    } else if (name === 'unexpected-request') {
        return new errors.UnexpectedRequestError(name, error, extra);
    }
    return new errors.StanzaError('unknown', error);
}

/**
 * Extract the XEP-0359 stanza IDs from the passed in stanza
 * and return a map containing them.
 * @param {Element} stanza - The message stanza
 * @param {Element} original_stanza - The encapsulating stanza which contains
 *      the message stanza.
 * @returns {Object}
 */
export function getStanzaIDs (stanza, original_stanza) {
    // Generic stanza ids
    const sids = sizzle(`stanza-id[xmlns="${Strophe.NS.SID}"]`, stanza);
    const sid_attrs = sids.reduce((acc, s) => {
        acc[`stanza_id ${s.getAttribute('by')}`] = s.getAttribute('id');
        return acc;
    }, {});

    // Origin id
    const origin_id = sizzle(`origin-id[xmlns="${Strophe.NS.SID}"]`, stanza).pop()?.getAttribute('id');

    const attrs = {
        origin_id,
        msgid: stanza.getAttribute('id') || original_stanza.getAttribute('id'),
        ...sid_attrs,
    };

    // Store the archive id
    const result = sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, original_stanza).pop();
    if (result) {
        const bare_jid = _converse.session.get('bare_jid');
        const by_jid = original_stanza.getAttribute('from') || bare_jid;
        attrs[`stanza_id ${by_jid}`] = result.getAttribute('id');
    }

    return attrs;
}

/**
 * @param {Element} stanza
 * @returns {import('./types').EncryptionAttrs}
 */
export function getEncryptionAttributes (stanza) {
    const eme_tag = sizzle(`encryption[xmlns="${Strophe.NS.EME}"]`, stanza).pop();
    const namespace = eme_tag?.getAttribute('namespace');
    const attrs = {};
    if (namespace) {
        attrs.is_encrypted = true;
        attrs.encryption_namespace = namespace;
    } else if (sizzle(`encrypted[xmlns="${Strophe.NS.OMEMO}"]`, stanza).pop()) {
        attrs.is_encrypted = true;
        attrs.encryption_namespace = Strophe.NS.OMEMO;
    }
    return attrs;
}

/**
 * @param {Element} stanza - The message stanza
 * @param {Element} original_stanza - The original stanza, that contains the
 *  message stanza, if it was contained, otherwise it's the message stanza itself.
 * @returns {import('./types').RetractionAttrs | {}}
 */
export function getDeprecatedRetractionAttributes (stanza, original_stanza) {
    const fastening = sizzle(`> apply-to[xmlns="${Strophe.NS.FASTEN}"]`, stanza).pop();
    if (fastening) {
        const applies_to_id = fastening.getAttribute('id');
        const retracted = sizzle(`> retract[xmlns="${Strophe.NS.RETRACT0}"]`, fastening).pop();
        if (retracted) {
            const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop();
            const time = delay ? dayjs(delay.getAttribute('stamp')).toISOString() : new Date().toISOString();
            return {
                editable: false,
                retracted: time,
                retracted_id: applies_to_id
            };
        }
    }
    return {};
}

/**
 * @param {Element} stanza - The message stanza
 * @param {Element} original_stanza - The original stanza, that contains the
 *  message stanza, if it was contained, otherwise it's the message stanza itself.
 * @returns {import('./types').RetractionAttrs | {}}
 */
export function getRetractionAttributes (stanza, original_stanza) {
    const retraction = sizzle(`> retract[xmlns="${Strophe.NS.RETRACT}"]`, stanza).pop();
    if (retraction) {
        const delay = sizzle(`> delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop();
        const time = delay ? dayjs(delay.getAttribute('stamp')).toISOString() : new Date().toISOString();
        return {
            editable: false,
            retracted: time,
            retracted_id: retraction.getAttribute('id')
        };
    } else {
        const tombstone =
            sizzle(`> retracted[xmlns="${Strophe.NS.RETRACT}"]`, stanza).pop() ||
            sizzle(`> retracted[xmlns="${Strophe.NS.RETRACT0}"]`, stanza).pop();
        if (tombstone) {
            return {
                editable: false,
                is_tombstone: true,
                retracted: tombstone.getAttribute('stamp'),
                retraction_id: tombstone.getAttribute('id')
            };
        }
    }
    return getDeprecatedRetractionAttributes(stanza, original_stanza);
}

/**
 * @param {Element} stanza
 * @param {Element} original_stanza
 */
export function getCorrectionAttributes (stanza, original_stanza) {
    const el = sizzle(`replace[xmlns="${Strophe.NS.MESSAGE_CORRECT}"]`, stanza).pop();
    if (el) {
        const replace_id = el.getAttribute('id');
        if (replace_id) {
            const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop();
            const time = delay ? dayjs(delay.getAttribute('stamp')).toISOString() : new Date().toISOString();
            return {
                replace_id,
                'edited': time
            };
        }
    }
    return {};
}

/**
 * @param {Element} stanza
 */
export function getOpenGraphMetadata (stanza) {
    const fastening = sizzle(`> apply-to[xmlns="${Strophe.NS.FASTEN}"]`, stanza).pop();
    if (fastening) {
        const applies_to_id = fastening.getAttribute('id');
        const meta = sizzle(`> meta[xmlns="${Strophe.NS.XHTML}"]`, fastening);
        if (meta.length) {
            const msg_limit = api.settings.get('message_limit');
            const data = meta.reduce((acc, el) => {
                const property = el.getAttribute('property');
                if (property) {
                    let value = decodeHTMLEntities(el.getAttribute('content') || '');
                    if (msg_limit && property === 'og:description' && value.length >= msg_limit) {
                        value = `${value.slice(0, msg_limit)}${decodeHTMLEntities('&#8230;')}`;
                    }
                    acc[property] = value;
                }
                return acc;
            }, {
                ogp_for_id: applies_to_id,
            });

            const url = data['og:url'];
            if (url?.startsWith('/') && data['og:site_name']?.toLowerCase() === 'github') {
                data['og:url'] = `https://github.com${url}`;
            }

            if ("og:description" in data || "og:title" in data || "og:image" in data) {
                return data;
            }
        }
    }
    return {};
}


/**
 * @param {Element} stanza
 */
export function getSpoilerAttributes (stanza) {
    const spoiler = sizzle(`spoiler[xmlns="${Strophe.NS.SPOILER}"]`, stanza).pop();
    return {
        'is_spoiler': !!spoiler,
        'spoiler_hint': spoiler?.textContent
    };
}

/**
 * @param {Element} stanza
 */
export function getOutOfBandAttributes (stanza) {
    const xform = sizzle(`x[xmlns="${Strophe.NS.OUTOFBAND}"]`, stanza).pop();
    if (xform) {
        return {
            'oob_url': xform.querySelector('url')?.textContent,
            'oob_desc': xform.querySelector('desc')?.textContent
        };
    }
    return {};
}

/**
 * Returns the human readable error message contained in a `groupchat` message stanza of type `error`.
 * @param {Element} stanza - The message stanza
 */
export function getErrorAttributes (stanza) {
    if (stanza.getAttribute('type') === 'error') {
        const error = stanza.querySelector('error');
        const text = sizzle(`text[xmlns="${Strophe.NS.STANZAS}"]`, error).pop();
        return {
            is_error: true,
            error_text: text?.textContent,
            error_type: error.getAttribute('type'),
            error_condition: error.firstElementChild.nodeName,
            errors: Array.from(error.children).map((e) => ({ name: e.nodeName, xmlns: e.getAttribute('xmlns') })),
        };
    }
    return {};
}

/**
 * Given a message stanza, find and return any XEP-0372 references
 * @param {Element} stanza - The message stanza
 * @returns {import('./types').XEP372Reference[]}
 */
export function getReferences (stanza) {
    return sizzle(`reference[xmlns="${Strophe.NS.REFERENCE}"]`, stanza).map(ref => {
        const anchor = ref.getAttribute('anchor');
        const text = stanza.querySelector(anchor ? `#${anchor}` : 'body')?.textContent;
        if (!text) {
            log.warn(`Could not find referenced text for ${ref}`);
            return null;
        }
        const begin = Number(ref.getAttribute('begin'));
        const end = Number(ref.getAttribute('end'));
        return {
            begin, end,
            type: ref.getAttribute('type'),
            value: text.slice(begin, end),
            uri: ref.getAttribute('uri')
        };
    }).filter(r => r);
}

/**
 * @param {Element} stanza
 */
export function getReceiptId (stanza) {
    const receipt = sizzle(`received[xmlns="${Strophe.NS.RECEIPTS}"]`, stanza).pop();
    return receipt?.getAttribute('id');
}

/**
 * Determines whether the passed in stanza is a XEP-0280 Carbon
 * @param {Element} stanza - The message stanza
 * @returns {Boolean}
 */
export function isCarbon (stanza) {
    const xmlns = Strophe.NS.CARBONS;
    return (
        sizzle(`message > received[xmlns="${xmlns}"]`, stanza).length > 0 ||
        sizzle(`message > sent[xmlns="${xmlns}"]`, stanza).length > 0
    );
}

/**
 * Returns the XEP-0085 chat state contained in a message stanza
 * @param {Element} stanza - The message stanza
 */
export function getChatState (stanza) {
    return sizzle(
        `
        composing[xmlns="${NS.CHATSTATES}"],
        paused[xmlns="${NS.CHATSTATES}"],
        inactive[xmlns="${NS.CHATSTATES}"],
        active[xmlns="${NS.CHATSTATES}"],
        gone[xmlns="${NS.CHATSTATES}"]`,
        stanza
    ).pop()?.nodeName;
}

/**
 * @param {Element} stanza
 * @param {Object} attrs
 */
export function isValidReceiptRequest (stanza, attrs) {
    return (
        attrs.sender !== 'me' &&
        !attrs.is_carbon &&
        !attrs.is_archived &&
        sizzle(`request[xmlns="${Strophe.NS.RECEIPTS}"]`, stanza).length
    );
}

/**
 * Check whether the passed-in stanza is a forwarded message that is "bare",
 * i.e. it's not forwarded as part of a larger protocol, like MAM.
 * @param { Element } stanza
 */
export function throwErrorIfInvalidForward (stanza) {
    const bare_forward = sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).length;
    if (bare_forward) {
        rejectMessage(stanza, 'Forwarded messages not part of an encapsulating protocol are not supported');
        const from_jid = stanza.getAttribute('from');
        throw new errors.StanzaParseError(stanza, `Ignoring unencapsulated forwarded message from ${from_jid}`);
    }
}

/**
 * Determines whether the passed in stanza is a XEP-0333 Chat Marker
 * @method getChatMarker
 * @param {Element} stanza - The message stanza
 * @returns {Element}
 */
export function getChatMarker (stanza) {
    // If we receive more than one marker (which shouldn't happen), we take
    // the highest level of acknowledgement.
    return sizzle(`
        acknowledged[xmlns="${Strophe.NS.MARKERS}"],
        displayed[xmlns="${Strophe.NS.MARKERS}"],
        received[xmlns="${Strophe.NS.MARKERS}"]`,
        stanza
    ).pop();
}

/**
 * @param {Element} stanza
 * @returns {boolean}
 */
export function isHeadline (stanza) {
    return stanza.getAttribute('type') === 'headline';
}

/**
 * @param {Element} stanza
 * @returns {Promise<boolean>}
 */
export async function isMUCPrivateMessage (stanza) {
    const bare_jid = Strophe.getBareJidFromJid(stanza.getAttribute('from'));
    return !!(await api.rooms.get(bare_jid));
}

/**
 * @param {Element} stanza
 * @returns {boolean}
 */
export function isServerMessage (stanza) {
    if (sizzle(`mentions[xmlns="${Strophe.NS.MENTIONS}"]`, stanza).pop()) {
        return false;
    }
    const from_jid = stanza.getAttribute('from');
    if (stanza.getAttribute('type') !== 'error' && from_jid && !from_jid.includes('@')) {
        // Some servers (e.g. Prosody) don't set the stanza
        // type to "headline" when sending server messages.
        // For now we check if an @ signal is included, and if not,
        // we assume it's a headline stanza.
        return true;
    }
    return false;
}

/**
 * Determines whether the passed in stanza is a XEP-0313 MAM stanza
 * @method isArchived
 * @param {Element} original_stanza - The message stanza
 * @returns {boolean}
 */
export function isArchived (original_stanza) {
    return !!sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, original_stanza).pop();
}

/**
 * @param {Element} field
 * @param {boolean} readonly
 * @param {Element} stanza
 * @return {import('./types').XFormField}
 */
function parseXFormField(field, readonly, stanza) {
    const v = field.getAttribute('var');
    const label = field.getAttribute('label') || '';
    const type = field.getAttribute('type');
    const desc = field.querySelector('desc')?.textContent;
    const result = { readonly, desc };

    if (type === 'list-single' || type === 'list-multi') {
        const values = Array.from(field.querySelectorAll(':scope > value')).map((el) => el?.textContent);
        const options = Array.from(field.querySelectorAll(':scope > option')).map(
            (/** @type {HTMLElement} */ option) => {
                const value = option.querySelector('value')?.textContent;
                return {
                    value,
                    label: option.getAttribute('label'),
                    selected: values.includes(value),
                    required: !!field.querySelector('required'),
                    ...result,
                };
            }
        );
        return {
            type,
            options,
            label: field.getAttribute('label'),
            var: v,
            required: !!field.querySelector('required'),
            ...result,
        };
    } else if (type === 'fixed') {
        const text = field.querySelector('value')?.textContent;
        return { text, label, type, var: v, ...result };
    } else if (type === 'jid-multi') {
        return {
            type,
            var: v,
            label,
            value: field.querySelector('value')?.textContent,
            required: !!field.querySelector('required'),
            ...result,
        };
    } else if (type === 'boolean') {
        const value = field.querySelector('value')?.textContent;
        return {
            type,
            var: v,
            label,
            checked: ((value === '1' || value === 'true') && true) || false,
            ...result,
        };
    } else if (v === 'url') {
        return {
            var: v,
            label,
            value: field.querySelector('value')?.textContent,
            ...result,
        };
    } else if (v === 'username') {
        return {
            var: v,
            label,
            value: field.querySelector('value')?.textContent,
            required: !!field.querySelector('required'),
            type: getInputType(field),
            ...result,
        };
    } else if (v === 'password') {
        return {
            var: v,
            label,
            value: field.querySelector('value')?.textContent,
            required: !!field.querySelector('required'),
            ...result,
        };
    } else if (v === 'ocr') { // Captcha
        const uri = field.querySelector('uri');
        const el = sizzle('data[cid="' + uri.textContent.replace(/^cid:/, '') + '"]', stanza)[0];
        return {
            label: field.getAttribute('label'),
            var: v,
            uri: {
                type: uri.getAttribute('type'),
                data: el?.textContent,
            },
            required: !!field.querySelector('required'),
            ...result,
        };
    } else {
        return {
            label,
            var: v,
            required: !!field.querySelector('required'),
            value: field.querySelector('value')?.textContent,
            type: getInputType(field),
            ...result,
        };
    }
}

/**
 * @param {Element} field
 */
export function getInputType(field) {
    const type = XFORM_TYPE_MAP[field.getAttribute('type')]
    if (type == 'text') {
        const datatypes = field.getElementsByTagNameNS("http://jabber.org/protocol/xdata-validate", "validate");
        if (datatypes.length === 1) {
            const datatype = datatypes[0].getAttribute("datatype");
            return XFORM_VALIDATE_TYPE_MAP[datatype] || type;
        }
    }
    return type;
}

/**
 * @param {Element} stanza
 * @returns {import('./types').XForm}
 */
export function parseXForm(stanza) {
    const xs = sizzle(`x[xmlns="${Strophe.NS.XFORM}"]`, stanza);
    if (xs.length > 1) {
        log.error(stanza);
        throw new Error('Invalid stanza');
    } else if (xs.length === 0) {
        return null;
    }

    const x = xs[0];
    const type = /** @type {import('./types').XFormResponseType} */ (x.getAttribute('type'));
    const result = {
        type,
        title: x.querySelector('title')?.textContent,
    };

    if (type === 'result') {
        const reported = x.querySelector(':scope > reported');
        if (reported) {
            const reported_fields = reported ? Array.from(reported.querySelectorAll(':scope > field')) : [];
            const items = Array.from(x.querySelectorAll(':scope > item'));
            return /** @type {import('./types').XForm} */({
                ...result,
                reported: /** @type {import('./types').XFormReportedField[]} */ (reported_fields.map(getAttributes)),
                items: items.map((item) => {
                    return Array.from(item.querySelectorAll('field')).map((field) => {
                        return /** @type {import('./types').XFormResultItemField} */ ({
                            ...getAttributes(field),
                            value: field.querySelector('value')?.textContent ?? '',
                        });
                    });
                }),
            });
        }
        return {
            ...result,
            fields: Array.from(x.querySelectorAll('field')).map((field) => parseXFormField(field, true, stanza)),
        };
    } else if (type === 'form') {
        return {
            ...result,
            instructions: x.querySelector('instructions')?.textContent,
            fields: Array.from(x.querySelectorAll('field')).map((field) => parseXFormField(field, false, stanza)),
        };
    } else {
        throw new Error(`Invalid type in XForm response stanza: ${type}`);
    }
}
