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

# pytype: skip-file

import copy
import itertools
import random
import threading
import unittest

from apache_beam.metrics.cells import BoundedTrieData
from apache_beam.metrics.cells import CounterCell
from apache_beam.metrics.cells import DistributionCell
from apache_beam.metrics.cells import DistributionData
from apache_beam.metrics.cells import GaugeCell
from apache_beam.metrics.cells import GaugeData
from apache_beam.metrics.cells import HistogramCell
from apache_beam.metrics.cells import HistogramCellFactory
from apache_beam.metrics.cells import HistogramData
from apache_beam.metrics.cells import StringSetCell
from apache_beam.metrics.cells import StringSetData
from apache_beam.metrics.cells import _BoundedTrieNode
from apache_beam.metrics.metricbase import MetricName
from apache_beam.utils.histogram import Histogram
from apache_beam.utils.histogram import LinearBucket


class TestCounterCell(unittest.TestCase):
  @classmethod
  def _modify_counter(cls, d):
    for i in range(cls.NUM_ITERATIONS):
      d.inc(i)

  NUM_THREADS = 5
  NUM_ITERATIONS = 100

  def test_parallel_access(self):
    # We create NUM_THREADS threads that concurrently modify the counter.
    threads = []
    c = CounterCell()
    for _ in range(TestCounterCell.NUM_THREADS):
      t = threading.Thread(
          target=TestCounterCell._modify_counter, args=(c, ))
      threads.append(t)
      t.start()

    for t in threads:
      t.join()

    total = (
        self.NUM_ITERATIONS * (self.NUM_ITERATIONS - 1) // 2 * self.NUM_THREADS)
    self.assertEqual(c.get_cumulative(), total)

  def test_basic_operations(self):
    c = CounterCell()
    c.inc(2)
    self.assertEqual(c.get_cumulative(), 2)

    c.dec(10)
    self.assertEqual(c.get_cumulative(), -8)

    c.dec()
    self.assertEqual(c.get_cumulative(), -9)

    c.inc()
    self.assertEqual(c.get_cumulative(), -8)

  def test_start_time_set(self):
    c = CounterCell()
    c.inc(2)

    name = MetricName('namespace', 'name1')
    mi = c.to_runner_api_monitoring_info(name, 'transform_id')
    self.assertGreater(mi.start_time.seconds, 0)


class TestDistributionCell(unittest.TestCase):
  @classmethod
  def _modify_distribution(cls, d):
    for i in range(cls.NUM_ITERATIONS):
      d.update(i)

  NUM_THREADS = 5
  NUM_ITERATIONS = 100

  def test_parallel_access(self):
    # We create NUM_THREADS threads that concurrently modify the distribution.
    threads = []
    d = DistributionCell()
    for _ in range(TestDistributionCell.NUM_THREADS):
      t = threading.Thread(
          target=TestDistributionCell._modify_distribution, args=(d, ))
      threads.append(t)
      t.start()

    for t in threads:
      t.join()

    total = (
        self.NUM_ITERATIONS * (self.NUM_ITERATIONS - 1) // 2 * self.NUM_THREADS)

    count = (self.NUM_ITERATIONS * self.NUM_THREADS)

    self.assertEqual(
        d.get_cumulative(),
        DistributionData(total, count, 0, self.NUM_ITERATIONS - 1))

  def test_basic_operations(self):
    d = DistributionCell()
    d.update(10)
    self.assertEqual(d.get_cumulative(), DistributionData(10, 1, 10, 10))

    d.update(2)
    self.assertEqual(d.get_cumulative(), DistributionData(12, 2, 2, 10))

    d.update(900)
    self.assertEqual(d.get_cumulative(), DistributionData(912, 3, 2, 900))

  def test_integer_only(self):
    d = DistributionCell()
    d.update(3.1)
    d.update(3.2)
    d.update(3.3)
    self.assertEqual(d.get_cumulative(), DistributionData(9, 3, 3, 3))

  def test_start_time_set(self):
    d = DistributionCell()
    d.update(3.1)

    name = MetricName('namespace', 'name1')
    mi = d.to_runner_api_monitoring_info(name, 'transform_id')
    self.assertGreater(mi.start_time.seconds, 0)


class TestGaugeCell(unittest.TestCase):
  def test_basic_operations(self):
    g = GaugeCell()
    g.set(10)
    self.assertEqual(g.get_cumulative().value, GaugeData(10).value)

    g.set(2)
    self.assertEqual(g.get_cumulative().value, 2)

  def test_integer_only(self):
    g = GaugeCell()
    g.set(3.3)
    self.assertEqual(g.get_cumulative().value, 3)

  def test_combine_appropriately(self):
    g1 = GaugeCell()
    g1.set(3)

    g2 = GaugeCell()
    g2.set(1)

    # THe second Gauge, with value 1 was the most recent, so it should be
    # the final result.
    result = g2.combine(g1)
    self.assertEqual(result.data.value, 1)

  def test_start_time_set(self):
    g1 = GaugeCell()
    g1.set(3)

    name = MetricName('namespace', 'name1')
    mi = g1.to_runner_api_monitoring_info(name, 'transform_id')
    self.assertGreater(mi.start_time.seconds, 0)


class TestStringSetCell(unittest.TestCase):
  def test_not_leak_mutable_set(self):
    c = StringSetCell()
    c.add('test')
    c.add('another')
    s = c.get_cumulative()
    self.assertEqual(s, StringSetData({'test', 'another'}, 11))
    s.add('yet another')
    self.assertEqual(c.get_cumulative(), StringSetData({'test', 'another'}, 11))

  def test_combine_appropriately(self):
    s1 = StringSetCell()
    s1.add('1')
    s1.add('2')

    s2 = StringSetCell()
    s2.add('1')
    s2.add('3')

    result = s2.combine(s1)
    self.assertEqual(result.data, StringSetData({'1', '2', '3'}))

  def test_add_size_tracked_correctly(self):
    s = StringSetCell()
    s.add('1')
    s.add('2')
    self.assertEqual(s.data.string_size, 2)
    s.add('2')
    s.add('3')
    self.assertEqual(s.data.string_size, 3)


class TestBoundedTrieNode(unittest.TestCase):
  @classmethod
  def random_segments_fixed_depth(cls, n, depth, overlap, rand):
    if depth == 0:
      yield from ((), ) * n
    else:
      seen = []
      to_string = lambda ix: chr(ord('a') + ix) if ix < 26 else f'z{ix}'
      for suffix in cls.random_segments_fixed_depth(n, depth - 1, overlap,
                                                    rand):
        if not seen or rand.random() > overlap:
          prefix = to_string(len(seen))
          seen.append(prefix)
        else:
          prefix = rand.choice(seen)
        yield (prefix, ) + suffix

  @classmethod
  def random_segments(cls, n, min_depth, max_depth, overlap, rand):
    for depth, segments in zip(
        itertools.cycle(range(min_depth, max_depth + 1)),
        cls.random_segments_fixed_depth(n, max_depth, overlap, rand)):
      yield segments[:depth]

  def assert_covers(self, node, expected, max_truncated=0):
    self.assert_covers_flattened(node.flattened(), expected, max_truncated)

  def assert_covers_flattened(self, flattened, expected, max_truncated=0):
    expected = set(expected)
    # Split node into the exact and truncated segments.
    partitioned = {True: set(), False: set()}
    for segments in flattened:
      partitioned[segments[-1]].add(segments[:-1])
    exact, truncated = partitioned[False], partitioned[True]
    # Check we cover both parts.
    self.assertLessEqual(len(truncated), max_truncated, truncated)
    self.assertTrue(exact.issubset(expected), exact - expected)
    seen_truncated = set()
    for segments in expected - exact:
      found = 0
      for ix in range(len(segments)):
        if segments[:ix] in truncated:
          seen_truncated.add(segments[:ix])
          found += 1
      if found != 1:
        self.fail(
            f"Expected exactly one prefix of {segments} "
            f"to occur in {truncated}, found {found}")
    self.assertEqual(seen_truncated, truncated, truncated - seen_truncated)

  def run_covers_test(self, flattened, expected, max_truncated):
    def parse(s):
      return tuple(s.strip('*')) + (s.endswith('*'), )

    self.assert_covers_flattened([parse(s) for s in flattened],
                                 [tuple(s) for s in expected],
                                 max_truncated)

  def test_covers_exact(self):
    self.run_covers_test(['ab', 'ac', 'cd'], ['ab', 'ac', 'cd'], 0)
    with self.assertRaises(AssertionError):
      self.run_covers_test(['ab', 'ac', 'cd'], ['ac', 'cd'], 0)
    with self.assertRaises(AssertionError):
      self.run_covers_test(['ab', 'ac'], ['ab', 'ac', 'cd'], 0)
    with self.assertRaises(AssertionError):
      self.run_covers_test(['a*', 'cd'], ['ab', 'ac', 'cd'], 0)

  def test_covers_trunacted(self):
    self.run_covers_test(['a*', 'cd'], ['ab', 'ac', 'cd'], 1)
    self.run_covers_test(['a*', 'cd'], ['ab', 'ac', 'abcde', 'cd'], 1)
    with self.assertRaises(AssertionError):
      self.run_covers_test(['ab', 'ac', 'cd'], ['ac', 'cd'], 1)
    with self.assertRaises(AssertionError):
      self.run_covers_test(['ab', 'ac'], ['ab', 'ac', 'cd'], 1)
    with self.assertRaises(AssertionError):
      self.run_covers_test(['a*', 'c*'], ['ab', 'ac', 'cd'], 1)
    with self.assertRaises(AssertionError):
      self.run_covers_test(['a*', 'c*'], ['ab', 'ac'], 1)

  def run_test(self, to_add):
    everything = list(set(to_add))
    all_prefixees = set(
        segments[:ix] for segments in everything for ix in range(len(segments)))
    everything_deduped = set(everything) - all_prefixees

    # Check basic addition.
    node = _BoundedTrieNode()
    total_size = node.size()
    self.assertEqual(total_size, 1)
    for segments in everything:
      total_size += node.add(segments)
    self.assertEqual(node.size(), len(everything_deduped), node)
    self.assertEqual(node.size(), total_size, node)
    self.assert_covers(node, everything_deduped)

    # Check merging
    node0 = _BoundedTrieNode()
    node0.add_all(everything[0::2])
    node1 = _BoundedTrieNode()
    node1.add_all(everything[1::2])
    pre_merge_size = node0.size()
    merge_delta = node0.merge(node1)
    self.assertEqual(node0.size(), pre_merge_size + merge_delta)
    self.assertEqual(node0, node)

    # Check trimming.
    if node.size() > 1:
      trim_delta = node.trim()
      self.assertLess(trim_delta, 0, node)
      self.assertEqual(node.size(), total_size + trim_delta)
      self.assert_covers(node, everything_deduped, max_truncated=1)

    if node.size() > 1:
      trim2_delta = node.trim()
      self.assertLess(trim2_delta, 0)
      self.assertEqual(node.size(), total_size + trim_delta + trim2_delta)
      self.assert_covers(node, everything_deduped, max_truncated=2)

    # Adding after trimming should be a no-op.
    node_copy = copy.deepcopy(node)
    for segments in everything:
      self.assertEqual(node.add(segments), 0)
    self.assertEqual(node, node_copy)

    # Merging after trimming should be a no-op.
    self.assertEqual(node.merge(node0), 0)
    self.assertEqual(node.merge(node1), 0)
    self.assertEqual(node, node_copy)

    if node._truncated:
      expected_delta = 0
    else:
      expected_delta = 2

    # Adding something new is not.
    new_values = [('new1', ), ('new2', 'new2.1')]
    self.assertEqual(node.add_all(new_values), expected_delta)
    self.assert_covers(
        node, list(everything_deduped) + new_values, max_truncated=2)

    # Nor is merging something new.
    new_values_node = _BoundedTrieNode()
    new_values_node.add_all(new_values)
    self.assertEqual(node_copy.merge(new_values_node), expected_delta)
    self.assert_covers(
        node_copy, list(everything_deduped) + new_values, max_truncated=2)

  def run_fuzz(self, iterations=10, **params):
    for _ in range(iterations):
      seed = random.getrandbits(64)
      segments = self.random_segments(**params, rand=random.Random(seed))
      try:
        self.run_test(segments)
      except:
        print("SEED", seed)
        raise

  def test_trivial(self):
    self.run_test([('a', 'b'), ('a', 'c')])

  def test_flat(self):
    self.run_test([('a', 'a'), ('b', 'b'), ('c', 'c')])

  def test_deep(self):
    self.run_test([('a', ) * 10, ('b', ) * 12])

  def test_small(self):
    self.run_fuzz(n=5, min_depth=2, max_depth=3, overlap=0.5)

  def test_medium(self):
    self.run_fuzz(n=20, min_depth=2, max_depth=4, overlap=0.5)

  def test_large_sparse(self):
    self.run_fuzz(n=120, min_depth=2, max_depth=4, overlap=0.2)

  def test_large_dense(self):
    self.run_fuzz(n=120, min_depth=2, max_depth=4, overlap=0.8)

  def test_bounded_trie_data_combine(self):
    empty = BoundedTrieData()
    # The merging here isn't complicated we're just ensuring that
    # BoundedTrieData invokes _BoundedTrieNode correctly.
    singletonA = BoundedTrieData(singleton=('a', 'a'))
    singletonB = BoundedTrieData(singleton=('b', 'b'))
    lots_root = _BoundedTrieNode()
    lots_root.add_all([('c', 'c'), ('d', 'd')])
    lots = BoundedTrieData(root=lots_root)
    self.assertEqual(empty.get_result(), set())
    self.assertEqual(
        empty.combine(singletonA).get_result(), set([('a', 'a', False)]))
    self.assertEqual(
        singletonA.combine(empty).get_result(), set([('a', 'a', False)]))
    self.assertEqual(
        singletonA.combine(singletonB).get_result(),
        set([('a', 'a', False), ('b', 'b', False)]))
    self.assertEqual(
        singletonA.combine(lots).get_result(),
        set([('a', 'a', False), ('c', 'c', False), ('d', 'd', False)]))
    self.assertEqual(
        lots.combine(singletonA).get_result(),
        set([('a', 'a', False), ('c', 'c', False), ('d', 'd', False)]))

  def test_bounded_trie_data_combine_trim(self):
    left = _BoundedTrieNode()
    left.add_all([('a', 'x'), ('b', 'd')])
    right = _BoundedTrieNode()
    right.add_all([('a', 'y'), ('c', 'd')])
    self.assertEqual(
        BoundedTrieData(root=left).combine(
            BoundedTrieData(root=right, bound=3)).get_result(),
        set([('a', True), ('b', 'd', False), ('c', 'd', False)]))

  def test_merge_on_empty_node(self):
    root1 = _BoundedTrieNode()
    root2 = _BoundedTrieNode()
    root2.add_all([["a", "b", "c"], ["a", "b", "d"], ["a", "e"]])
    self.assertEqual(2, root1.merge(root2))
    self.assertEqual(3, root1.size())
    self.assertFalse(root1._truncated)

  def test_merge_with_empty_node(self):
    root1 = _BoundedTrieNode()
    root1.add_all([["a", "b", "c"], ["a", "b", "d"], ["a", "e"]])
    root2 = _BoundedTrieNode()

    self.assertEqual(0, root1.merge(root2))
    self.assertEqual(3, root1.size())
    self.assertFalse(root1._truncated)


class TestHistogramCell(unittest.TestCase):
  @classmethod
  def _modify_histogram(cls, d):
    for i in range(cls.NUM_ITERATIONS):
      d.update(i)

  NUM_THREADS = 5
  NUM_ITERATIONS = 100

  def test_parallel_access(self):
    # We create NUM_THREADS threads that concurrently modify the distribution.
    threads = []
    bucket_type = LinearBucket(0, 1, 100)
    d = HistogramCell(bucket_type)
    for _ in range(TestHistogramCell.NUM_THREADS):
      t = threading.Thread(
          target=TestHistogramCell._modify_histogram, args=(d, ))
      threads.append(t)
      t.start()

    for t in threads:
      t.join()

    histogram = Histogram(bucket_type)
    for _ in range(self.NUM_THREADS):
      for i in range(self.NUM_ITERATIONS):
        histogram.record(i)

    self.assertEqual(d.get_cumulative(), HistogramData(histogram))

  def test_basic_operations(self):
    d = HistogramCellFactory(LinearBucket(0, 1, 10))()
    d.update(10)
    self.assertEqual(
        str(d.get_cumulative()),
        'HistogramData(Total count: 1, P99: >=10, P90: >=10, P50: >=10)')
    d.update(0)
    self.assertEqual(
        str(d.get_cumulative()),
        'HistogramData(Total count: 2, P99: >=10, P90: >=10, P50: 1)')
    d.update(5)
    self.assertEqual(
        str(d.get_cumulative()),
        'HistogramData(Total count: 3, P99: >=10, P90: >=10, P50: 6)')


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