/*
 * Copyright (c) 2026, Callum Law <callumlaw1709@outlook.com>.
 *
 * SPDX-License-Identifier: BSD-2-Clause
 */

#include "ScrollTimeline.h"
#include <LibWeb/Animations/Animation.h>
#include <LibWeb/DOM/Document.h>
#include <LibWeb/HTML/Window.h>
#include <LibWeb/Layout/Viewport.h>
#include <LibWeb/Painting/PaintableBox.h>

namespace Web::Animations {

GC_DEFINE_ALLOCATOR(ScrollTimeline);

GC::Ref<ScrollTimeline> ScrollTimeline::create(JS::Realm& realm, DOM::Document& document, Source source, Bindings::ScrollAxis axis)
{
    auto timeline = realm.create<ScrollTimeline>(realm, document, source, axis);

    // NB: The passed timestamp is ignored for ScrollTimelines so we can just pass 0 here.
    timeline->update_current_time(0);

    return timeline;
}

// https://drafts.csswg.org/scroll-animations-1/#dom-scrolltimeline-scrolltimeline
GC::Ref<ScrollTimeline> ScrollTimeline::construct_impl(JS::Realm& realm, Bindings::ScrollTimelineOptions options)
{
    auto& document = as<HTML::Window>(realm.global_object()).associated_document();

    // 1. Let timeline be the new ScrollTimeline object.
    // 2. Set the source of timeline to:
    auto source = [&]() -> GC::Ptr<DOM::Element const> {
        // If the source member of options is present,
        // The source member of options.
        if (options.source.has_value())
            return options.source.value();

        // Otherwise,
        // The scrollingElement of the Document associated with the Window that is the current global object.
        if (document.scrolling_element())
            return document.scrolling_element();

        return nullptr;
    }();

    // 3. Set the axis property of timeline to the corresponding value from options.
    return create(realm, document, source, options.axis);
}

GC::Ptr<DOM::Element const> ScrollTimeline::source() const
{
    return m_source.visit(
        [](GC::Ptr<DOM::Element const> const& source) -> GC::Ptr<DOM::Element const> {
            return source;
        },
        [](AnonymousSource const& anonymous_source) -> GC::Ptr<DOM::Element const> {
            switch (anonymous_source.scroller) {
            case CSS::Scroller::Root:
                return anonymous_source.target.document().document_element();
            case CSS::Scroller::Nearest: {
                GC::Ptr<DOM::Element const> ancestor = anonymous_source.target.parent_element();

                while (ancestor && !ancestor->is_scroll_container())
                    ancestor = ancestor->parent_element();

                return ancestor;
            }
            case CSS::Scroller::Self:
                return anonymous_source.target.element();
            }
            VERIFY_NOT_REACHED();
        });
}

struct ComputedScrollAxis {
    bool is_vertical;
    bool is_reversed;
};
static ComputedScrollAxis computed_scroll_axis(Bindings::ScrollAxis axis, CSS::WritingMode writing_mode, CSS::Direction direction)
{
    // NB: This is based on the table specified here: https://drafts.csswg.org/css-writing-modes-4/#logical-to-physical

    // FIXME: Note: The used direction depends on the computed writing-mode and text-orientation: in vertical writing
    //              modes, a text-orientation value of upright forces the used direction to ltr.
    auto used_direction = direction;

    switch (axis) {
    case Bindings::ScrollAxis::Block:
        switch (writing_mode) {
        case CSS::WritingMode::HorizontalTb:
            return { true, false };
        case CSS::WritingMode::VerticalRl:
        case CSS::WritingMode::SidewaysRl:
            return { false, true };
        case CSS::WritingMode::VerticalLr:
        case CSS::WritingMode::SidewaysLr:
            return { false, false };
        }
        VERIFY_NOT_REACHED();
    case Bindings::ScrollAxis::Inline:
        switch (writing_mode) {
        case CSS::WritingMode::HorizontalTb:
            return { false, used_direction == CSS::Direction::Rtl };
        case CSS::WritingMode::VerticalRl:
        case CSS::WritingMode::SidewaysRl:
        case CSS::WritingMode::VerticalLr:
            return { true, used_direction == CSS::Direction::Rtl };
        case CSS::WritingMode::SidewaysLr:
            return { true, used_direction == CSS::Direction::Ltr };
        }
        VERIFY_NOT_REACHED();
    case Bindings::ScrollAxis::X:
        return { false, false };
    case Bindings::ScrollAxis::Y:
        return { true, false };
    }

    VERIFY_NOT_REACHED();
}

struct ScrollOffsetData {
    double scroll_offset;
    double max_scroll_offset;
};
static Optional<ScrollOffsetData> compute_scroll_offset_data(Variant<GC::Ptr<DOM::Element const>, GC::Ptr<DOM::Document>> propagated_source, Bindings::ScrollAxis axis)
{
    if (propagated_source.visit([](auto const& source) { return source == nullptr; }))
        return {};

    auto const& layout_node = propagated_source.visit([](auto const& source) -> Layout::NodeWithStyle const* { return source->unsafe_layout_node(); });

    if (!layout_node || !layout_node->is_scroll_container())
        return {};

    auto const& paintable_box = propagated_source.visit([](auto const& source) -> RefPtr<Painting::PaintableBox const> { return source->unsafe_paintable_box(); });

    if (!paintable_box || !paintable_box->has_scrollable_overflow())
        return {};

    auto const& scrollable_overflow_rect = paintable_box->scrollable_overflow_rect().value();
    auto const& computed_axis = computed_scroll_axis(axis, paintable_box->computed_values().writing_mode(), paintable_box->computed_values().direction());

    // FIXME: Scroll offset is currently incorrect as it is always relative to the top left of the scrollable overflow
    //        rect when it should instead be relative to the scroll origin.

    // FIXME: Support the case where the computed scroll axis is reversed

    return ScrollOffsetData {
        .scroll_offset = computed_axis.is_vertical
            ? paintable_box->scroll_offset().y().to_double()
            : paintable_box->scroll_offset().x().to_double(),
        .max_scroll_offset = computed_axis.is_vertical
            ? scrollable_overflow_rect.height().to_double() - paintable_box->content_height().to_double()
            : scrollable_overflow_rect.width().to_double() - paintable_box->content_width().to_double(),
    };
}

bool ScrollTimeline::is_stale() const
{
    // FIXME: This should probably be a spec bug

    // https://drafts.csswg.org/scroll-animations-1/#stale-timelines
    // AD-HOC: The spec only lists two criteria for a scroll timeline to be considered stale: a) it was newly created
    //         within the current frame or; b) style update and layout within the current frame changed it's associated
    //         named timeline ranges. However, WPT expects us to also consider the source element resizing as a cause
    //         for staleness, so we use a more broad definition of staleness and consider any timeline whose max scroll
    //         offset has changed since the last call to `update_current_time` to be stale. This matches the (admittedly
    //         narrow) tests in WPT and is the same behavior as is implemented by WebKit.

    // FIXME: Account for the named timeline ranges of view progress timelines once they are implemented.
    auto scroll_offset_data = compute_scroll_offset_data(get_propagated_source(), m_axis);

    return scroll_offset_data.map([](auto const& data) { return data.max_scroll_offset; }) != m_last_max_scroll_offset;
}

void ScrollTimeline::update_current_time(double)
{
    // https://drafts.csswg.org/scroll-animations-1/#ref-for-dom-animationtimeline-currenttime
    // currentTime represents the scroll progress of the scroll container as a percentage CSSUnitValue, with 0%
    // representing its startmost scroll position (in the writing mode of the scroll container). Null when the timeline
    // is inactive.

    auto scroll_offset_data = compute_scroll_offset_data(get_propagated_source(), m_axis);

    m_last_max_scroll_offset = scroll_offset_data.map([](auto const& data) { return data.max_scroll_offset; });

    // If the source of a ScrollTimeline is an element whose principal box does not exist or is not a scroll container,
    // or if there is no scrollable overflow, then the ScrollTimeline is inactive.
    // NB: Called during animation timeline update, which runs before layout is up to date.

    if (!scroll_offset_data.has_value()) {
        set_current_time({});
        return;
    }

    // https://drafts.csswg.org/scroll-animations-1/#scroll-timeline-progress
    // If the 0% position and 100% position coincide (i.e. the denominator in the current time formula is zero), the timeline is inactive.
    if (scroll_offset_data->max_scroll_offset == 0) {
        set_current_time({});
        return;
    }

    // FIXME: In paged media, scroll progress timelines that would otherwise reference the document viewport are also inactive.

    // https://drafts.csswg.org/scroll-animations-1/#scroll-timeline-progress
    // Progress (the current time) for a scroll progress timeline is calculated as:
    //     scroll offset ÷ (scrollable overflow size − scroll container size)
    auto progress = scroll_offset_data->scroll_offset / scroll_offset_data->max_scroll_offset;

    set_current_time(TimeValue { TimeValue::Type::Percentage, progress * 100 });
}

ScrollTimeline::ScrollTimeline(JS::Realm& realm, DOM::Document& document, Source source, Bindings::ScrollAxis axis)
    : AnimationTimeline(realm, document)
    , m_source(source)
    , m_axis(axis)
{
}

void ScrollTimeline::visit_edges(Cell::Visitor& visitor)
{
    Base::visit_edges(visitor);
    m_source.visit(
        [&](GC::Ptr<DOM::Element const>& source) { visitor.visit(source); },
        [&](AnonymousSource& anonymous_source) { anonymous_source.target.visit(visitor); });
}

void ScrollTimeline::initialize(JS::Realm& realm)
{
    WEB_SET_PROTOTYPE_FOR_INTERFACE(ScrollTimeline);
    Base::initialize(realm);
}

Variant<GC::Ptr<DOM::Element const>, GC::Ptr<DOM::Document>> ScrollTimeline::get_propagated_source() const
{
    auto const& source = this->source();

    // https://drafts.csswg.org/scroll-animations-1/#scroll-notation
    // References to the root element propagate to the document viewport (which functions as its scroll container).
    if (source && source == source->document().document_element())
        return source->owner_document();

    return source;
}

Bindings::ScrollAxis css_axis_to_bindings_scroll_axis(CSS::Axis axis)
{
    switch (axis) {
    case CSS::Axis::Block:
        return Bindings::ScrollAxis::Block;
    case CSS::Axis::Inline:
        return Bindings::ScrollAxis::Inline;
    case CSS::Axis::X:
        return Bindings::ScrollAxis::X;
    case CSS::Axis::Y:
        return Bindings::ScrollAxis::Y;
    }

    VERIFY_NOT_REACHED();
}

}
