# encoding: utf-8

# Modified by Devin Zhang, 2025-10-27
# This file is part of a project based on httprunner/httprunner.py,
# licensed under the Apache License 2.0.

import collections
try:
    # Python 3.10+ 中一些collections类型被移动到 collections.abc
    from collections.abc import Iterable
    collections_deque = collections.deque
except ImportError:
    # Python < 3.10 兼容性
    from collections import Iterable
    collections_deque = collections.deque
import copy
import io
import itertools
import json
import os.path
import re
import uuid
from datetime import datetime

import requests
import sentry_sdk

from apimeter import __version__, exceptions, logger
from apimeter.compat import basestring, bytes, is_py2
from apimeter.exceptions import ParamsError

absolute_http_url_regexp = re.compile(r"^https?://", re.I)


def init_sentry_sdk():
    sentry_sdk.init(
        dsn="https://aa296f8dafe9c785593e318d44cc0ca9@o4510260785053696.ingest.us.sentry.io/4510260797177856",
        release="apimeter@{}".format(__version__),
    )

    with sentry_sdk.configure_scope() as scope:
        scope.set_user({"id": uuid.getnode()})


class GAClient(object):

    version = "1"  # GA API Version
    report_url = "https://www.google-analytics.com/collect"
    report_debug_url = (
        "https://www.google-analytics.com/debug/collect"  # used for debug
    )

    def __init__(self, tracking_id):
        self.http_client = requests.Session()
        self.label = str(__version__)
        self.common_params = {
            "v": self.version,
            "tid": tracking_id,  # Tracking ID / Property ID, XX-XXXXXXX-X
            "cid": uuid.getnode(),  # Anonymous Client ID
            "ua": "HttpRunner/{}".format(__version__),
        }

    def track_event(self, category, action, value=0):
        data = {
            "t": "event",  # Event hit type = event
            "ec": category,  # Required. Event Category.
            "ea": action,  # Required. Event Action.
            "el": self.label,  # Optional. Event label, used as version.
            "ev": value,  # Optional. Event value, must be non-negative integer
        }
        data.update(self.common_params)
        try:
            self.http_client.post(self.report_url, data=data, timeout=5)
        except Exception:  # ProxyError, SSLError, ConnectionError
            pass

    def track_user_timing(self, category, variable, duration):
        data = {
            "t": "timing",  # Event hit type = timing
            "utc": category,  # Required. user timing category. e.g. jsonLoader
            "utv": variable,  # Required. timing variable. e.g. load
            "utt": duration,  # Required. time took duration.
            "utl": self.label,  # Optional. user timing label, used as version.
        }
        data.update(self.common_params)
        try:
            self.http_client.post(self.report_url, data=data, timeout=5)
        except Exception:  # ProxyError, SSLError, ConnectionError
            pass


ga_client = GAClient("UA-114587036-1")


def set_os_environ(variables_mapping):
    """set variables mapping to os.environ"""
    for variable in variables_mapping:
        os.environ[variable] = variables_mapping[variable]
        logger.log_debug("Set OS environment variable: {}".format(variable))


def unset_os_environ(variables_mapping):
    """set variables mapping to os.environ"""
    for variable in variables_mapping:
        os.environ.pop(variable)
        logger.log_debug("Unset OS environment variable: {}".format(variable))


def get_os_environ(variable_name):
    """get value of environment variable.

    Args:
        variable_name(str): variable name

    Returns:
        value of environment variable.

    Raises:
        exceptions.EnvNotFound: If environment variable not found.

    """
    try:
        return os.environ[variable_name]
    except KeyError:
        raise exceptions.EnvNotFound(variable_name)


def build_url(base_url, path):
    """prepend url with base_url unless it's already an absolute URL"""
    if absolute_http_url_regexp.match(path):
        return path
    elif base_url:
        return "{}/{}".format(base_url.rstrip("/"), path.lstrip("/"))
    else:
        raise ParamsError("base url missed!")


def query_json(json_content, query, delimiter="."):
    """Do an xpath-like query with json_content.

    Args:
        json_content (dict/list/string): content to be queried.
        query (str): query string.
        delimiter (str): delimiter symbol.

    Returns:
        str: queried result.

    Examples:
        >>> json_content = {
            "ids": [1, 2, 3, 4],
            "person": {
                "name": {
                    "first_name": "Leo",
                    "last_name": "Lee",
                },
                "age": 29,
                "cities": ["Guangzhou", "Shenzhen"]
            }
        }
        >>>
        >>> query_json(json_content, "person.name.first_name")
        >>> Leo
        >>>
        >>> query_json(json_content, "person.name.first_name.0")
        >>> L
        >>>
        >>> query_json(json_content, "person.cities.0")
        >>> Guangzhou

    """
    raise_flag = False
    response_body = "response body: {}\n".format(json_content)
    try:
        for key in query.split(delimiter):
            if isinstance(json_content, (list, basestring)):
                json_content = json_content[int(key)]
            elif isinstance(json_content, dict):
                json_content = json_content[key]
            else:
                logger.log_error(
                    "invalid type value: {}({})".format(
                        json_content, type(json_content)
                    )
                )
                raise_flag = True
    except (KeyError, ValueError, IndexError):
        raise_flag = True

    if raise_flag:
        err_msg = "Failed to extract! => {}\n".format(query)
        err_msg += response_body
        logger.log_error(err_msg)
        raise exceptions.ExtractFailure(err_msg)

    return json_content


def lower_dict_keys(origin_dict):
    """convert keys in dict to lower case

    Args:
        origin_dict (dict): mapping data structure

    Returns:
        dict: mapping with all keys lowered.

    Examples:
        >>> origin_dict = {
            "Name": "",
            "Request": "",
            "URL": "",
            "METHOD": "",
            "Headers": "",
            "Data": ""
        }
        >>> lower_dict_keys(origin_dict)
            {
                "name": "",
                "request": "",
                "url": "",
                "method": "",
                "headers": "",
                "data": ""
            }

    """
    if not origin_dict or not isinstance(origin_dict, dict):
        return origin_dict

    return {key.lower(): value for key, value in origin_dict.items()}


def lower_test_dict_keys(test_dict):
    """convert keys in test_dict to lower case, convertion will occur in two places:
    1, all keys in test_dict;
    2, all keys in test_dict["request"]
    """
    # convert keys in test_dict
    test_dict = lower_dict_keys(test_dict)

    if "request" in test_dict:
        # convert keys in test_dict["request"]
        test_dict["request"] = lower_dict_keys(test_dict["request"])

    return test_dict


def deepcopy_dict(data):
    """deepcopy dict data, ignore file object (_io.BufferedReader)

    Args:
        data (dict): dict data structure
            {
                'a': 1,
                'b': [2, 4],
                'c': lambda x: x+1,
                'd': open('LICENSE'),
                'f': {
                    'f1': {'a1': 2},
                    'f2': io.open('LICENSE', 'rb'),
                }
            }

    Returns:
        dict: deep copied dict data, with file object unchanged.

    """
    try:
        return copy.deepcopy(data)
    except TypeError:
        copied_data = {}
        for key, value in data.items():
            if isinstance(value, dict):
                copied_data[key] = deepcopy_dict(value)
            else:
                try:
                    copied_data[key] = copy.deepcopy(value)
                except TypeError:
                    copied_data[key] = value

        return copied_data


def ensure_mapping_format(variables):
    """ensure variables are in mapping format.

    Args:
        variables (list/dict): original variables

    Returns:
        dict: ensured variables in dict format

    Examples:
        >>> variables = [
                {"a": 1},
                {"b": 2}
            ]
        >>> print(ensure_mapping_format(variables))
            {
                "a": 1,
                "b": 2
            }

    """
    if isinstance(variables, list):
        variables_dict = {}
        for map_dict in variables:
            variables_dict.update(map_dict)

        return variables_dict

    elif isinstance(variables, dict):
        return variables

    else:
        raise exceptions.ParamsError("variables format error!")


def extend_variables(raw_variables, override_variables):
    """extend raw_variables with override_variables.
        override_variables will merge and override raw_variables.

    Args:
        raw_variables (list):
        override_variables (list):

    Returns:
        dict: extended variables mapping

    Examples:
        >>> raw_variables = [{"var1": "val1"}, {"var2": "val2"}]
        >>> override_variables = [{"var1": "val111"}, {"var3": "val3"}]
        >>> extend_variables(raw_variables, override_variables)
            {
                'var1', 'val111',
                'var2', 'val2',
                'var3', 'val3'
            }

    """
    if not raw_variables:
        override_variables_mapping = ensure_mapping_format(override_variables)
        return override_variables_mapping

    elif not override_variables:
        raw_variables_mapping = ensure_mapping_format(raw_variables)
        return raw_variables_mapping

    else:
        raw_variables_mapping = ensure_mapping_format(raw_variables)
        override_variables_mapping = ensure_mapping_format(override_variables)
        raw_variables_mapping.update(override_variables_mapping)
        return raw_variables_mapping


def get_testcase_io(testcase):
    """get and print testcase input(variables) and output(export).

    Args:
        testcase (unittest.suite.TestSuite): corresponding to one YAML/JSON file, it has been set two attributes:
            config: parsed config block
            runner: initialized runner.Runner() with config
    Returns:
        dict: input(variables) and output mapping.

    """
    test_runner = testcase.runner
    variables = testcase.config.get("variables", {})
    output_list = testcase.config.get("export") or testcase.config.get("output", [])
    export_mapping = test_runner.export_variables(output_list)

    return {"in": variables, "out": export_mapping}


def print_info(info_mapping):
    """print info in mapping.

    Args:
        info_mapping (dict): input(variables) or output mapping.

    Examples:
        >>> info_mapping = {
                "var_a": "hello",
                "var_b": "world"
            }
        >>> info_mapping = {
                "status_code": 500
            }
        >>> print_info(info_mapping)
        ==================== Output ====================
        Key              :  Value
        ---------------- :  ----------------------------
        var_a            :  hello
        var_b            :  world
        ------------------------------------------------

    """
    if not info_mapping:
        return

    content_format = "{:<16} : {:<}\n"
    content = "\n==================== Output ====================\n"
    content += content_format.format("Variable", "Value")
    content += content_format.format("-" * 16, "-" * 29)

    for key, value in info_mapping.items():
        if isinstance(value, (tuple, collections_deque)):
            continue
        elif isinstance(value, (dict, list)):
            value = json.dumps(value)
        elif value is None:
            value = "None"

        if is_py2:
            if isinstance(key, unicode):
                key = key.encode("utf-8")
            if isinstance(value, unicode):
                value = value.encode("utf-8")

        content += content_format.format(key, value)

    content += "-" * 48 + "\n"
    logger.log_info(content)


def create_scaffold(project_name):
    """create scaffold with specified project name."""
    if os.path.isdir(project_name):
        logger.log_warning(
            "Folder {} exists, please specify a new folder name.".format(project_name)
        )
        return

    ga_client.track_event("Scaffold", "startproject")
    logger.color_print("Start to create new project: {}".format(project_name), "GREEN")
    logger.color_print("CWD: {}\n".format(os.getcwd()), "BLUE")

    def create_folder(path):
        os.makedirs(path)
        msg = "created folder: {}".format(path)
        logger.color_print(msg, "BLUE")

    def create_file(path, file_content=""):
        with open(path, "w") as f:
            f.write(file_content)
        msg = "created file: {}".format(path)
        logger.color_print(msg, "BLUE")

    demo_api_content = """
name: demo api
variables:
    var1: value1
    var2: value2
request:
    url: /api/path/$var1
    method: POST
    headers:
        Content-Type: "application/json"
    json:
        key: $var2
validate:
    - eq: ["status_code", 200]
"""
    demo_testcase_content = """
config:
    name: "demo testcase"
    variables:
        device_sn: "ABC"
        username: ${ENV(USERNAME)}
        password: ${ENV(PASSWORD)}
    base_url: "http://127.0.0.1:5000"

teststeps:
-
    name: demo step 1
    api: path/to/api1.yml
    variables:
        user_agent: 'iOS/10.3'
        device_sn: $device_sn
    extract:
        - token: content.token
    validate:
        - eq: ["status_code", 200]
-
    name: demo step 2
    api: path/to/api2.yml
    variables:
        token: $token
"""
    demo_testsuite_content = """
config:
    name: "demo testsuite"
    variables:
        device_sn: "XYZ"
    base_url: "http://127.0.0.1:5000"

testcases:
-
    name: call demo_testcase with data 1
    testcase: path/to/demo_testcase.yml
    variables:
        device_sn: $device_sn
-
    name: call demo_testcase with data 2
    testcase: path/to/demo_testcase.yml
    variables:
        device_sn: $device_sn
"""
    ignore_content = "\n".join(
        [".env", "reports/*", "__pycache__/*", "*.pyc", ".python-version", "logs/*"]
    )
    demo_debugtalk_content = """
import time

def sleep(n_secs):
    time.sleep(n_secs)
"""
    demo_env_content = "\n".join(["USERNAME=leolee", "PASSWORD=123456"])

    create_folder(project_name)
    create_folder(os.path.join(project_name, "api"))
    create_folder(os.path.join(project_name, "testcases"))
    create_folder(os.path.join(project_name, "testsuites"))
    create_folder(os.path.join(project_name, "reports"))
    create_file(os.path.join(project_name, "api", "demo_api.yml"), demo_api_content)
    create_file(
        os.path.join(project_name, "testcases", "demo_testcase.yml"),
        demo_testcase_content,
    )
    create_file(
        os.path.join(project_name, "testsuites", "demo_testsuite.yml"),
        demo_testsuite_content,
    )
    create_file(os.path.join(project_name, "debugtalk.py"), demo_debugtalk_content)
    create_file(os.path.join(project_name, ".env"), demo_env_content)
    create_file(os.path.join(project_name, ".gitignore"), ignore_content)


def gen_cartesian_product(*args):
    """generate cartesian product for lists

    Args:
        args (list of list): lists to be generated with cartesian product

    Returns:
        list: cartesian product in list

    Examples:

        >>> arg1 = [{"a": 1}, {"a": 2}]
        >>> arg2 = [{"x": 111, "y": 112}, {"x": 121, "y": 122}]
        >>> args = [arg1, arg2]
        >>> gen_cartesian_product(*args)
        >>> # same as below
        >>> gen_cartesian_product(arg1, arg2)
            [
                {'a': 1, 'x': 111, 'y': 112},
                {'a': 1, 'x': 121, 'y': 122},
                {'a': 2, 'x': 111, 'y': 112},
                {'a': 2, 'x': 121, 'y': 122}
            ]

    """
    if not args:
        return []
    elif len(args) == 1:
        return args[0]

    product_list = []
    for product_item_tuple in itertools.product(*args):
        product_item_dict = {}
        for item in product_item_tuple:
            product_item_dict.update(item)

        product_list.append(product_item_dict)

    return product_list


def prettify_json_file(file_list):
    """prettify JSON testcase format"""
    for json_file in set(file_list):
        if not json_file.endswith(".json"):
            logger.log_warning(
                "Only JSON file format can be prettified, skip: {}".format(json_file)
            )
            continue

        logger.color_print("Start to prettify JSON file: {}".format(json_file), "GREEN")

        dir_path = os.path.dirname(json_file)
        file_name, file_suffix = os.path.splitext(os.path.basename(json_file))
        outfile = os.path.join(dir_path, "{}.pretty.json".format(file_name))

        with io.open(json_file, "r", encoding="utf-8") as stream:
            try:
                obj = json.load(stream)
            except ValueError as e:
                raise SystemExit(e)

        with io.open(outfile, "w", encoding="utf-8") as out:
            json.dump(obj, out, indent=4, separators=(",", ": "))
            out.write("\n")

        print("success: {}".format(outfile))


def omit_long_data(body, omit_len=512):
    """omit too long str/bytes"""
    if not isinstance(body, basestring):
        return body

    body_len = len(body)
    if body_len <= omit_len:
        return body

    omitted_body = body[0:omit_len]

    appendix_str = " ... OMITTED {} CHARACTORS ...".format(body_len - omit_len)
    if isinstance(body, bytes):
        appendix_str = appendix_str.encode("utf-8")

    return omitted_body + appendix_str


def dump_json_file(json_data, json_file_abs_path):
    """dump json data to file"""

    class PythonObjectEncoder(json.JSONEncoder):
        def default(self, obj):
            try:
                return super().default(self, obj)
            except TypeError:
                return str(obj)

    file_foder_path = os.path.dirname(json_file_abs_path)
    if not os.path.isdir(file_foder_path):
        os.makedirs(file_foder_path)

    try:
        with io.open(json_file_abs_path, "w", encoding="utf-8") as outfile:
            if is_py2:
                outfile.write(
                    unicode(
                        json.dumps(
                            json_data,
                            indent=4,
                            separators=(",", ":"),
                            encoding="utf8",
                            ensure_ascii=False,
                            cls=PythonObjectEncoder,
                        )
                    )
                )
            else:
                json.dump(
                    json_data,
                    outfile,
                    indent=4,
                    separators=(",", ":"),
                    ensure_ascii=False,
                    cls=PythonObjectEncoder,
                )

        msg = "dump file: {}".format(json_file_abs_path)
        logger.color_print(msg, "BLUE")

    except TypeError as ex:
        msg = "Failed to dump json file: {}\nReason: {}".format(json_file_abs_path, ex)
        logger.color_print(msg, "RED")


def prepare_dump_json_file_abs_path(project_mapping, tag_name):
    """prepare dump json file absolute path."""
    pwd_dir_path = project_mapping.get("PWD") or os.getcwd()
    test_path = project_mapping.get("test_path")

    if not test_path:
        # running passed in testcase/testsuite data structure
        dump_file_name = "tests_mapping.{}.json".format(tag_name)
        dumped_json_file_abs_path = os.path.join(pwd_dir_path, "logs", dump_file_name)
        return dumped_json_file_abs_path

    # both test_path and pwd_dir_path are absolute path
    logs_dir_path = os.path.join(pwd_dir_path, "logs")
    test_path_relative_path = test_path[len(pwd_dir_path) + 1 :]

    if os.path.isdir(test_path):
        file_foder_path = os.path.join(logs_dir_path, test_path_relative_path)
        dump_file_name = "all.{}.json".format(tag_name)
    else:
        file_relative_folder_path, test_file = os.path.split(test_path_relative_path)
        file_foder_path = os.path.join(logs_dir_path, file_relative_folder_path)
        test_file_name, _file_suffix = os.path.splitext(test_file)
        dump_file_name = "{}.{}.json".format(test_file_name, tag_name)

    dumped_json_file_abs_path = os.path.join(file_foder_path, dump_file_name)
    return dumped_json_file_abs_path


def dump_logs(json_data, project_mapping, tag_name):
    """dump tests data to json file.
        the dumped file is located in PWD/logs folder.

    Args:
        json_data (list/dict): json data to dump
        project_mapping (dict): project info
        tag_name (str): tag name, loaded/parsed/summary

    """
    json_file_abs_path = prepare_dump_json_file_abs_path(project_mapping, tag_name)
    dump_json_file(json_data, json_file_abs_path)


def get_python2_retire_msg():
    retire_day = datetime(2020, 1, 1)
    today = datetime.now()
    left_days = (retire_day - today).days

    if left_days > 0:
        retire_msg = (
            "Python 2 will retire in {} days, why not move to Python 3?".format(
                left_days
            )
        )
    else:
        retire_msg = "Python 2 has been retired, you should move to Python 3."

    return retire_msg
