import json
from functools import partial
from typing import Dict, Mapping, Callable, Any
from urllib.parse import urljoin

from skivvy.brace_expansion import brace_expand_string
from skivvy.skivvy_config2 import Settings, conf_get
from skivvy.util import dict_util, log, str_util, file_util
from skivvy.util.dict_util import get_all, subset


def create_request(testcase: Mapping[str, object]) -> tuple[dict, dict]:
    """
    Validates a test case and returns a request-dict and a testconfig-dict.
    The request-dict can be passed directly to execute while the testconfig-dict determines the behavior of the test (expected status, fields, brace expansion and so on).
    """
    # TODO: Before we reach here we should have just already have this dict filled with
    # TODO: the necessary values by just iterating over the dict and for each option either it would have a default or we would raise a validation exception
    required_fields = (Settings.BASE_URL.key, Settings.URL.key, Settings.METHOD.key)
    base_url, url, method = dict_util.get_all(testcase, *required_fields)
    url = urljoin(base_url, url)
    testcase[Settings.URL.key] = url
    log.debug(f"Creating request {method}: {url}")

    # apply for these fields, the ones not present will just be ignored
    options = (
        Settings.URL,
        Settings.BODY,
        Settings.READ_HEADERS,
        Settings.WRITE_HEADERS,
        Settings.HEADERS,
    )
    fields_to_expand = [opt.key for opt in options]

    # in either case, we get back a dict that represents all configuration related to the request
    request_config = brace_expand_fields(testcase, *fields_to_expand)
    # validate and warn if the request looks odd
    is_valid = validate_request_body(request_config)
    if not is_valid:
        log.warning(f"Request does not seem valid: {method, url}")

    request_fields = [
        Settings.METHOD,
        Settings.URL,
        Settings.QUERY,
        Settings.BODY,
        Settings.FORM,
        Settings.UPLOAD,
        Settings.HEADERS,
    ]
    headers = build_request_headers(request_config)
    if headers:
        request_config[Settings.HEADERS.key] = headers

    request_data = subset(
        request_config, [option.key for option in request_fields], include_none=False
    )

    return request_data, request_config


def validate_request_body(d):
    """Validates that the body data makes sense for the HTTP method"""
    url, method = get_all(d, Settings.URL.key, Settings.METHOD.key)

    json_data = conf_get(d, Settings.BODY)
    form_data = conf_get(d, Settings.FORM)
    upload = conf_get(d, Settings.UPLOAD)

    # TODO: stop lowering everywhere
    match method.lower():
        case "post" | "put" | "patch" | "delete":
            # These methods can have body data - validate no conflicts
            body_fields = [json_data, form_data, upload]
            match body_fields:
                case [None, None, None]:
                    log.debug(f"No data provided for {method} {url}")
                case [json_data, None, None]:
                    log.debug(f"{method} {url} (JSON payload)")
                case [None, form_data, None]:
                    log.debug(f"{method} {url} (multi-form payload)")
                case [None, None, upload]:
                    log.debug(f"{method} {url} (file upload)")
                case _:
                    log.warning(f"Multiple body data for {method} {url}")
                    log.warning(f"json: {json_data} form: {form_data} upload: {upload}")
                    return False
        case "get" | "delete" | "head" | "options":
            # These methods shouldn't have body data
            if any([json_data, form_data, upload]):
                log.warning(
                    f"Multiple body data for {method} {url} - this may cause issues"
                )
                return False
        case _:
            raise ValueError(f"Unsupported HTTP method: {method}")
    return True


def brace_expand_fields(
    request_dict: Mapping[str, object], *keys: str
) -> Dict[str, object]:
    """
    Takes a list of keys and applies brace expansion for each key it finds, others are silently ignored.
    Returns a new dict, all the other entries as well.
    If is_enabled is False, then it will simply just return a new dict without any expansion applied.
    """
    expand_func = get_brace_expansion_func(request_dict)
    result = json.loads(json.dumps(dict(request_dict)))

    for k in keys:
        field = result.get(k)
        if field:
            field = dict_util.map_nested_dicts_py(field, expand_func)
            result[k] = field
    return result


def get_brace_expansion_func(config: Mapping[str, object]) -> Callable:
    if conf_get(config, Settings.AUTO_COERCE):
        auto_coerce_func = auto_coercer
    else:
        auto_coerce_func = auto_coercer_noop

    if conf_get(config, Settings.BRACE_EXPANSION):
        brace_expand_func = partial(
            brace_expand_string, auto_coerce_func=auto_coerce_func
        )
    else:
        brace_expand_func = brace_expander_noop

    return brace_expand_func


def auto_coercer(s):
    return str_util.coerce_str_to_int(s)


# identity function, when auto coercion is not enabled
def auto_coercer_noop(s):
    return s


def brace_expander(s, **kwargs):
    return brace_expand_string(s, **kwargs)


# identity function, when brace expansion is not enabled
def brace_expander_noop(s, **_kwargs):
    return s


def build_request_headers(request_config: Mapping[str, Any]) -> dict[str, str] | None:
    headers: dict[str, str] = {}
    headers_from_file = load_headers(conf_get(request_config, Settings.READ_HEADERS))
    headers.update(headers_from_file)

    inline_headers = conf_get(request_config, Settings.HEADERS) or {}
    if inline_headers:
        if not isinstance(inline_headers, Mapping):
            raise TypeError("headers must be a mapping (got %s)" % type(inline_headers))
        headers.update(inline_headers)

    maybe_add_content_type(headers, request_config)
    return headers or None


def load_headers(source: Any) -> dict[str, str]:
    if source is None:
        return {}
    if isinstance(source, Mapping):
        return dict(source)
    if isinstance(source, str):
        return file_util.parse_json(source)
    raise TypeError("read_headers must be a string (path) or mapping (got %s)" % type(source))


def header_exists(headers: dict[str, str], name: str) -> bool:
    return any(k.lower() == name.lower() for k in headers.keys())


def maybe_add_content_type(headers: dict[str, str], request_config: Mapping[str, Any]) -> None:
    has_payload = any(
        [
            conf_get(request_config, Settings.BODY),
            conf_get(request_config, Settings.FORM),
        ]
    )
    has_upload = conf_get(request_config, Settings.UPLOAD)
    if not has_payload or has_upload:
        return

    content_type = conf_get(request_config, Settings.CONTENT_TYPE)
    if content_type and not header_exists(headers, "content-type"):
        headers["Content-Type"] = content_type
