#
# 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 collections
import logging
from typing import Any
from typing import Iterable
from typing import Iterator
from typing import TypeVar

from apache_beam import coders
from apache_beam.coders.coder_impl import create_InputStream
from apache_beam.coders.coder_impl import create_OutputStream
from apache_beam.portability import common_urns
from apache_beam.portability.api import beam_fn_api_pb2
from apache_beam.portability.api import beam_runner_api_pb2
from apache_beam.runners import common
from apache_beam.runners import pipeline_context
from apache_beam.runners import runner
from apache_beam.runners.portability.fn_api_runner import translations
from apache_beam.runners.portability.fn_api_runner import worker_handlers
from apache_beam.runners.worker import bundle_processor
from apache_beam.transforms import core
from apache_beam.transforms import trigger
from apache_beam.utils import windowed_value

T = TypeVar("T")

_LOGGER = logging.getLogger(__name__)


class TrivialRunner(runner.PipelineRunner):
  """A bare-bones batch Python pipeline runner illistrating how to use the
  RunnerAPI and FnAPI to execute pipelines.

  Note that this runner is primarily for pedagogical purposes and is missing
  several features in order to keep it as simple as possible.  Where possible
  pointers are provided which this should serve as a useful starting point.
  """
  def run_portable_pipeline(self, pipeline, options):
    # First ensure we are able to run this pipeline.
    # Specifically, that it does not depend on requirements that were
    # added since this runner was developed.
    self.check_requirements(pipeline, self.supported_requirements())

    # Now we optimize the pipeline, notably performing pipeline fusion,
    # to turn it into a DAG where all the operations are one of
    # Impulse, Flatten, GroupByKey, or beam:runner:executable_stage.
    optimized_pipeline = translations.optimize_pipeline(
        pipeline,
        phases=translations.standard_optimize_phases(),
        known_runner_urns=frozenset([
            common_urns.primitives.IMPULSE.urn,
            common_urns.primitives.FLATTEN.urn,
            common_urns.primitives.GROUP_BY_KEY.urn
        ]),
        # This boolean indicates we want fused executable_stages.
        partial=False)

    # standard_optimize_phases() has a final step giving the stages in
    # topological order, so now we can just walk over them and execute
    # them.  (This are not quite so simple if we were attempting to execute
    # a streaming pipeline, but this is a trivial runner that only supports
    # batch...)
    execution_state = ExecutionState(optimized_pipeline)
    for transform_id in optimized_pipeline.root_transform_ids:
      self.execute_transform(transform_id, execution_state)

    # A more sophisticated runner may perform the execution in the background
    # and return a PipelineResult that can be used to monitor/cancel the
    # concurrent execution.
    return runner.PipelineResult(runner.PipelineState.DONE)

  def execute_transform(self, transform_id, execution_state):
    """Execute a single transform."""
    transform_proto = execution_state.optimized_pipeline.components.transforms[
        transform_id]
    _LOGGER.info(
        "Executing stage %s %s", transform_id, transform_proto.unique_name)
    if not is_primitive_transform(transform_proto):
      # A composite is simply executed by executing its parts.
      for sub_transform in transform_proto.subtransforms:
        self.execute_transform(sub_transform, execution_state)

    elif transform_proto.spec.urn == common_urns.primitives.IMPULSE.urn:
      # An impulse has no inputs and produces a single output (which happens
      # to be an empty byte string in the global window).
      execution_state.set_pcollection_contents(
          only_element(transform_proto.outputs.values()),
          [common.ENCODED_IMPULSE_VALUE])

    elif transform_proto.spec.urn == common_urns.primitives.FLATTEN.urn:
      # The output of a flatten is simply the union of its inputs.
      output_pcoll_id = only_element(transform_proto.outputs.values())
      execution_state.set_pcollection_contents(
          output_pcoll_id,
          sum([
              execution_state.get_pcollection_contents(pc)
              for pc in transform_proto.inputs.values()
          ], []))

    elif transform_proto.spec.urn == common_urns.executable_stage:
      # This is a collection of user DoFns.
      self.execute_executable_stage(transform_proto, execution_state)

    elif transform_proto.spec.urn == common_urns.primitives.GROUP_BY_KEY.urn:
      # Execute the grouping operation.
      self.group_by_key_and_window(
          only_element(transform_proto.inputs.values()),
          only_element(transform_proto.outputs.values()),
          execution_state)

    else:
      raise RuntimeError(
          f"Unsupported transform {transform_id}"
          " of type {transform_proto.spec.urn}")

  def execute_executable_stage(self, transform_proto, execution_state):
    # Stage here is like a mini pipeline, with PTransforms, PCollections, etc.
    # inside of it.
    stage = beam_runner_api_pb2.ExecutableStagePayload.FromString(
        transform_proto.spec.payload)
    if stage.side_inputs:
      # To support these we would need to make the side input PCollections
      # available over the state API before processing this bundle.
      raise NotImplementedError()

    # This is the set of transforms that were fused together.
    stage_transforms = {
        id: stage.components.transforms[id]
        for id in stage.transforms
    }
    # The executable stage has bare PCollections as its inputs and outputs.
    #
    # We need an operation to feed data into the bundle (runner -> SDK).
    # This is done by attaching special transform that reads from the data
    # channel.
    input_transform = execution_state.new_id('stage_input')
    input_pcoll = stage.input
    stage_transforms[input_transform] = beam_runner_api_pb2.PTransform(
        # Read data, encoded with the given coder, from the data channel.
        spec=beam_runner_api_pb2.FunctionSpec(
            urn=bundle_processor.DATA_INPUT_URN,
            payload=beam_fn_api_pb2.RemoteGrpcPort(
                # If we were using a cross-process data channel, we would also
                # need to set the address of the data channel itself here.
                coder_id=execution_state.windowed_coder_id(
                    stage.input)).SerializeToString()),
        # Wire its "output" to the required PCollection.
        outputs={'out': input_pcoll})
    # Also add operations to consume data produced by the bundle and writes
    # them to the data channel (SDK -> runner).
    output_ops_to_pcoll = {}
    for output_pcoll in stage.outputs:
      output_transform = execution_state.new_id('stage_output')
      stage_transforms[output_transform] = beam_runner_api_pb2.PTransform(
          # Data will be written here, with the given coder.
          spec=beam_runner_api_pb2.FunctionSpec(
              urn=bundle_processor.DATA_OUTPUT_URN,
              payload=beam_fn_api_pb2.RemoteGrpcPort(
                  # Again, the grpc address itself is implicit.
                  coder_id=execution_state.windowed_coder_id(
                      output_pcoll)).SerializeToString()),
          # This operation takes as input the bundle's output pcollection.
          inputs={'input': output_pcoll})
      output_ops_to_pcoll[output_transform] = output_pcoll

    # Now we can create a description of what it means to process a bundle
    # of this type.  When processing the bundle, we simply refer to this
    # descriptor by id.  (In our case we only do so once, but in a more general
    # runner one may want to process the same "type" of bundle of many distinct
    # partitions of the input, especially in streaming where one may have
    # hundreds of concurrently processed bundles per key.)
    process_bundle_descriptor = beam_fn_api_pb2.ProcessBundleDescriptor(
        id=execution_state.new_id('descriptor'),
        transforms=stage_transforms,
        pcollections=stage.components.pcollections,
        coders=execution_state.optimized_pipeline.components.coders,
        windowing_strategies=stage.components.windowing_strategies,
        environments=stage.components.environments,
        # Were timers and state supported, their endpoints would be listed here.
    )
    execution_state.register_process_bundle_descriptor(
        process_bundle_descriptor)

    # Now we are ready to actually execute the bundle.
    process_bundle_id = execution_state.new_id('bundle')

    # First, push the all input data onto the data channel.
    # Timers would be sent over this channel as well.
    # In a real runner we could do this after (or concurrently with) starting
    # the bundle to avoid having to hold all the data to process in memory,
    # but for simplicity our bundle invocation (below) is synchronous so the
    # data must be available for processing right away.
    to_worker = execution_state.worker_handler.data_conn.output_stream(
        process_bundle_id, input_transform)
    for encoded_data in execution_state.get_pcollection_contents(input_pcoll):
      to_worker.write(encoded_data)
    to_worker.close()

    # Now we send a process bundle request over the control plane.
    process_bundle_request = beam_fn_api_pb2.InstructionRequest(
        instruction_id=process_bundle_id,
        process_bundle=beam_fn_api_pb2.ProcessBundleRequest(
            process_bundle_descriptor_id=process_bundle_descriptor.id))
    result_future = execution_state.worker_handler.control_conn.push(
        process_bundle_request)

    # Read the results off the data channel.
    # Note that if there are multiple outputs, we may get them in any order,
    # possibly interleaved.
    for output in execution_state.worker_handler.data_conn.input_elements(
        process_bundle_id, list(output_ops_to_pcoll.keys())):
      if isinstance(output, beam_fn_api_pb2.Elements.Data):
        # Adds the output to the appropriate PCollection.
        execution_state.set_pcollection_contents(
            output_ops_to_pcoll[output.transform_id], [output.data])
      else:
        # E.g. timers to set.
        raise RuntimeError("Unexpected data type: %s" % output)

    # Ensure the operation completed successfully.
    # This result contains things like metrics and continuation tokens as well.
    result = result_future.get()
    if result.error:
      raise RuntimeError(result.error)
    if result.process_bundle.residual_roots:
      # We would need to re-schedule execution of this bundle with this data.
      raise NotImplementedError('SDF continuation')
    if result.process_bundle.requires_finalization:
      # We would need to invoke the finalization callback, on a best effort
      # basis, *after* the outputs are durably committed.
      raise NotImplementedError('finalization')
    if result.process_bundle.elements.data:
      # These should be processed just like outputs from the data channel.
      raise NotImplementedError('control-channel data')
    if result.process_bundle.elements.timers:
      # These should be processed just like outputs from the data channel.
      raise NotImplementedError('timers')

  def group_by_key_and_window(self, input_pcoll, output_pcoll, execution_state):
    """Groups the elements of input_pcoll, placing their output in output_pcoll.
    """
    # Note that we are using the designated coders to decode and deeply inspect
    # the elements. This could be useful for other operations as well (e.g.
    # splitting the returned bundles of encoded elements into smaller chunks).
    # One issue that we're sweeping under the rug here is that for a truly
    # portable multi-language runner we may not be able to instanciate
    # every coder in Python. This can be overcome by wrapping unknown coders as
    # length-prefixing (or otherwise delimiting) variants.

    # Decode the input elements to get at their individual keys and values.
    input_coder = execution_state.windowed_coder(input_pcoll)
    key_coder = input_coder.key_coder()
    input_elements = []
    for encoded_elements in execution_state.get_pcollection_contents(
        input_pcoll):
      for element in decode_all(encoded_elements, input_coder):
        input_elements.append(element)

    # Now perform the actual grouping.
    components = execution_state.optimized_pipeline.components
    windowing = components.windowing_strategies[
        components.pcollections[input_pcoll].windowing_strategy_id]

    if (windowing.merge_status
        == beam_runner_api_pb2.MergeStatus.Enum.NON_MERGING and
        windowing.output_time
        == beam_runner_api_pb2.OutputTime.Enum.END_OF_WINDOW):
      # This is the "easy" case, show how to do it by hand.
      # Note that we're grouping by encoded key, and also by the window.
      grouped = collections.defaultdict(list)
      for element in input_elements:
        for window in element.windows:
          key, value = element.value
          grouped[window, key_coder.encode(key)].append(value)
      output_elements = [
          windowed_value.WindowedValue(
              (key_coder.decode(encoded_key), values),
              window.end, [window],
              trigger.BatchGlobalTriggerDriver.ONLY_FIRING)
          for ((window, encoded_key), values) in grouped.items()
      ]
    else:
      # This handles generic merging and triggering.
      trigger_driver = trigger.create_trigger_driver(
          execution_state.windowing_strategy(input_pcoll), True)
      grouped_by_key = collections.defaultdict(list)
      for element in input_elements:
        key, value = element.value
        grouped_by_key[key_coder.encode(key)].append(element.with_value(value))
      output_elements = []
      for encoded_key, windowed_values in grouped_by_key.items():
        for grouping in trigger_driver.process_entire_key(
            key_coder.decode(encoded_key), windowed_values):
          output_elements.append(grouping)

    # Store the grouped values in the output PCollection.
    output_coder = execution_state.windowed_coder(output_pcoll)
    execution_state.set_pcollection_contents(
        output_pcoll, [encode_all(output_elements, output_coder)])

  def supported_requirements(self) -> Iterable[str]:
    # Nothing non-trivial is supported.
    return []


class ExecutionState:
  """A helper class holding various values and context during execution."""
  def __init__(self, optimized_pipeline):
    self.optimized_pipeline = optimized_pipeline
    self._pcollections_to_encoded_chunks = {}
    self._counter = 0
    self._process_bundle_descriptors = {}
    # This emulates a connection to an SDK worker (e.g. its data, control,
    # etc. channels).
    # There are other variants available as well (e.g. GRPC, Docker, ...)
    self.worker_handler = worker_handlers.EmbeddedWorkerHandler(
        None,
        state=worker_handlers.StateServicer(),
        provision_info=None,
        worker_manager=self)
    self._windowed_coders = {}
    # Populate all windowed coders before creating _pipeline_context.
    for pcoll_id in self.optimized_pipeline.components.pcollections.keys():
      self.windowed_coder_id(pcoll_id)
    self._pipeline_context = pipeline_context.PipelineContext(
        optimized_pipeline.components)

  def register_process_bundle_descriptor(
      self, process_bundle_descriptor: beam_fn_api_pb2.ProcessBundleDescriptor):
    self._process_bundle_descriptors[
        process_bundle_descriptor.id] = process_bundle_descriptor

  def get_pcollection_contents(self, pcoll_id: str) -> list[bytes]:
    return self._pcollections_to_encoded_chunks[pcoll_id]

  def set_pcollection_contents(self, pcoll_id: str, chunks: list[bytes]):
    self._pcollections_to_encoded_chunks[pcoll_id] = chunks

  def new_id(self, prefix='') -> str:
    self._counter += 1
    return f'runner_{prefix}_{self._counter}'

  def windowed_coder(self, pcollection_id: str) -> coders.Coder:
    return self._pipeline_context.coders.get_by_id(
        self.windowed_coder_id(pcollection_id))

  def windowing_strategy(self, pcollection_id: str) -> core.Windowing:
    return self._pipeline_context.windowing_strategies.get_by_id(
        self.optimized_pipeline.components.pcollections[pcollection_id].
        windowing_strategy_id)

  def windowed_coder_id(self, pcollection_id: str) -> str:
    pcoll = self.optimized_pipeline.components.pcollections[pcollection_id]
    windowing = self.optimized_pipeline.components.windowing_strategies[
        pcoll.windowing_strategy_id]
    return self._windowed_coder_id_from(
        pcoll.coder_id, windowing.window_coder_id)

  def _windowed_coder_id_from(self, coder_id: str, window_coder_id: str) -> str:
    if (coder_id, window_coder_id) not in self._windowed_coders:
      windowed_coder_id = self.new_id('windowed_coder')
      self._windowed_coders[coder_id, window_coder_id] = windowed_coder_id
      self.optimized_pipeline.components.coders[windowed_coder_id].CopyFrom(
          beam_runner_api_pb2.Coder(
              spec=beam_runner_api_pb2.FunctionSpec(
                  urn=common_urns.coders.WINDOWED_VALUE.urn),
              component_coder_ids=[coder_id, window_coder_id]))
    return self._windowed_coders[coder_id, window_coder_id]


def is_primitive_transform(transform: beam_runner_api_pb2.PTransform) -> bool:
  # pylint: disable=consider-using-ternary
  return (
      # Only non-primitive (aka composite) transforms have subtransforms.
      not transform.subtransforms
      # If it has outputs but all of the outputs are also inputs to this
      # transform, there is nothing to compute.
      and not transform.outputs or
      bool(set(transform.outputs.values()) - set(transform.inputs.values())))


def only_element(iterable: Iterable[T]) -> T:
  element, = iterable
  return element


def decode_all(encoded_elements: bytes, coder: coders.Coder) -> Iterator[Any]:
  coder_impl = coder.get_impl()
  input_stream = create_InputStream(encoded_elements)
  while input_stream.size() > 0:
    yield coder_impl.decode_from_stream(input_stream, True)


def encode_all(elements: Iterator[T], coder: coders.Coder) -> bytes:
  coder_impl = coder.get_impl()
  output_stream = create_OutputStream()
  for element in elements:
    coder_impl.encode_to_stream(element, output_stream, True)
  return output_stream.get()
