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

"""
Common helper module for CI/CD Steps
"""
import asyncio
import logging
import os
import urllib.parse
from pathlib import PurePath
from typing import List, Optional, Dict
from api.v1 import api_pb2

import pydantic
from tqdm.asyncio import tqdm
import yaml

from api.v1.api_pb2 import (
    SDK_UNSPECIFIED,
    STATUS_UNSPECIFIED,
    Sdk,
    STATUS_VALIDATING,
    STATUS_PREPARING,
    STATUS_COMPILING,
    STATUS_EXECUTING,
    PRECOMPILED_OBJECT_TYPE_UNIT_TEST,
    PRECOMPILED_OBJECT_TYPE_KATA,
    PRECOMPILED_OBJECT_TYPE_UNSPECIFIED,
    PRECOMPILED_OBJECT_TYPE_EXAMPLE,
    PrecompiledObjectType,
)
from config import Config, TagFields, PrecompiledExampleType
from grpc_client import GRPCClient
from constants import BEAM_ROOT_DIR_ENV_VAR_KEY

from models import Example, Tag, SdkEnum, Dataset


def _check_no_nested(subdirs: List[str]):
    """
    Check there're no nested subdirs

    Sort alphabetically and compare the pairs of adjacent items
    using pathlib.PurePath: we don't want fs calls in this check
    """
    sorted_subdirs = sorted(PurePath(s) for s in subdirs)
    for dir1, dir2 in zip(sorted_subdirs, sorted_subdirs[1:]):
        if dir1 in [dir2, *dir2.parents]:
            raise ValueError(f"{dir2} is a subdirectory of {dir1}")


def find_examples(root_dir: str, subdirs: List[str], sdk: SdkEnum) -> List[Example]:
    """
    Find and return beam examples.

    Search throws all child files of work_dir directory files with beam tag:
    Beam-playground:
        name: NameOfExample
        description: Description of NameOfExample.
        multifile: false
        default_example: false
        context_line: 10
        categories:
            - category-1
            - category-2
        pipeline_options: --inputFile your_file --outputFile your_output_file
        complexity: MEDIUM
        tags:
            - example
    If some example contains beam tag with incorrect format raise an error.

    Args:
        root_dir: project root dir
        subdirs: sub-directories where to search examples.
        sdk: sdk that using to find examples for the specific sdk.

    Returns:
        List of Examples.
    """
    has_errors = False
    examples = []
    _check_no_nested(subdirs)
    for subdir in subdirs:
        subdir = os.path.join(root_dir, subdir)
        logging.info("subdir: %s", subdir)
        for root, _, files in os.walk(subdir):
            for filename in files:
                filepath = os.path.join(root, filename)
                try:
                    try:
                        example = _load_example(
                            filename=filename, filepath=filepath, sdk=sdk
                        )
                        if example is not None:
                            examples.append(example)
                    except pydantic.ValidationError as err:
                        if len(err.errors()) > 1:
                            raise
                        if err.errors()[0]["msg"] == "multifile is True but no files defined":
                            logging.warning("incomplete multifile example ignored %s", filepath)
                            continue
                        raise
                except Exception:
                    logging.exception("error loading example at %s", filepath)
                    has_errors = True
    if has_errors:
        raise ValueError(
            "Some of the beam examples contain beam playground tag with "
            "an incorrect format"
        )
    return examples


async def get_statuses(
    client: GRPCClient, examples: List[Example], concurrency: int = 10
):
    """
    Receive status and update example.status and example.pipeline_id for
    each example

    Args:
        examples: beam examples for processing and updating statuses and
        pipeline_id values.
    """
    tasks = []
    try:
        concurrency = int(os.environ["BEAM_CONCURRENCY"])
        logging.info("override default concurrency: %d", concurrency)
    except (KeyError, ValueError):
        pass

    semaphore = asyncio.Semaphore(concurrency)

    async def _semaphored_task(example):
        await semaphore.acquire()
        try:
            await _update_example_status(example, client)
        finally:
            semaphore.release()

    for example in examples:
        if example.tag.never_run:
            logging.info("skipping non runnable example %s", example.filepath)
        else:
            tasks.append(_semaphored_task(example))
    await tqdm.gather(*tasks)


def get_tag(filepath: PurePath) -> Optional[Tag]:
    """
    Parse file by filepath and find beam tag

    Args:
        filepath: path of the file

    Returns:
        If file contains tag, returns Tag object
        If file doesn't contain tag, returns None
    """
    with open(filepath, encoding="utf-8") as parsed_file:
        lines = parsed_file.readlines()

    line_start: Optional[int] = None
    line_finish: Optional[int] = None
    tag_prefix: Optional[str] = ""
    for idx, line in enumerate(lines):
        if line_start is None and line.endswith(Config.BEAM_PLAYGROUND_TITLE):
            line_start = idx
            prefix_len = len(line) - len(Config.BEAM_PLAYGROUND_TITLE)
            tag_prefix = line[:prefix_len]
        elif line_start and not line.startswith(tag_prefix):
            line_finish = idx
            break

    if not line_start or not line_finish:
        return None

    embdedded_yaml_content = "".join(
        line[len(tag_prefix) :] for line in lines[line_start:line_finish]
    )
    yml = yaml.load(embdedded_yaml_content, Loader=yaml.SafeLoader)

    try:
        return Tag(
            filepath=str(filepath),
            line_start=line_start,
            line_finish=line_finish,
            **yml[Config.BEAM_PLAYGROUND],
            )
    except pydantic.ValidationError as err:
        if len(err.errors()) == 1 and err.errors()[0]["msg"] == "multifile is True but no files defined":
            logging.warning("incomplete multifile example ignored %s", filepath)
            return None
        raise

def _load_example(filename, filepath, sdk: SdkEnum) -> Optional[Example]:
    """
    Check file by filepath for matching to beam example. If file is beam example,

    Args:
        filename: name of the file.
        filepath: path to the file.
        sdk: sdk that using to find examples for the specific sdk.

    Returns:
        If the file is an example, return Example object
        If it's not, return None
        In case of error, raise Exception
    """
    logging.debug("inspecting file %s", filepath)
    extension = filepath.split(os.extsep)[-1]
    if extension == Config.SDK_TO_EXTENSION[sdk]:
        logging.debug("sdk %s matched extension %s", api_pb2.Sdk.Name(sdk), extension)
        tag = get_tag(filepath)
        if tag is not None:
            logging.debug("playground-beam tag found")
            return _get_example(filepath, filename, tag, sdk)
    return None


# Make load_supported_categories called only once
# to make testing easier
_load_supported_categories = False


def load_supported_categories(categories_path: str):
    """
    Load the list of supported categories from categories_path file
    into Tag model config

    Args:
        categories_path: path to the file with categories.
    """
    global _load_supported_categories
    if _load_supported_categories:
        return
    with open(categories_path, encoding="utf-8") as supported_categories:
        yaml_object = yaml.load(supported_categories.read(), Loader=yaml.SafeLoader)

    Tag.Config.supported_categories = yaml_object[TagFields.categories]
    _load_supported_categories = True


def _get_content(filepath: str, tag_start_line: int, tag_finish_line) -> str:
    with open(filepath, encoding="utf-8") as parsed_file:
        lines = parsed_file.readlines()
        lines = lines[:tag_start_line] + lines[tag_finish_line:]
    return "".join(lines)


def _get_url_vcs(filepath: str) -> str:
    """
    Construct VCS URL from example's filepath
    """
    root_dir = os.getenv(BEAM_ROOT_DIR_ENV_VAR_KEY, "../..")
    rel_path = os.path.relpath(filepath, root_dir)
    url_vcs = "{}/{}".format(Config.URL_VCS_PREFIX, urllib.parse.quote(rel_path))
    return url_vcs


def _get_example(filepath: str, filename: str, tag: Tag, sdk: int) -> Example:
    """
    Return an Example by filepath and filename.

    Args:
         filepath: path of the example's file.
         filename: name of the example's file.
         tag: tag of the example.

    Returns:
        Parsed Example object.
    """

    # Calculate context line with tag removed. Note: context_line is 1-based, line_start and line_finish are 0-based.
    context_line = tag.context_line if tag.context_line <= tag.line_start else tag.context_line - (tag.line_finish - tag.line_start)
    
    return Example(
        sdk=SdkEnum(sdk),
        tag=tag,
        filepath=filepath,
        status=STATUS_UNSPECIFIED,
        type=_get_object_type(filename, filepath),
        code=_get_content(filepath, tag.line_start, tag.line_finish),
        url_vcs=_get_url_vcs(filepath),  # type: ignore
        context_line=context_line,
    )


async def _update_example_status(example: Example, client: GRPCClient):
    """
    Receive status for examples and update example.status and pipeline_id

    Use client to send requests to the backend:
    1. Start code processing.
    2. Ping the backend while status is STATUS_VALIDATING/
      STATUS_PREPARING/STATUS_COMPILING/STATUS_EXECUTING
    Update example.status with resulting status.

    Args:
        example: beam example for processing and updating status and pipeline_id.
        client: client to send requests to the server.
    """
    datasets: List[api_pb2.Dataset] = []
    for emulator in example.tag.emulators:
        dataset: Dataset = example.tag.datasets[emulator.topic.source_dataset]

        datasets.append(
            api_pb2.Dataset(
                type=api_pb2.EmulatorType.Value(
                    f"EMULATOR_TYPE_{emulator.type.upper()}"
                ),
                options={"topic": emulator.topic.id},
                dataset_path=dataset.file_name,
            )
        )
    files: List[api_pb2.SnippetFile] = [
        api_pb2.SnippetFile(name=example.filepath, content=example.code, is_main=True)
    ]
    for file in example.tag.files:
        files.append(
            api_pb2.SnippetFile(name=file.name, content=file.content, is_main=False)
        )

    pipeline_id = await client.run_code(
        example.code, example.sdk, example.tag.pipeline_options, datasets, files=files,
    )
    example.pipeline_id = pipeline_id
    status = await client.check_status(pipeline_id)
    while status in [
        STATUS_VALIDATING,
        STATUS_PREPARING,
        STATUS_COMPILING,
        STATUS_EXECUTING,
    ]:
        await asyncio.sleep(Config.PAUSE_DELAY)
        status = await client.check_status(pipeline_id)
    example.status = status


def _get_object_type(filename, filepath):
    """
    Get type of an object based on it filename/filepath

    Args:
        filename: object's filename
        filepath: object's filepath

    Returns: type of the object (example, kata, unit-test)
    """
    filename_no_ext = (os.path.splitext(filename)[0]).lower()
    if filename_no_ext.endswith(PrecompiledExampleType.test_ends):
        object_type = PRECOMPILED_OBJECT_TYPE_UNIT_TEST
    elif PrecompiledExampleType.katas in filepath.split(os.sep):
        object_type = PRECOMPILED_OBJECT_TYPE_KATA
    elif PrecompiledExampleType.examples in filepath.split(os.sep):
        object_type = PRECOMPILED_OBJECT_TYPE_EXAMPLE
    else:
        object_type = PRECOMPILED_OBJECT_TYPE_UNSPECIFIED
    return object_type


class DuplicatesError(Exception):
    pass


def validate_examples_for_duplicates_by_name(examples: List[Example]):
    """
    Validate examples for duplicates by example name to avoid duplicates in the Cloud Datastore
    :param examples: examples from the repository for saving to the Cloud Datastore
    """
    duplicates: Dict[str, Example] = {}
    for example in examples:
        if example.tag.name not in duplicates.keys():
            duplicates[example.tag.name] = example
        else:
            err_msg = f"Examples have duplicate names.\nDuplicates: \n - path #1: {duplicates[example.tag.name].filepath} \n - path #2: {example.filepath}"
            logging.error(err_msg)
            raise DuplicatesError(err_msg)
