#
# 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.

import unittest

import pandas as pd

import apache_beam as beam
from apache_beam.dataframe import convert
from apache_beam.testing.util import assert_that
from apache_beam.testing.util import equal_to


def equal_to_unordered_series(expected):
  def check(actual):
    actual = pd.concat(actual)
    if sorted(expected) != sorted(actual):
      raise AssertionError('Series not equal: \n%s\n%s\n' % (expected, actual))

  return check


class ConvertTest(unittest.TestCase):
  def test_convert_yield_pandas(self):
    with beam.Pipeline() as p:
      a = pd.Series([1, 2, 3])
      b = pd.Series([100, 200, 300])

      pc_a = p | 'A' >> beam.Create([a])
      pc_b = p | 'B' >> beam.Create([b])

      df_a = convert.to_dataframe(pc_a, proxy=a[:0])
      df_b = convert.to_dataframe(pc_b, proxy=b[:0])

      df_2a = 2 * df_a
      df_3a = 3 * df_a
      df_ab = df_a * df_b

      # Converting multiple results at a time can be more efficient.
      pc_2a, pc_ab = convert.to_pcollection(df_2a, df_ab,
                                            yield_elements='pandas')
      # But separate conversions can be done as well.
      pc_3a = convert.to_pcollection(df_3a, yield_elements='pandas')

      assert_that(pc_2a, equal_to_unordered_series(2 * a), label='Check2a')
      assert_that(pc_3a, equal_to_unordered_series(3 * a), label='Check3a')
      assert_that(pc_ab, equal_to_unordered_series(a * b), label='Checkab')

  def test_convert(self):
    with beam.Pipeline() as p:
      a = pd.Series([1, 2, 3])
      b = pd.Series([100, 200, 300])

      pc_a = p | 'A' >> beam.Create(a)
      pc_b = p | 'B' >> beam.Create(b)

      df_a = convert.to_dataframe(pc_a)
      df_b = convert.to_dataframe(pc_b)

      df_2a = 2 * df_a
      df_3a = 3 * df_a
      df_ab = df_a * df_b

      # Converting multiple results at a time can be more efficient.
      pc_2a, pc_ab = convert.to_pcollection(df_2a, df_ab)
      # But separate conversions can be done as well.
      pc_3a = convert.to_pcollection(df_3a)

      assert_that(pc_2a, equal_to(list(2 * a)), label='Check2a')
      assert_that(pc_3a, equal_to(list(3 * a)), label='Check3a')
      assert_that(pc_ab, equal_to(list(a * b)), label='Checkab')

  def test_convert_with_none(self):
    # Ensure the logical Any type allows (nullable) None, see BEAM-12587.
    df = pd.DataFrame({'A': ['str', 10, None], 'B': [None, 'str', 20]})
    with beam.Pipeline() as p:
      res = convert.to_pcollection(df, pipeline=p) | beam.Map(tuple)
      assert_that(res, equal_to([(row.A, row.B) for _, row in df.iterrows()]))

  def test_convert_scalar(self):
    with beam.Pipeline() as p:
      pc = p | 'A' >> beam.Create([1, 2, 3])
      s = convert.to_dataframe(pc)
      pc_sum = convert.to_pcollection(s.sum())
      assert_that(pc_sum, equal_to([6]))

  def test_convert_non_deferred(self):
    with beam.Pipeline() as p:
      s1 = pd.Series([1, 2, 3])
      s2 = convert.to_dataframe(p | beam.Create([100, 200, 300]))

      pc1, pc2 = convert.to_pcollection(s1, s2, pipeline=p)
      assert_that(pc1, equal_to([1, 2, 3]), label='CheckNonDeferred')
      assert_that(pc2, equal_to([100, 200, 300]), label='CheckDeferred')

  def test_convert_memoization(self):
    with beam.Pipeline() as p:
      a = pd.Series([1, 2, 3])
      b = pd.Series([100, 200, 300])

      pc_a = p | 'A' >> beam.Create([a])
      pc_b = p | 'B' >> beam.Create([b])

      df_a = convert.to_dataframe(pc_a, proxy=a[:0])
      df_b = convert.to_dataframe(pc_b, proxy=b[:0])

      df_2a = 2 * df_a
      df_3a = 3 * df_a
      df_ab = df_a * df_b

      # Two calls to to_pcollection with the same Dataframe should produce the
      # same PCollection(s)
      pc_2a_, pc_ab_ = convert.to_pcollection(df_2a, df_ab)
      pc_3a, pc_2a, pc_ab = convert.to_pcollection(df_3a, df_2a, df_ab)

      self.assertIs(pc_2a, pc_2a_)
      self.assertIs(pc_ab, pc_ab_)
      self.assertIsNot(pc_3a, pc_2a)
      self.assertIsNot(pc_3a, pc_ab)

      # The same conversions without the unbatching transform should also cache
      # PCollections
      pc_2a_pandas_, pc_ab_pandas_ = convert.to_pcollection(df_2a, df_ab,
                                            yield_elements='pandas')
      pc_3a_pandas, pc_2a_pandas, pc_ab_pandas = convert.to_pcollection(df_3a,
                                                                        df_2a,
                                                                        df_ab,
                                                   yield_elements='pandas')

      self.assertIs(pc_2a_pandas, pc_2a_pandas_)
      self.assertIs(pc_ab_pandas, pc_ab_pandas_)
      self.assertIsNot(pc_3a_pandas, pc_2a_pandas)
      self.assertIsNot(pc_3a_pandas, pc_ab_pandas)

      # .. but the cached PCollections should be different
      self.assertIsNot(pc_2a_pandas, pc_2a)
      self.assertIsNot(pc_ab_pandas, pc_ab)
      self.assertIsNot(pc_3a_pandas, pc_3a)

  def test_convert_memoization_clears_cache(self):
    # This test re-runs the other memoization test, and makes sure that the
    # cache is cleaned up with the pipeline. Otherwise there would be concerns
    # of it growing without bound.

    import gc

    # Make sure cache is clear
    gc.collect()
    self.assertEqual(len(convert.TO_PCOLLECTION_CACHE), 0)

    # Disable GC so it doesn't run pre-emptively, confounding assertions about
    # cache size
    gc.disable()

    # Also disable logging, as some implementations may artificially extend
    # the life of objects.
    import logging
    logging.disable(logging.INFO)

    try:
      self.test_convert_memoization()
      self.assertEqual(len(convert.TO_PCOLLECTION_CACHE), 3)

      gc.collect()

      # PCollections should be removed from cache after pipelines go out of
      # scope and are GC'd
      self.assertEqual(len(convert.TO_PCOLLECTION_CACHE), 0)
    finally:
      # Always re-enable GC and logging
      gc.enable()
      logging.disable(logging.NOTSET)

  def test_auto_convert(self):
    class MySchemaTransform(beam.PTransform):
      def expand(self, pcoll):
        return pcoll | beam.Map(
            lambda x: beam.Row(
                a=x.n**2 - x.m**2, b=2 * x.m * x.n, c=x.n**2 + x.m**2))

    with beam.Pipeline() as p:
      pc_mn = p | beam.Create([
          (1, 2), (2, 3), (3, 10)
      ]) | beam.MapTuple(lambda m, n: beam.Row(m=m, n=n))

      df_mn = convert.to_dataframe(pc_mn)

      # Apply a transform directly to a dataframe to get another dataframe.
      df_abc = df_mn | MySchemaTransform()

      pc_abc = convert.to_pcollection(df_abc) | beam.Map(tuple)
      assert_that(pc_abc, equal_to([(3, 4, 5), (5, 12, 13), (91, 60, 109)]))


if __name__ == '__main__':
  unittest.main()
