# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License.  You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied.  See the License for the
# specific language governing permissions and limitations
# under the License.
# isort:skip_file
from datetime import datetime
import math
from typing import Any, List, Optional

from pandas import DataFrame, Series, Timestamp
import pytest

from superset.exceptions import QueryObjectValidationError
from superset.utils import pandas_postprocessing as proc
from superset.utils.core import (
    DTTM_ALIAS,
    PostProcessingContributionOrientation,
    PostProcessingBoxplotWhiskerType,
)

from .base_tests import SupersetTestCase
from .fixtures.dataframes import (
    categories_df,
    lonlat_df,
    names_df,
    timeseries_df,
    prophet_df,
)

AGGREGATES_SINGLE = {"idx_nulls": {"operator": "sum"}}
AGGREGATES_MULTIPLE = {
    "idx_nulls": {"operator": "sum"},
    "asc_idx": {"operator": "mean"},
}


def series_to_list(series: Series) -> List[Any]:
    """
    Converts a `Series` to a regular list, and replaces non-numeric values to
    Nones.

    :param series: Series to convert
    :return: list without nan or inf
    """
    return [
        None
        if not isinstance(val, str) and (math.isnan(val) or math.isinf(val))
        else val
        for val in series.tolist()
    ]


def round_floats(
    floats: List[Optional[float]], precision: int
) -> List[Optional[float]]:
    """
    Round list of floats to certain precision

    :param floats: floats to round
    :param precision: intended decimal precision
    :return: rounded floats
    """
    return [round(val, precision) if val else None for val in floats]


class TestPostProcessing(SupersetTestCase):
    def test_flatten_column_after_pivot(self):
        """
        Test pivot column flattening function
        """
        # single aggregate cases
        self.assertEqual(
            proc._flatten_column_after_pivot(
                aggregates=AGGREGATES_SINGLE, column="idx_nulls",
            ),
            "idx_nulls",
        )
        self.assertEqual(
            proc._flatten_column_after_pivot(
                aggregates=AGGREGATES_SINGLE, column=1234,
            ),
            "1234",
        )
        self.assertEqual(
            proc._flatten_column_after_pivot(
                aggregates=AGGREGATES_SINGLE, column=Timestamp("2020-09-29T00:00:00"),
            ),
            "2020-09-29 00:00:00",
        )
        self.assertEqual(
            proc._flatten_column_after_pivot(
                aggregates=AGGREGATES_SINGLE, column="idx_nulls",
            ),
            "idx_nulls",
        )
        self.assertEqual(
            proc._flatten_column_after_pivot(
                aggregates=AGGREGATES_SINGLE, column=("idx_nulls", "col1"),
            ),
            "col1",
        )
        self.assertEqual(
            proc._flatten_column_after_pivot(
                aggregates=AGGREGATES_SINGLE, column=("idx_nulls", "col1", 1234),
            ),
            "col1, 1234",
        )

        # Multiple aggregate cases
        self.assertEqual(
            proc._flatten_column_after_pivot(
                aggregates=AGGREGATES_MULTIPLE, column=("idx_nulls", "asc_idx", "col1"),
            ),
            "idx_nulls, asc_idx, col1",
        )
        self.assertEqual(
            proc._flatten_column_after_pivot(
                aggregates=AGGREGATES_MULTIPLE,
                column=("idx_nulls", "asc_idx", "col1", 1234),
            ),
            "idx_nulls, asc_idx, col1, 1234",
        )

    def test_pivot_without_columns(self):
        """
        Make sure pivot without columns returns correct DataFrame
        """
        df = proc.pivot(df=categories_df, index=["name"], aggregates=AGGREGATES_SINGLE,)
        self.assertListEqual(
            df.columns.tolist(), ["name", "idx_nulls"],
        )
        self.assertEqual(len(df), 101)
        self.assertEqual(df.sum()[1], 1050)

    def test_pivot_with_single_column(self):
        """
        Make sure pivot with single column returns correct DataFrame
        """
        df = proc.pivot(
            df=categories_df,
            index=["name"],
            columns=["category"],
            aggregates=AGGREGATES_SINGLE,
        )
        self.assertListEqual(
            df.columns.tolist(), ["name", "cat0", "cat1", "cat2"],
        )
        self.assertEqual(len(df), 101)
        self.assertEqual(df.sum()[1], 315)

        df = proc.pivot(
            df=categories_df,
            index=["dept"],
            columns=["category"],
            aggregates=AGGREGATES_SINGLE,
        )
        self.assertListEqual(
            df.columns.tolist(), ["dept", "cat0", "cat1", "cat2"],
        )
        self.assertEqual(len(df), 5)

    def test_pivot_with_multiple_columns(self):
        """
        Make sure pivot with multiple columns returns correct DataFrame
        """
        df = proc.pivot(
            df=categories_df,
            index=["name"],
            columns=["category", "dept"],
            aggregates=AGGREGATES_SINGLE,
        )
        self.assertEqual(len(df.columns), 1 + 3 * 5)  # index + possible permutations

    def test_pivot_fill_values(self):
        """
        Make sure pivot with fill values returns correct DataFrame
        """
        df = proc.pivot(
            df=categories_df,
            index=["name"],
            columns=["category"],
            metric_fill_value=1,
            aggregates={"idx_nulls": {"operator": "sum"}},
        )
        self.assertEqual(df.sum()[1], 382)

    def test_pivot_exceptions(self):
        """
        Make sure pivot raises correct Exceptions
        """
        # Missing index
        self.assertRaises(
            TypeError,
            proc.pivot,
            df=categories_df,
            columns=["dept"],
            aggregates=AGGREGATES_SINGLE,
        )

        # invalid index reference
        self.assertRaises(
            QueryObjectValidationError,
            proc.pivot,
            df=categories_df,
            index=["abc"],
            columns=["dept"],
            aggregates=AGGREGATES_SINGLE,
        )

        # invalid column reference
        self.assertRaises(
            QueryObjectValidationError,
            proc.pivot,
            df=categories_df,
            index=["dept"],
            columns=["abc"],
            aggregates=AGGREGATES_SINGLE,
        )

        # invalid aggregate options
        self.assertRaises(
            QueryObjectValidationError,
            proc.pivot,
            df=categories_df,
            index=["name"],
            columns=["category"],
            aggregates={"idx_nulls": {}},
        )

    def test_aggregate(self):
        aggregates = {
            "asc sum": {"column": "asc_idx", "operator": "sum"},
            "asc q2": {
                "column": "asc_idx",
                "operator": "percentile",
                "options": {"q": 75},
            },
            "desc q1": {
                "column": "desc_idx",
                "operator": "percentile",
                "options": {"q": 25},
            },
        }
        df = proc.aggregate(
            df=categories_df, groupby=["constant"], aggregates=aggregates
        )
        self.assertListEqual(
            df.columns.tolist(), ["constant", "asc sum", "asc q2", "desc q1"]
        )
        self.assertEqual(series_to_list(df["asc sum"])[0], 5050)
        self.assertEqual(series_to_list(df["asc q2"])[0], 75)
        self.assertEqual(series_to_list(df["desc q1"])[0], 25)

    def test_sort(self):
        df = proc.sort(df=categories_df, columns={"category": True, "asc_idx": False})
        self.assertEqual(96, series_to_list(df["asc_idx"])[1])

        self.assertRaises(
            QueryObjectValidationError, proc.sort, df=df, columns={"abc": True}
        )

    def test_rolling(self):
        # sum rolling type
        post_df = proc.rolling(
            df=timeseries_df,
            columns={"y": "y"},
            rolling_type="sum",
            window=2,
            min_periods=0,
        )

        self.assertListEqual(post_df.columns.tolist(), ["label", "y"])
        self.assertListEqual(series_to_list(post_df["y"]), [1.0, 3.0, 5.0, 7.0])

        # mean rolling type with alias
        post_df = proc.rolling(
            df=timeseries_df,
            rolling_type="mean",
            columns={"y": "y_mean"},
            window=10,
            min_periods=0,
        )
        self.assertListEqual(post_df.columns.tolist(), ["label", "y", "y_mean"])
        self.assertListEqual(series_to_list(post_df["y_mean"]), [1.0, 1.5, 2.0, 2.5])

        # count rolling type
        post_df = proc.rolling(
            df=timeseries_df,
            rolling_type="count",
            columns={"y": "y"},
            window=10,
            min_periods=0,
        )
        self.assertListEqual(post_df.columns.tolist(), ["label", "y"])
        self.assertListEqual(series_to_list(post_df["y"]), [1.0, 2.0, 3.0, 4.0])

        # quantile rolling type
        post_df = proc.rolling(
            df=timeseries_df,
            columns={"y": "q1"},
            rolling_type="quantile",
            rolling_type_options={"quantile": 0.25},
            window=10,
            min_periods=0,
        )
        self.assertListEqual(post_df.columns.tolist(), ["label", "y", "q1"])
        self.assertListEqual(series_to_list(post_df["q1"]), [1.0, 1.25, 1.5, 1.75])

        # incorrect rolling type
        self.assertRaises(
            QueryObjectValidationError,
            proc.rolling,
            df=timeseries_df,
            columns={"y": "y"},
            rolling_type="abc",
            window=2,
        )

        # incorrect rolling type options
        self.assertRaises(
            QueryObjectValidationError,
            proc.rolling,
            df=timeseries_df,
            columns={"y": "y"},
            rolling_type="quantile",
            rolling_type_options={"abc": 123},
            window=2,
        )

    def test_select(self):
        # reorder columns
        post_df = proc.select(df=timeseries_df, columns=["y", "label"])
        self.assertListEqual(post_df.columns.tolist(), ["y", "label"])

        # one column
        post_df = proc.select(df=timeseries_df, columns=["label"])
        self.assertListEqual(post_df.columns.tolist(), ["label"])

        # rename and select one column
        post_df = proc.select(df=timeseries_df, columns=["y"], rename={"y": "y1"})
        self.assertListEqual(post_df.columns.tolist(), ["y1"])

        # rename one and leave one unchanged
        post_df = proc.select(df=timeseries_df, rename={"y": "y1"})
        self.assertListEqual(post_df.columns.tolist(), ["label", "y1"])

        # drop one column
        post_df = proc.select(df=timeseries_df, exclude=["label"])
        self.assertListEqual(post_df.columns.tolist(), ["y"])

        # rename and drop one column
        post_df = proc.select(df=timeseries_df, rename={"y": "y1"}, exclude=["label"])
        self.assertListEqual(post_df.columns.tolist(), ["y1"])

        # invalid columns
        self.assertRaises(
            QueryObjectValidationError,
            proc.select,
            df=timeseries_df,
            columns=["abc"],
            rename={"abc": "qwerty"},
        )

        # select renamed column by new name
        self.assertRaises(
            QueryObjectValidationError,
            proc.select,
            df=timeseries_df,
            columns=["label_new"],
            rename={"label": "label_new"},
        )

    def test_diff(self):
        # overwrite column
        post_df = proc.diff(df=timeseries_df, columns={"y": "y"})
        self.assertListEqual(post_df.columns.tolist(), ["label", "y"])
        self.assertListEqual(series_to_list(post_df["y"]), [None, 1.0, 1.0, 1.0])

        # add column
        post_df = proc.diff(df=timeseries_df, columns={"y": "y1"})
        self.assertListEqual(post_df.columns.tolist(), ["label", "y", "y1"])
        self.assertListEqual(series_to_list(post_df["y"]), [1.0, 2.0, 3.0, 4.0])
        self.assertListEqual(series_to_list(post_df["y1"]), [None, 1.0, 1.0, 1.0])

        # look ahead
        post_df = proc.diff(df=timeseries_df, columns={"y": "y1"}, periods=-1)
        self.assertListEqual(series_to_list(post_df["y1"]), [-1.0, -1.0, -1.0, None])

        # invalid column reference
        self.assertRaises(
            QueryObjectValidationError,
            proc.diff,
            df=timeseries_df,
            columns={"abc": "abc"},
        )

    def test_cum(self):
        # create new column (cumsum)
        post_df = proc.cum(df=timeseries_df, columns={"y": "y2"}, operator="sum",)
        self.assertListEqual(post_df.columns.tolist(), ["label", "y", "y2"])
        self.assertListEqual(series_to_list(post_df["label"]), ["x", "y", "z", "q"])
        self.assertListEqual(series_to_list(post_df["y"]), [1.0, 2.0, 3.0, 4.0])
        self.assertListEqual(series_to_list(post_df["y2"]), [1.0, 3.0, 6.0, 10.0])

        # overwrite column (cumprod)
        post_df = proc.cum(df=timeseries_df, columns={"y": "y"}, operator="prod",)
        self.assertListEqual(post_df.columns.tolist(), ["label", "y"])
        self.assertListEqual(series_to_list(post_df["y"]), [1.0, 2.0, 6.0, 24.0])

        # overwrite column (cummin)
        post_df = proc.cum(df=timeseries_df, columns={"y": "y"}, operator="min",)
        self.assertListEqual(post_df.columns.tolist(), ["label", "y"])
        self.assertListEqual(series_to_list(post_df["y"]), [1.0, 1.0, 1.0, 1.0])

        # invalid operator
        self.assertRaises(
            QueryObjectValidationError,
            proc.cum,
            df=timeseries_df,
            columns={"y": "y"},
            operator="abc",
        )

    def test_geohash_decode(self):
        # decode lon/lat from geohash
        post_df = proc.geohash_decode(
            df=lonlat_df[["city", "geohash"]],
            geohash="geohash",
            latitude="latitude",
            longitude="longitude",
        )
        self.assertListEqual(
            sorted(post_df.columns.tolist()),
            sorted(["city", "geohash", "latitude", "longitude"]),
        )
        self.assertListEqual(
            round_floats(series_to_list(post_df["longitude"]), 6),
            round_floats(series_to_list(lonlat_df["longitude"]), 6),
        )
        self.assertListEqual(
            round_floats(series_to_list(post_df["latitude"]), 6),
            round_floats(series_to_list(lonlat_df["latitude"]), 6),
        )

    def test_geohash_encode(self):
        # encode lon/lat into geohash
        post_df = proc.geohash_encode(
            df=lonlat_df[["city", "latitude", "longitude"]],
            latitude="latitude",
            longitude="longitude",
            geohash="geohash",
        )
        self.assertListEqual(
            sorted(post_df.columns.tolist()),
            sorted(["city", "geohash", "latitude", "longitude"]),
        )
        self.assertListEqual(
            series_to_list(post_df["geohash"]), series_to_list(lonlat_df["geohash"]),
        )

    def test_geodetic_parse(self):
        # parse geodetic string with altitude into lon/lat/altitude
        post_df = proc.geodetic_parse(
            df=lonlat_df[["city", "geodetic"]],
            geodetic="geodetic",
            latitude="latitude",
            longitude="longitude",
            altitude="altitude",
        )
        self.assertListEqual(
            sorted(post_df.columns.tolist()),
            sorted(["city", "geodetic", "latitude", "longitude", "altitude"]),
        )
        self.assertListEqual(
            series_to_list(post_df["longitude"]),
            series_to_list(lonlat_df["longitude"]),
        )
        self.assertListEqual(
            series_to_list(post_df["latitude"]), series_to_list(lonlat_df["latitude"]),
        )
        self.assertListEqual(
            series_to_list(post_df["altitude"]), series_to_list(lonlat_df["altitude"]),
        )

        # parse geodetic string into lon/lat
        post_df = proc.geodetic_parse(
            df=lonlat_df[["city", "geodetic"]],
            geodetic="geodetic",
            latitude="latitude",
            longitude="longitude",
        )
        self.assertListEqual(
            sorted(post_df.columns.tolist()),
            sorted(["city", "geodetic", "latitude", "longitude"]),
        )
        self.assertListEqual(
            series_to_list(post_df["longitude"]),
            series_to_list(lonlat_df["longitude"]),
        )
        self.assertListEqual(
            series_to_list(post_df["latitude"]), series_to_list(lonlat_df["latitude"]),
        )

    def test_contribution(self):
        df = DataFrame(
            {
                DTTM_ALIAS: [
                    datetime(2020, 7, 16, 14, 49),
                    datetime(2020, 7, 16, 14, 50),
                ],
                "a": [1, 3],
                "b": [1, 9],
            }
        )
        with pytest.raises(QueryObjectValidationError, match="not numeric"):
            proc.contribution(df, columns=[DTTM_ALIAS])

        with pytest.raises(QueryObjectValidationError, match="same length"):
            proc.contribution(df, columns=["a"], rename_columns=["aa", "bb"])

        # cell contribution across row
        processed_df = proc.contribution(
            df, orientation=PostProcessingContributionOrientation.ROW,
        )
        self.assertListEqual(processed_df.columns.tolist(), [DTTM_ALIAS, "a", "b"])
        self.assertListEqual(processed_df["a"].tolist(), [0.5, 0.25])
        self.assertListEqual(processed_df["b"].tolist(), [0.5, 0.75])

        # cell contribution across column without temporal column
        df.pop(DTTM_ALIAS)
        processed_df = proc.contribution(
            df, orientation=PostProcessingContributionOrientation.COLUMN
        )
        self.assertListEqual(processed_df.columns.tolist(), ["a", "b"])
        self.assertListEqual(processed_df["a"].tolist(), [0.25, 0.75])
        self.assertListEqual(processed_df["b"].tolist(), [0.1, 0.9])

        # contribution only on selected columns
        processed_df = proc.contribution(
            df,
            orientation=PostProcessingContributionOrientation.COLUMN,
            columns=["a"],
            rename_columns=["pct_a"],
        )
        self.assertListEqual(processed_df.columns.tolist(), ["a", "b", "pct_a"])
        self.assertListEqual(processed_df["a"].tolist(), [1, 3])
        self.assertListEqual(processed_df["b"].tolist(), [1, 9])
        self.assertListEqual(processed_df["pct_a"].tolist(), [0.25, 0.75])

    def test_prophet_valid(self):
        pytest.importorskip("fbprophet")

        df = proc.prophet(
            df=prophet_df, time_grain="P1M", periods=3, confidence_interval=0.9
        )
        columns = {column for column in df.columns}
        assert columns == {
            DTTM_ALIAS,
            "a__yhat",
            "a__yhat_upper",
            "a__yhat_lower",
            "a",
            "b__yhat",
            "b__yhat_upper",
            "b__yhat_lower",
            "b",
        }
        assert df[DTTM_ALIAS].iloc[0].to_pydatetime() == datetime(2018, 12, 31)
        assert df[DTTM_ALIAS].iloc[-1].to_pydatetime() == datetime(2022, 3, 31)
        assert len(df) == 7

        df = proc.prophet(
            df=prophet_df, time_grain="P1M", periods=5, confidence_interval=0.9
        )
        assert df[DTTM_ALIAS].iloc[0].to_pydatetime() == datetime(2018, 12, 31)
        assert df[DTTM_ALIAS].iloc[-1].to_pydatetime() == datetime(2022, 5, 31)
        assert len(df) == 9

    def test_prophet_missing_temporal_column(self):
        df = prophet_df.drop(DTTM_ALIAS, axis=1)

        self.assertRaises(
            QueryObjectValidationError,
            proc.prophet,
            df=df,
            time_grain="P1M",
            periods=3,
            confidence_interval=0.9,
        )

    def test_prophet_incorrect_confidence_interval(self):
        self.assertRaises(
            QueryObjectValidationError,
            proc.prophet,
            df=prophet_df,
            time_grain="P1M",
            periods=3,
            confidence_interval=0.0,
        )

        self.assertRaises(
            QueryObjectValidationError,
            proc.prophet,
            df=prophet_df,
            time_grain="P1M",
            periods=3,
            confidence_interval=1.0,
        )

    def test_prophet_incorrect_periods(self):
        self.assertRaises(
            QueryObjectValidationError,
            proc.prophet,
            df=prophet_df,
            time_grain="P1M",
            periods=0,
            confidence_interval=0.8,
        )

    def test_prophet_incorrect_time_grain(self):
        self.assertRaises(
            QueryObjectValidationError,
            proc.prophet,
            df=prophet_df,
            time_grain="yearly",
            periods=10,
            confidence_interval=0.8,
        )

    def test_boxplot_tukey(self):
        df = proc.boxplot(
            df=names_df,
            groupby=["region"],
            whisker_type=PostProcessingBoxplotWhiskerType.TUKEY,
            metrics=["cars"],
        )
        columns = {column for column in df.columns}
        assert columns == {
            "cars__mean",
            "cars__median",
            "cars__q1",
            "cars__q3",
            "cars__max",
            "cars__min",
            "cars__count",
            "cars__outliers",
            "region",
        }
        assert len(df) == 4

    def test_boxplot_min_max(self):
        df = proc.boxplot(
            df=names_df,
            groupby=["region"],
            whisker_type=PostProcessingBoxplotWhiskerType.MINMAX,
            metrics=["cars"],
        )
        columns = {column for column in df.columns}
        assert columns == {
            "cars__mean",
            "cars__median",
            "cars__q1",
            "cars__q3",
            "cars__max",
            "cars__min",
            "cars__count",
            "cars__outliers",
            "region",
        }
        assert len(df) == 4

    def test_boxplot_percentile(self):
        df = proc.boxplot(
            df=names_df,
            groupby=["region"],
            whisker_type=PostProcessingBoxplotWhiskerType.PERCENTILE,
            metrics=["cars"],
            percentiles=[1, 99],
        )
        columns = {column for column in df.columns}
        assert columns == {
            "cars__mean",
            "cars__median",
            "cars__q1",
            "cars__q3",
            "cars__max",
            "cars__min",
            "cars__count",
            "cars__outliers",
            "region",
        }
        assert len(df) == 4

    def test_boxplot_percentile_incorrect_params(self):
        with pytest.raises(QueryObjectValidationError):
            proc.boxplot(
                df=names_df,
                groupby=["region"],
                whisker_type=PostProcessingBoxplotWhiskerType.PERCENTILE,
                metrics=["cars"],
            )

        with pytest.raises(QueryObjectValidationError):
            proc.boxplot(
                df=names_df,
                groupby=["region"],
                whisker_type=PostProcessingBoxplotWhiskerType.PERCENTILE,
                metrics=["cars"],
                percentiles=[10],
            )

        with pytest.raises(QueryObjectValidationError):
            proc.boxplot(
                df=names_df,
                groupby=["region"],
                whisker_type=PostProcessingBoxplotWhiskerType.PERCENTILE,
                metrics=["cars"],
                percentiles=[90, 10],
            )

        with pytest.raises(QueryObjectValidationError):
            proc.boxplot(
                df=names_df,
                groupby=["region"],
                whisker_type=PostProcessingBoxplotWhiskerType.PERCENTILE,
                metrics=["cars"],
                percentiles=[10, 90, 10],
            )
