/*
 * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
 * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License version 2 as
 * published by the Free Software Foundation.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 */

#include "messageview.h"

#include <QApplication>
#include <QDesktopServices>
#include <QDialog>
#include <QDialogButtonBox>
#include <QDir>
#include <QFileDialog>
#include <QHBoxLayout>
#include <QLabel>
#include <QLocale>
#include <QMouseEvent>
#include <QPushButton>
#include <QStandardPaths>
#include <QtNumeric>
#include <QTimer>
#include <QTreeWidget>
#include <QVBoxLayout>
#include <QWheelEvent>
#include <QWebEnginePage>
#include <QWebEngineProfile>
#include <QWebEngineSettings>
#include <QWebEngineView>

#include <algorithm>

#include "cidschemehandler.h"
#include "htmlbuilder.h"
#include "requestinterceptor.h"
#include "tagstrip.h"
#include "threadcidmap.h"

namespace {

/// Intercepts link clicks so a message can never navigate the pane.
class MessagePage : public QWebEnginePage
{
public:
    MessagePage(QWebEngineProfile *profile, QObject *parent)
        : QWebEnginePage(profile, parent) {}

protected:
    bool acceptNavigationRequest(const QUrl &url, NavigationType type,
                                 bool isMainFrame) override
    {
        // setHtml() does NOT navigate to the base URL it is given: it
        // navigates to a data: URL carrying the markup, and applies the base
        // URL afterwards as the document's origin. Verified empirically on Qt
        // 6.11; an earlier version of this function compared against
        // documentUrl() here and rejected every document load, so nothing
        // rendered at all.
        //
        // A typed main-frame navigation is therefore one we initiated
        // ourselves, and is accepted on that basis. This is not the security
        // boundary: RequestInterceptor still vets every request the document
        // goes on to make, including the qtmaildir: origin itself.
        if (type == NavigationTypeTyped && isMainFrame)
            return true;

        if (type == NavigationTypeLinkClicked) {
            QDesktopServices::openUrl(url);
            return false;
        }

        // Subframe loads are still subject to the interceptor; a main-frame
        // navigation would replace the pane, which no message may do.
        return !isMainFrame;
    }
};

} // namespace

MessageView::MessageView(QWidget *parent)
    : QWidget(parent)
{
    // Off-the-record profile: no cookies, no cache, nothing persisted.
    m_profile = new QWebEngineProfile(this);
    m_profile->setHttpCacheType(QWebEngineProfile::NoCache);
    m_profile->setPersistentCookiesPolicy(QWebEngineProfile::NoPersistentCookies);

    m_interceptor = new RequestInterceptor(this);
    m_profile->setUrlRequestInterceptor(m_interceptor);

    m_cidHandler = new CidSchemeHandler(this);
    m_profile->installUrlSchemeHandler(QByteArrayLiteral("cid"), m_cidHandler);

    m_view = new QWebEngineView(this);
    m_view->setPage(new MessagePage(m_profile, m_view));

    QWebEngineSettings *settings = m_view->settings();
    settings->setAttribute(QWebEngineSettings::JavascriptEnabled, false);
    settings->setAttribute(QWebEngineSettings::LocalContentCanAccessRemoteUrls, false);
    settings->setAttribute(QWebEngineSettings::LocalContentCanAccessFileUrls, false);
    settings->setAttribute(QWebEngineSettings::PluginsEnabled, false);
    settings->setAttribute(QWebEngineSettings::FullScreenSupportEnabled, false);

    // Ctrl+wheel zoom. The filter goes on the application rather than on
    // m_view: the wheel event is delivered to an internal QQuickWidget the
    // view creates lazily, so there is no child to filter at this point and a
    // filter on m_view itself would never see it. eventFilter() narrows by
    // ancestry, so no event outside this pane is touched.
    qApp->installEventFilter(this);

    m_headerLabel = new QLabel(this);
    m_headerLabel->setTextFormat(Qt::RichText);
    m_headerLabel->setWordWrap(true);
    m_headerLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);

    m_blockedLabel = new QLabel(tr("Remote content blocked"), this);
    m_loadRemoteButton = new QPushButton(tr("Load remote content"), this);
    connect(m_loadRemoteButton, &QPushButton::clicked,
            this, &MessageView::loadRemoteContent);

    auto *blockedRow = new QHBoxLayout;
    blockedRow->addWidget(m_blockedLabel);
    blockedRow->addWidget(m_loadRemoteButton);
    blockedRow->addStretch();

    m_attachmentBar = new QWidget(this);
    new QHBoxLayout(m_attachmentBar);

    // Tags live under the message rather than in the thread list, where
    // spelling them out cost most of the list's width.
    m_tagStrip = new TagStrip(this);
    m_tagStrip->hide();

    auto *layout = new QVBoxLayout(this);
    layout->addWidget(m_headerLabel);
    layout->addLayout(blockedRow);
    layout->addWidget(m_view, 1);
    layout->addWidget(m_attachmentBar);
    layout->addWidget(m_tagStrip);

    clear();
}

MessageView::~MessageView() = default;

void MessageView::setTagColors(const TagColors *colours)
{
    m_tagStrip->setTagColors(colours);
}

void MessageView::setTags(const QStringList &tags)
{
    m_tagStrip->setTags(tags);
}

/// The single place that loads a document into the view.
///
/// RequestInterceptor trusts exactly one qtmaildir: URL and denies every other
/// URL on that scheme, so the base URL given to setHtml() and the one given to
/// setDocumentUrl() must be identical. Routing every load through here is what
/// makes that true by construction rather than by remembering to pair two calls
/// at each site.
void MessageView::setDocument(const QString &html)
{
    m_interceptor->setDocumentUrl(documentUrl());
    m_view->setHtml(html, documentUrl());
}

void MessageView::clear()
{
    m_items.clear();
    m_tagStrip->setTags({});

    // No thread is displayed, so nothing may be served or allowed. Without
    // this, the previous thread's parts would stay reachable.
    m_cidHandler->setParts({});
    m_interceptor->setAllowedCids({});
    m_interceptor->resetForNewMessage();

    setDocument(QString());
    m_headerLabel->clear();
    m_blockedLabel->hide();
    m_loadRemoteButton->hide();

    // clear() does not go through render(), so the bar has to be emptied
    // here or the previous thread's attachments stay offered.
    rebuildAttachmentBar();
}

void MessageView::showThread(const QList<ThreadRenderItem> &items)
{
    m_items = items;
    m_preferHtml = true;

    // Every thread starts from a clean policy: no remote grant carries over.
    m_interceptor->resetForNewMessage();

    // Resetting the policy is not enough on its own. Anything fetched under a
    // previous grant stays in the engine's caches, and a cached resource is
    // painted without the interceptor being consulted at all, so returning to
    // a thread would show its remote images again with the grant switched off.
    // The policy would be right and the pane would still be lying.
    //
    // clearHttpCache() empties the profile's store, but the render process
    // keeps its own decoded-image cache keyed on the document, and that one
    // outlives a setHtml() of the same URL. Loading about:blank first discards
    // the previous document entirely, which is what actually drops those
    // images. Verified against a local server: the image is fetched once under
    // the grant and never re-fetched afterwards, so anything still visible on
    // return could only have come from that cache.
    //
    // This belongs here rather than in render(): render() also runs for the
    // remote-content grant itself, where throwing the document away would
    // discard exactly what the user just asked to see.
    m_profile->clearHttpCache();
    m_view->setUrl(QUrl(QStringLiteral("about:blank")));

    // Two messages in one thread commonly share a Content-ID, and the thread is
    // one document, so the parts are namespaced per message.
    const ThreadCidMap cidMap = buildThreadCidMap(m_items);
    m_interceptor->setAllowedCids(cidMap.allowedCids);
    m_cidHandler->setParts(cidMap.parts);

    updateHeader();
    render();
}

void MessageView::showError(const QString &text, const QString &filePath)
{
    m_items.clear();

    // An error card references nothing, so the policy is emptied rather than
    // left holding the previous thread's parts.
    m_cidHandler->setParts({});
    m_interceptor->setAllowedCids({});
    m_interceptor->resetForNewMessage();

    m_headerLabel->setText(tr("<b>Cannot display message</b>"));
    m_blockedLabel->hide();
    m_loadRemoteButton->hide();

    const QString html = QStringLiteral(
        "<html><body><p>%1</p><p><code>%2</code></p></body></html>")
        .arg(text.toHtmlEscaped(), filePath.toHtmlEscaped());
    setDocument(html);
}

void MessageView::updateHeader()
{
    if (m_items.isEmpty()) {
        m_headerLabel->clear();
        return;
    }

    // The thread's subject comes from its first message; later replies carry
    // Re: prefixes that add nothing.
    const QString subject = m_items.first().message.subject;

    m_headerLabel->setText(
        QStringLiteral("<b>%1</b><br><small>%2</small>")
            .arg(subject.toHtmlEscaped(),
                 tr("%n message(s) in thread", "", m_items.size())));
}

void MessageView::render()
{
    const HtmlBuilder::Mode mode =
        m_preferHtml ? HtmlBuilder::PreferHtml : HtmlBuilder::ForcePlain;

    setDocument(HtmlBuilder::buildThread(m_items, mode));
    rebuildAttachmentBar();

    // Blocking is discovered during load, so check shortly afterwards.
    QTimer::singleShot(300, this, [this]() {
        const bool blocked = m_interceptor->blockedAnything()
                             && !m_interceptor->allowRemote();
        m_blockedLabel->setVisible(blocked);
        m_loadRemoteButton->setVisible(blocked);
    });
}

QList<Attachment> MessageView::allAttachments() const
{
    QList<Attachment> all;
    for (const ThreadRenderItem &item : m_items)
        all.append(item.message.attachments);
    return all;
}

void MessageView::rebuildAttachmentBar()
{
    auto *layout = qobject_cast<QHBoxLayout *>(m_attachmentBar->layout());

    // Rebuilt rather than updated: a thread can change under the same widget
    // (toggle_html re-renders, and the next thread reuses this bar), and a
    // stale button would offer a save from the message before it.
    while (QLayoutItem *item = layout->takeAt(0)) {
        delete item->widget();
        delete item;
    }

    const int total = allAttachments().size();
    if (total == 0) {
        m_attachmentBar->hide();
        return;
    }

    // One button whatever the count. A button per attachment made the bar as
    // wide as the window on a thread with fifteen of them, which pushed the
    // splitter over and left the thread list a few pixels wide.
    auto *button = new QPushButton(tr("Attachments (%1)...").arg(total),
                                   m_attachmentBar);
    button->setToolTip(tr("List the attachments in this thread"));
    connect(button, &QPushButton::clicked,
            this, &MessageView::showAttachmentDialog);

    layout->addWidget(button);
    layout->addStretch();
    m_attachmentBar->show();
}

void MessageView::showAttachmentDialog()
{
    QDialog dialog(this);
    dialog.setWindowTitle(tr("Attachments"));

    auto *layout = new QVBoxLayout(&dialog);
    auto *list = new QTreeWidget(&dialog);
    list->setColumnCount(4);
    // The fourth column holds the per-row Save button and needs no label.
    list->setHeaderLabels({ tr("Message"), tr("File"), tr("Size"), QString() });
    list->setRootIsDecorated(false);
    list->setSelectionMode(QAbstractItemView::NoSelection);

    // A thread renders as one document, so the message number is what says
    // which of them a file came from.
    for (int index = 0; index < m_items.size(); ++index) {
        const ParsedMessage &message = m_items.at(index).message;
        for (const Attachment &attachment : message.attachments) {
            auto *row = new QTreeWidgetItem(list);
            row->setText(0, QString::number(index + 1));
            // safeFilename(), never the raw filename: the name in a message is
            // attacker-controlled and may carry separators or "..".
            row->setText(1, attachment.safeFilename());
            row->setText(2, QLocale().formattedDataSize(attachment.data.size()));

            auto *save = new QPushButton(tr("Save..."), list);
            // Copied into the lambda: m_items is replaced wholesale by the
            // next showThread(), so a reference would dangle.
            connect(save, &QPushButton::clicked, this,
                    [this, attachment]() { saveAttachment(attachment); });
            list->setItemWidget(row, 3, save);
        }
    }
    for (int column = 0; column < 3; ++column)
        list->resizeColumnToContents(column);

    layout->addWidget(list);

    auto *buttons = new QDialogButtonBox(QDialogButtonBox::Close, &dialog);
    // Only worth offering for more than one file: with a single attachment it
    // is the same action as its own Save button, one dialog deeper.
    if (allAttachments().size() > 1) {
        auto *saveAll = buttons->addButton(tr("Save all..."),
                                           QDialogButtonBox::ActionRole);
        connect(saveAll, &QPushButton::clicked, this,
                [this, &dialog]() {
            saveAllAttachments();
            dialog.accept();
        });
    }
    connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject);
    layout->addWidget(buttons);

    dialog.resize(560, 320);
    dialog.exec();
}

void MessageView::saveAllAttachments()
{
    const QList<Attachment> attachments = allAttachments();
    if (attachments.isEmpty())
        return;

    // The subfolder is stated up front rather than discovered afterwards: the
    // user picks a parent, and what lands in it is one directory, not fifteen
    // loose files among whatever is already there.
    const QString subject = m_items.isEmpty() ? QString()
                                              : m_items.first().message.subject;
    const QString rfc822Date = m_items.isEmpty() ? QString()
                                                 : m_items.first().message.date;
    const QString folder = attachmentFolderName(rfc822Date, subject);

    const QString parent = QFileDialog::getExistingDirectory(
        this,
        tr("Choose a folder. A subfolder \"%1\" will be created inside it.")
            .arg(folder),
        QStandardPaths::writableLocation(QStandardPaths::DownloadLocation));
    if (parent.isEmpty())
        return;  // cancelled

    // Never overwrite an existing directory: a second save of the same thread
    // gets its own folder rather than merging into the first.
    QDir parentDir(parent);
    QString unique = folder;
    for (int suffix = 2; parentDir.exists(unique); ++suffix)
        unique = tr("%1 (%2)").arg(folder).arg(suffix);

    if (!parentDir.mkpath(unique)) {
        emit statusMessage(tr("Could not create %1").arg(unique));
        return;
    }
    const QString target = parentDir.absoluteFilePath(unique);

    int saved = 0;
    QStringList failures;
    for (const Attachment &attachment : attachments) {
        QString error;
        // Not saveTo(): several messages in a thread commonly attach the same
        // filename, and overwriting silently lost six of sixteen files while
        // still reporting every one as saved.
        if (attachment.saveWithoutOverwriting(target, &error).isEmpty())
            failures.append(attachment.safeFilename());
        else
            ++saved;
    }

    if (failures.isEmpty()) {
        emit statusMessage(tr("Saved %1 attachment(s) to %2")
                               .arg(saved).arg(target));
    } else {
        emit statusMessage(tr("Saved %1 of %2 to %3; failed: %4")
                               .arg(saved).arg(attachments.size())
                               .arg(target, failures.join(QStringLiteral(", "))));
    }
}

void MessageView::saveAttachment(const Attachment &attachment)
{
    const QString directory = QFileDialog::getExistingDirectory(
        this, tr("Save attachment to"),
        QStandardPaths::writableLocation(QStandardPaths::DownloadLocation));
    if (directory.isEmpty())
        return;  // cancelled

    QString error;
    const QString written = attachment.saveTo(directory, &error);
    if (written.isEmpty()) {
        emit statusMessage(tr("Could not save attachment: %1").arg(error));
        return;
    }

    // Reported, not silent: a save with no feedback is the same failure as
    // acting on a thread and seeing nothing change.
    emit statusMessage(tr("Saved %1").arg(written));
}

void MessageView::toggleHtml()
{
    const bool anyHtml = std::any_of(
        m_items.cbegin(), m_items.cend(),
        [](const ThreadRenderItem &item) { return item.message.hasHtml(); });

    if (!anyHtml) {
        emit statusMessage(tr("No message in this thread has an HTML part"));
        return;
    }
    m_preferHtml = !m_preferHtml;
    render();
}

bool MessageView::eventFilter(QObject *watched, QEvent *event)
{
    const QEvent::Type type = event->type();
    if (type != QEvent::Wheel && type != QEvent::MouseButtonPress)
        return QWidget::eventFilter(watched, event);

    // Application-wide filter: only events inside this pane are ours. Anything
    // else, including a Ctrl+wheel over the thread list, passes untouched.
    // isAncestorOf() is false for the widget itself, so test that separately.
    auto *widget = qobject_cast<QWidget *>(watched);
    if (!widget || (widget != m_view && !m_view->isAncestorOf(widget)))
        return QWidget::eventFilter(watched, event);

    if (type == QEvent::Wheel) {
        auto *wheel = static_cast<QWheelEvent *>(event);
        if (!(wheel->modifiers() & Qt::ControlModifier))
            return QWidget::eventFilter(watched, event);

        // angleDelta is in eighths of a degree; one detent is 120. A high
        // resolution wheel sends smaller steps, so scale rather than treating
        // every event as one full step.
        const int delta = wheel->angleDelta().y();
        if (delta != 0)
            setZoomFactor(zoomFactor() + 0.1 * delta / 120.0);

        // Consumed, or Chromium's own Ctrl+wheel zoom would run on top of
        // ours and the factor we track would no longer be what is on screen.
        return true;
    }

    // Ctrl+middle-click resets: the same hand that just zoomed with the wheel
    // puts it back, without reaching for the keyboard.
    auto *mouse = static_cast<QMouseEvent *>(event);
    if (mouse->button() != Qt::MiddleButton
        || !(mouse->modifiers() & Qt::ControlModifier)) {
        return QWidget::eventFilter(watched, event);
    }

    zoomReset();

    // Consumed: a plain middle click is paste-on-X11 in some contexts, and
    // this gesture must do one thing only.
    return true;
}

qreal MessageView::clampZoom(qreal factor)
{
    // qIsFinite rejects the NaN and infinity a corrupt or hand-edited state
    // file can produce; qFuzzyIsNull rejects the 0.0 that a missing or
    // non-numeric value converts to, which would render nothing at all.
    if (!qIsFinite(factor) || factor <= 0.0)
        return kDefaultZoom;
    return qBound(kMinZoom, factor, kMaxZoom);
}

qreal MessageView::zoomFactor() const
{
    // The web view is the single source of truth. It keeps the factor across
    // setHtml(), verified on Qt 6.11, so there is no second copy to drift.
    return m_view->zoomFactor();
}

void MessageView::setZoomFactor(qreal factor)
{
    m_view->setZoomFactor(clampZoom(factor));
}

void MessageView::zoomIn()
{
    setZoomFactor(zoomFactor() + 0.1);
}

void MessageView::zoomOut()
{
    setZoomFactor(zoomFactor() - 0.1);
}

void MessageView::zoomReset()
{
    setZoomFactor(kDefaultZoom);
}

void MessageView::loadRemoteContent()
{
    // Applies to this thread only and is cleared by the next showThread().
    m_interceptor->setAllowRemote(true);
    m_blockedLabel->hide();
    m_loadRemoteButton->hide();
    render();
}
