const nql = require('@tryghost/nql');
const logging = require('@tryghost/logging');
const {Readable} = require('stream');

const EXPORT_BATCH_SIZE = 50;
const DEFAULT_EXPORT_LIMIT = 15;
const EXPORT_WITH_RELATED = [
    'tiers',
    'tags',
    'authors',
    'count.signups',
    'count.paid_conversions',
    'count.clicks',
    'count.positive_feedback',
    'count.negative_feedback',
    'email'
];

function parseExportLimit(limit) {
    if (limit === 'all') {
        return Infinity;
    }

    if (limit === undefined || limit === null) {
        return DEFAULT_EXPORT_LIMIT;
    }

    const parsed = Number.parseInt(limit, 10);

    if (!Number.isSafeInteger(parsed)) {
        return DEFAULT_EXPORT_LIMIT;
    }

    return Math.max(parsed, 1);
}

function hasIdOrder(order) {
    return String(order)
        .split(',')
        .map(orderPart => orderPart.trim().toLowerCase())
        .some((orderPart) => {
            const [field] = orderPart.split(/\s+/);
            return field === 'id' || field === 'posts.id';
        });
}

function withStableExportOrder(order) {
    if (!order || hasIdOrder(order)) {
        return order;
    }

    return `${order}, id desc`;
}

class PostsExporter {
    #models;
    #getPostUrl;
    #settingsCache;
    #settingsHelpers;

    /**
     * @param {Object} dependencies
     * @param {Object} dependencies.models
     * @param {Object} dependencies.models.Post
     * @param {Object} dependencies.models.Newsletter
     * @param {Object} dependencies.models.Label
     * @param {Object} dependencies.models.Product
     * @param {Object} dependencies.getPostUrl
     * @param {Object} dependencies.settingsCache
     * @param {Object} dependencies.settingsHelpers
     */
    constructor({models, getPostUrl, settingsCache, settingsHelpers}) {
        this.#models = models;
        this.#getPostUrl = getPostUrl;
        this.#settingsCache = settingsCache;
        this.#settingsHelpers = settingsHelpers;
    }

    /**
     * @param {object} options
     * @param {string} [options.filter]
     * @param {string} [options.order]
     * @param {string|number} [options.limit]
     * @returns {Promise<Readable>}
     */
    async export({filter, order, limit, mongoTransformer}) {
        const exportContext = await this.#getExportContext();
        const requestedLimit = parseExportLimit(limit);
        const pageLimit = Math.min(EXPORT_BATCH_SIZE, requestedLimit);

        return Readable.from(this.#streamPosts({
            filter,
            order: withStableExportOrder(order),
            requestedLimit,
            pageLimit,
            exportContext,
            mongoTransformer
        }), {objectMode: true});
    }

    /**
     * @typedef {object} ExportContext
     * @property {object[]} newsletters
     * @property {object[]} labels
     * @property {object[]} tiers
     * @property {boolean} membersEnabled
     * @property {boolean} membersTrackSources
     * @property {boolean} paidMembersEnabled
     * @property {boolean} trackOpens
     * @property {boolean} trackClicks
     * @property {boolean} hasNewslettersWithFeedback
     */

    /**
     * @param {object} options
     * @param {string} [options.filter]
     * @param {string} [options.order]
     * @param {number} options.requestedLimit - Infinity means unbounded
     * @param {number} options.pageLimit
     * @param {ExportContext} options.exportContext
     * @returns {AsyncGenerator<object>}
     */
    async *#streamPosts({filter, order, requestedLimit, pageLimit, exportContext, mongoTransformer}) {
        let emitted = 0;

        for (let page = 1; emitted < requestedLimit; page++) {
            const posts = await this.#models.Post.findPage({
                filter: filter ?? 'status:published,status:sent',
                order,
                limit: pageLimit,
                page,
                skipPagination: true,
                withRelated: EXPORT_WITH_RELATED,
                mongoTransformer
            });

            if (posts.data.length === 0) {
                break;
            }

            const remaining = requestedLimit - emitted;
            const batch = posts.data.slice(0, remaining);
            const mapped = this.#mapPosts(batch, exportContext);
            emitted += mapped.length;
            yield* mapped;

            if (posts.data.length < pageLimit) {
                break;
            }
        }
    }

    async #getExportContext() {
        const [
            newsletters,
            labels,
            tiers
        ] = await Promise.all([
            this.#models.Newsletter.findAll(),
            this.#models.Label.findAll(),
            this.#models.Product.findAll()
        ]);

        const membersEnabled = this.#settingsHelpers.isMembersEnabled();
        const membersTrackSources = membersEnabled && this.#settingsCache.get('members_track_sources');
        const paidMembersEnabled = membersEnabled && this.#settingsHelpers.arePaidMembersEnabled();
        const trackOpens = this.#settingsCache.get('email_track_opens');
        const trackClicks = this.#settingsCache.get('email_track_clicks');
        const hasNewslettersWithFeedback = !!newsletters.models.find(newsletter => newsletter.get('feedback_enabled'));

        return {
            newsletters: newsletters.models,
            labels: labels.models,
            tiers: tiers.models,
            membersEnabled,
            membersTrackSources,
            paidMembersEnabled,
            trackOpens,
            trackClicks,
            hasNewslettersWithFeedback
        };
    }

    #mapPosts(posts, exportContext) {
        const {
            newsletters,
            labels,
            tiers,
            membersEnabled,
            membersTrackSources,
            paidMembersEnabled,
            trackOpens,
            trackClicks,
            hasNewslettersWithFeedback
        } = exportContext;

        const mapped = posts.map((post) => {
            let email = post.related('email');

            // Weird bookshelf thing fix
            if (!email.id) {
                email = null;
            }

            let published = true;
            if (post.get('status') === 'draft' || post.get('status') === 'scheduled') {
                // Manually clear it to avoid including information for a post that was reverted to draft
                email = null;
                published = false;
            }

            const feedbackEnabled = email && email.get('feedback_enabled') && hasNewslettersWithFeedback;
            const showEmailClickAnalytics = trackClicks && email && email.get('track_clicks');

            return {
                id: post.get('id'),
                title: post.get('title'),
                url: this.#getPostUrl(post),
                author: post.related('authors').map(author => author.get('name')).join(', '),
                status: this.mapPostStatus(post.get('status'), !!email),
                created_at: post.get('created_at'),
                updated_at: post.get('updated_at'),
                published_at: published ? post.get('published_at') : null,
                featured: post.get('featured'),
                tags: post.related('tags').map(tag => tag.get('name')).join(', '),
                post_access: this.postAccessToString(post),
                email_recipients: email ? this.humanReadableEmailRecipientFilter(email?.get('recipient_filter'), labels, tiers) : null,
                newsletter_name: newsletters.length > 1 && post.get('newsletter_id') && email ? newsletters.find(newsletter => newsletter.get('id') === post.get('newsletter_id'))?.get('name') : null,
                sends: email?.get('email_count') ?? null,
                opens: trackOpens ? (email?.get('opened_count') ?? null) : null,
                clicks: showEmailClickAnalytics ? (post.get('count__clicks') ?? 0) : null,
                signups: membersTrackSources && published ? (post.get('count__signups') ?? 0) : null,
                paid_conversions: membersTrackSources && paidMembersEnabled && published ? (post.get('count__paid_conversions') ?? 0) : null,
                feedback_more_like_this: feedbackEnabled ? (post.get('count__positive_feedback') ?? 0) : null,
                feedback_less_like_this: feedbackEnabled ? (post.get('count__negative_feedback') ?? 0) : null
            };
        });

        if (mapped.length) {
            // Limit the amount of removeable columns so the structure is consistent depending on global settings
            const removeableColumns = [];

            if (newsletters.length <= 1) {
                removeableColumns.push('newsletter_name');
            }

            if (!membersEnabled) {
                removeableColumns.push('email_recipients', 'sends', 'opens', 'clicks', 'feedback_more_like_this', 'feedback_less_like_this');
            } else if (!hasNewslettersWithFeedback) {
                removeableColumns.push('feedback_more_like_this', 'feedback_less_like_this');
            }

            if (membersEnabled && !trackClicks) {
                removeableColumns.push('clicks');
            }

            if (membersEnabled && !trackOpens) {
                removeableColumns.push('opens');
            }

            if (!membersTrackSources || !membersEnabled) {
                removeableColumns.push('signups', 'paid_conversions');
            } else if (!paidMembersEnabled) {
                removeableColumns.push('paid_conversions');
            }

            for (const columnToRemove of removeableColumns) {
                for (const row of mapped) {
                    delete row[columnToRemove];
                }
            }
        }

        return mapped;
    }

    mapPostStatus(status, hasEmail) {
        if (status === 'draft') {
            return 'draft';
        }

        if (status === 'scheduled') {
            return 'scheduled';
        }

        if (status === 'sent') {
            return 'emailed only';
        }

        if (status === 'published') {
            if (hasEmail) {
                return 'published and emailed';
            }
            return 'published only';
        }
        return status;
    }

    postAccessToString(post) {
        const visibility = post.get('visibility');
        if (visibility === 'public') {
            return 'Public';
        }

        if (visibility === 'members') {
            return 'Members-only';
        }

        if (visibility === 'paid') {
            return 'Paid members-only';
        }

        if (visibility === 'tiers') {
            const tiers = post.related('tiers');
            if (tiers.length === 0) {
                return 'Specific tiers: none';
            }

            return 'Specific tiers: ' + tiers.map(tier => tier.get('name')).join(', ');
        }

        return visibility;
    }

    /**
     * @private Convert an email filter to a human readable string
     * @param {string} recipientFilter
     * @param {*} allLabels
     * @param {*} allTiers
     * @returns
     */
    humanReadableEmailRecipientFilter(recipientFilter, allLabels, allTiers) {
        // Examples: "label:test"; "label:test,label:batch1"; "status:-free,label:test", "all"
        if (recipientFilter === 'all') {
            return 'All subscribers';
        }

        try {
            const parsed = nql(recipientFilter).parse();
            const strings = this.filterToString(parsed, allLabels, allTiers);
            return strings.join(', ');
        } catch (e) {
            logging.error(e);
            return recipientFilter;
        }
    }

    /**
     * @private Convert an email filter to a human readable string
     * @param {*} filter Parsed NQL filter
     * @param {*} allLabels All available member labels
     * @returns
     */
    filterToString(filter, allLabels, allTiers) {
        const strings = [];
        if (filter.$and) {
            // Not supported
        } else if (filter.$or) {
            for (const subfilter of filter.$or) {
                strings.push(...this.filterToString(subfilter, allLabels, allTiers));
            }
        } else {
            for (const key of Object.keys(filter)) {
                if (key === 'label') {
                    if (typeof filter.label === 'string') {
                        const labelSlug = filter.label;
                        const label = allLabels.find(l => l.get('slug') === labelSlug);
                        if (label) {
                            strings.push(label.get('name'));
                        } else {
                            strings.push(labelSlug);
                        }
                    }
                }
                if (key === 'tier') {
                    if (typeof filter.tier === 'string') {
                        const tierSlug = filter.tier;
                        const tier = allTiers.find(l => l.get('slug') === tierSlug);
                        if (tier) {
                            strings.push(tier.get('name'));
                        } else {
                            strings.push(tierSlug);
                        }
                    }
                }
                if (key === 'status') {
                    if (typeof filter.status === 'string') {
                        if (filter.status === 'free') {
                            strings.push('Free subscribers');
                        } else if (filter.status === 'paid') {
                            strings.push('Paid subscribers');
                        } else if (filter.status === 'comped') {
                            strings.push('Complimentary subscribers');
                        }
                    } else {
                        if (filter.status.$ne === 'free') {
                            strings.push('Paid subscribers');
                        }

                        if (filter.status.$ne === 'paid') {
                            strings.push('Free subscribers');
                        }
                    }
                }
            }
        }

        return strings;
    }
}

module.exports = PostsExporter;
