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

# This script is used to run Change Point Analysis using a config file.
# config file holds the parameters required to fetch data, and to run the
# change point analysis. Change Point Analysis is used to find Performance
# regressions for benchmark/load/performance test.

import argparse
import logging
import uuid
from datetime import datetime
from datetime import timezone
from typing import Any

import pandas as pd

from apache_beam.testing.analyzers import constants
from apache_beam.testing.analyzers.perf_analysis_utils import BigQueryMetricsFetcher
from apache_beam.testing.analyzers.perf_analysis_utils import ChangePointConfig
from apache_beam.testing.analyzers.perf_analysis_utils import GitHubIssueMetaData
from apache_beam.testing.analyzers.perf_analysis_utils import MetricsFetcher
from apache_beam.testing.analyzers.perf_analysis_utils import TestConfigContainer
from apache_beam.testing.analyzers.perf_analysis_utils import create_performance_alert
from apache_beam.testing.analyzers.perf_analysis_utils import find_latest_change_point_index
from apache_beam.testing.analyzers.perf_analysis_utils import get_existing_issues_data
from apache_beam.testing.analyzers.perf_analysis_utils import is_change_point_in_valid_window
from apache_beam.testing.analyzers.perf_analysis_utils import is_sibling_change_point
from apache_beam.testing.analyzers.perf_analysis_utils import publish_issue_metadata_to_big_query
from apache_beam.testing.analyzers.perf_analysis_utils import read_test_config


def get_test_config_container(
    params: dict[str, Any],
    test_id: str,
    metric_name: str,
) -> TestConfigContainer:
  """
  Args:
    params: Dict containing parameters to run change point analysis.
  Returns:
    TestConfigContainer object containing test config parameters.
  """
  return TestConfigContainer(
      project=params['project'],
      metrics_dataset=params['metrics_dataset'],
      metrics_table=params['metrics_table'],
      metric_name=metric_name,
      test_id=test_id,
      test_description=params['test_description'],
      test_name=params.get('test_name', None),
      labels=params.get('labels', None),
  )


def get_change_point_config(
    params: dict[str, Any],
) -> ChangePointConfig:
  """
  Args:
    params: Dict containing parameters to run change point analysis.
  Returns:
    ChangePointConfig object containing change point analysis parameters.
  """
  return ChangePointConfig(
      min_runs_between_change_points=params.get(
          'min_runs_between_change_points',
          constants._DEFAULT_MIN_RUNS_BETWEEN_CHANGE_POINTS),
      num_runs_in_change_point_window=params.get(
          'num_runs_in_change_point_window',
          constants._DEFAULT_NUM_RUMS_IN_CHANGE_POINT_WINDOW),
      median_abs_deviation_threshold=params.get(
          'median_abs_deviation_threshold',
          constants._DEFAULT_MEDIAN_ABS_DEVIATION_THRESHOLD))


def run_change_point_analysis(
    test_config_container: TestConfigContainer,
    big_query_metrics_fetcher: MetricsFetcher,
    change_point_config: ChangePointConfig = ChangePointConfig(),
    save_alert_metadata: bool = False,
):
  """
  Args:
   test_config_container: TestConfigContainer containing test metadata for
    fetching data and running change point analysis.
   big_query_metrics_fetcher: BigQuery metrics fetcher used to fetch data for
    change point analysis.
    change_point_config: ChangePointConfig containing parameters to run
      change point analysis.
    save_alert_metadata: bool indicating if issue metadata
      should be published to BigQuery table.
  Returns:
     bool indicating if a change point is observed and alerted on GitHub.
  """
  logging.info(
      "Running change point analysis for test ID :%s on metric: % s" %
      (test_config_container.test_id, test_config_container.metric_name))

  # test_name will be used to query a single test from
  # multiple tests in a single BQ table. Right now, the default
  # assumption is that all the test have an individual BQ table
  # but this might not be case for other tests(such as IO tests where
  # a single BQ tables stores all the data)
  test_name = test_config_container.test_name

  min_runs_between_change_points = (
      change_point_config.min_runs_between_change_points)

  num_runs_in_change_point_window = (
      change_point_config.num_runs_in_change_point_window)

  metric_container = big_query_metrics_fetcher.fetch_metric_data(
      test_config=test_config_container)
  metric_container.sort_by_timestamp()

  metric_values = metric_container.values
  timestamps = metric_container.timestamps

  change_point_index = find_latest_change_point_index(
      metric_values=metric_values,
      median_abs_deviation_threshold=change_point_config.
      median_abs_deviation_threshold)
  if not change_point_index:
    logging.info(
        "Change point is not detected for the test ID %s" %
        test_config_container.test_id)
    return False
  # since timestamps are ordered in ascending order and
  # num_runs_in_change_point_window refers to the latest runs,
  # latest_change_point_run can help determine if the change point
  # index is recent wrt num_runs_in_change_point_window
  latest_change_point_run = len(timestamps) - 1 - change_point_index
  if not is_change_point_in_valid_window(num_runs_in_change_point_window,
                                         latest_change_point_run):
    logging.info(
        'Performance regression/improvement found for the test ID: %s. '
        'on metric %s. Since the change point run %s '
        'lies outside the num_runs_in_change_point_window distance: %s, '
        'alert is not raised.' % (
            test_config_container.test_id,
            test_config_container.metric_name,
            latest_change_point_run + 1,
            num_runs_in_change_point_window))
    return False

  is_valid_change_point = True
  last_reported_issue_number = None

  # create a unique table name for each test and metric combination.
  # for beam load tests, metric_name and metric table are enough to
  # create a unique table name. For templates/IO tests, add `test_name`.
  issue_metadata_table_name = (
      f'{test_config_container.metrics_table}_{test_config_container.metric_name}'  # pylint: disable=line-too-long
  )
  if test_config_container.test_name:
    issue_metadata_table_name = (
        f'{issue_metadata_table_name}_{test_config_container.test_name}')

  existing_issue_data = get_existing_issues_data(
      table_name=issue_metadata_table_name)

  if existing_issue_data is not None:
    existing_issue_timestamps = existing_issue_data[
        constants._CHANGE_POINT_TIMESTAMP_LABEL].tolist()
    last_reported_issue_number = existing_issue_data[
        constants._ISSUE_NUMBER].tolist()[0]

    if not isinstance(last_reported_issue_number, int):
      # convert numpy.int64 to int
      last_reported_issue_number = last_reported_issue_number.item()

    is_valid_change_point = is_sibling_change_point(
        previous_change_point_timestamps=existing_issue_timestamps,
        change_point_index=change_point_index,
        timestamps=timestamps,
        min_runs_between_change_points=min_runs_between_change_points,
        test_id=test_config_container.test_id)

  # for testing purposes, we don't want to create an issue even if there is
  # a valid change point. This is useful when we want to test the change point
  # analysis logic without creating an issue.
  if is_valid_change_point and save_alert_metadata:
    issue_number, issue_url = create_performance_alert(
    test_config_container=test_config_container,
    metric_container=metric_container,
    change_point_index=change_point_index,
    existing_issue_number=last_reported_issue_number,
    )

    issue_metadata = GitHubIssueMetaData(
        issue_timestamp=pd.Timestamp(
            datetime.now().replace(tzinfo=timezone.utc)),
        # BQ doesn't allow '.' in table name
        test_id=test_config_container.test_id.replace('.', '_'),
        test_name=test_name or uuid.uuid4().hex,
        metric_name=test_config_container.metric_name,
        change_point=metric_values[change_point_index],
        issue_number=issue_number,
        issue_url=issue_url,
        change_point_timestamp=timestamps[change_point_index],
    )
    publish_issue_metadata_to_big_query(
        issue_metadata=issue_metadata,
        table_name=issue_metadata_table_name,
        project=test_config_container.project,
    )
  return is_valid_change_point


def run(
    *,
    config_file_path: str,
    big_query_metrics_fetcher: MetricsFetcher = BigQueryMetricsFetcher(),
    save_alert_metadata: bool = False,
) -> None:
  """
  run is the entry point to run change point analysis on test metric
  data, which is read from config file, and if there is a performance
  regression/improvement observed for a test, an alert
  will filed with GitHub Issues.

  If config_file_path is None, then the run method will use default
  config file to read the required perf test parameters.

  Please take a look at the README for more information on the parameters
  defined in the config file.

  """
  tests_config: dict[str, dict[str, Any]] = read_test_config(config_file_path)

  for test_id, params in tests_config.items():
    # single test config can have multiple metrics so we need to
    # iterate over all the metrics and run change point analysis
    # for each metric.
    metric_names = params['metric_name']
    if isinstance(metric_names, str):
      metric_names = [metric_names]

    for metric_name in metric_names:
      test_config_container = get_test_config_container(
          params=params, test_id=test_id, metric_name=metric_name)
      change_point_config = get_change_point_config(params)
      run_change_point_analysis(
          test_config_container=test_config_container,
          big_query_metrics_fetcher=big_query_metrics_fetcher,
          change_point_config=change_point_config,
          save_alert_metadata=save_alert_metadata)


if __name__ == '__main__':
  logging.basicConfig(level=logging.INFO)

  parser = argparse.ArgumentParser()
  parser.add_argument(
      '--config_file_path',
      required=True,
      type=str,
      help='Path to the config file that contains data to run the Change Point '
      'Analysis.The default file will used will be '
      'apache_beam/testing/analyzers/tests.config.yml. '
      'If you would like to use the Change Point Analysis for finding '
      'performance regression in the tests, '
      'please provide an .yml file in the same structure as the above '
      'mentioned file. ')
  parser.add_argument(
      '--save_alert_metadata',
      action='store_true',
      help='Save perf alert/ GH Issue metadata to BigQuery table.')
  known_args, unknown_args = parser.parse_known_args()

  if unknown_args:
    logging.warning('Discarding unknown arguments : %s ' % unknown_args)

  run(
      config_file_path=known_args.config_file_path,
      # Set this to true while running in production.
      save_alert_metadata=known_args.save_alert_metadata  # pylint: disable=line-too-long
  )
