import re
import requests
import keyword
from collections import namedtuple
from munch import Munch
import logging

from aeknow.exceptions import UnsupportedNodeVersion, ConfigException
import semver

import json


class OpenAPIArgsException(Exception):
    """Raised when there is an error in method arguments"""

    def __init__(self, message):
        self.message = message


class OpenAPIClientException(Exception):
    """Raised when there is an error executing requests"""

    def __init__(self, message, code=500, data={}):
        self.message = message
        self.code = code
        self.data = data


class OpenAPIException(Exception):
    """Raised when there is an error executing requests"""

    def __init__(self, message):
        self.message = message


class OpenAPICli(object):
    """
    Generates a OpenAPI client
    """
    # openapi versions (swagger 2.0 and openapi 3.x)
    open_api_versions = ["2.0", "3.0.0", "3.0.1", "3.0.2", "3.0.3", "3.1.0"]
    # type mapping
    oa2py_type = {
        "string": "str",
        "integer": "int",
        "boolean": "bool",
    }

    def __init__(self, url, url_internal=None, debug=False, compatibility_version_range=(None, None), force_compatibility=False):
        try:
            self.url, self.url_internal = url, url_internal
            self.skip_tags = set(["obsolete"])
            # load the openapi json file from the node
            api_reply = requests.get(f"{url}/api", timeout=10)
            
            # Pre-validate response before JSON parsing
            if api_reply.status_code != 200:
                raise ConfigException(
                    f"Node at {url} returned error response (status: {api_reply.status_code}). "
                    f"Please check if the node is running and accessible."
                )
            
            if not api_reply.text or len(api_reply.text.strip()) == 0:
                raise ConfigException(
                    f"Node at {url} returned empty response. "
                    f"Please verify the node URL is correct."
                )
            
            # Parse JSON with enhanced error handling
            try:
                self.api_def = api_reply.json()
            except (json.JSONDecodeError, requests.exceptions.JSONDecodeError) as e:
                raise ConfigException(
                    f"Node at {url} returned invalid JSON: {str(e)}. "
                    f"Is there an aeknow node running at this address?"
                )
            
            if self.api_def.get('api') is not None:  # TODO: workaround for different swagger styles
                self.api_def = self.api_def.get('api', {})
            self.api_version = self.api_def.get("info", {}).get("version", "unknown")
            
            # evaluate min version
            lower_bound = compatibility_version_range[0]
            match_min = True if lower_bound is None else semver.match(self.api_version, lower_bound)
            # evaluate max version
            upper_bound = compatibility_version_range[1]
            match_max = True if upper_bound is None else semver.match(self.api_version, upper_bound)
            # evaluate the version range
            if (not match_min or not match_max) and not force_compatibility:
                raise UnsupportedNodeVersion(
                    f"Unsupported node version {self.url}@{self.api_version}. "
                    f"Supported versions: {lower_bound} to {upper_bound}. "
                    f"Use force_compatibility=True to bypass this check (not recommended)."
                )
        except requests.exceptions.ConnectionError:
            raise ConfigException(
                f"Error connecting to the node at {self.url}. "
                f"Please check: 1) Node is running 2) URL is correct 3) Network connectivity"
            )
        except requests.exceptions.Timeout:
            raise ConfigException(
                f"Connection timeout to the node at {self.url}. "
                f"The node may be overloaded or network is slow."
            )
        except ConfigException:
            raise
        except UnsupportedNodeVersion:
            raise
        except Exception as e:
            raise ConfigException(f"Unexpected error connecting to node at {self.url}: {str(e)}")

        # enable printing debug messages
        # create logger
        self.logger = logging.getLogger(__name__)

        # check open_api_version (support both swagger 2.0 and openapi 3.x)
        api_version_key = self.api_def.get('swagger') or self.api_def.get('openapi')
        if api_version_key not in self.open_api_versions:
            raise OpenAPIException(f"Unsupported Open API specification version {api_version_key}")
        self.version = self.api_def.get('info', {}).get('version')

        # regexp to convert method signature to snake case
        first_cap_re = re.compile('(.)([A-Z][a-z]+)')
        all_cap_re = re.compile('([a-z0-9])([A-Z])')

        def p2s(name):
            s1 = first_cap_re.sub(r'\1_\2', name)
            return all_cap_re.sub(r'\1_\2', s1).lower()

        # prepare the baseurl
        base_path = self.api_def.get('basePath', '').rstrip('/')
        # OpenAPI 3.x uses 'servers' instead of 'basePath'
        if not base_path and 'servers' in self.api_def and len(self.api_def['servers']) > 0:
            base_path = self.api_def['servers'][0].get('url', '').rstrip('/')
        
        self.base_url = f"{url}{base_path}"
        self.base_url_internal = f"{url_internal}{base_path}"
        self.logger.debug(f"Internal URL {url_internal}, {type(url_internal)}, {(url_internal is None)}, {self.base_url_internal}")
        if url_internal is None:
            # do not build internal endpoints
            self.skip_tags.add("internal")
        else:
            self.base_url_internal = f"{url_internal}{base_path}"

        # parse the api
        # definition of a field
        FieldDef = namedtuple("FieldDef", ["required", "type", "values", "minimum", "maximum", "default"])
        Param = namedtuple("Param", ["name", "raw", "pos", "field"])
        Resp = namedtuple("Resp", ["schema", "desc"])
        Api, self.api_methods = namedtuple("Api", ["name", "doc", "params", "responses", "endpoint", "http_method"]), []

        for query_path, path in self.api_def.get("paths").items():
            for m, func in path.items():
                func_tags = func.get("tags", [])
                # exclude the paths/method tagged with skip_tags
                if len(func_tags) > 0 and not self.skip_tags.isdisjoint(func_tags):
                    continue
                # get if is an internal or external endpoint
                endpoint_url = self.base_url_internal if "internal" in func_tags else self.base_url
                api = Api(
                    name=p2s(func.get('operationId')),
                    doc=func.get("description"),
                    params=[],
                    responses={},
                    endpoint=f"{endpoint_url}{query_path}",
                    http_method=m,

                )
                # collect the parameters
                for p in func.get('parameters', []):
                    # check if it is a reference, and resolve it if it is
                    if "$ref" in p:
                        # Support both OpenAPI 2.0 (#/parameters/HeightIn) and 3.x (#/components/parameters/accountPubkey)
                        ref_path = p.get("$ref")[2:].split("/")
                        # Navigate through the path to get the referenced object
                        resolved = self.api_def
                        for part in ref_path:
                            resolved = resolved.get(part, {})
                        p = resolved
                    
                    # Skip if parameter resolution failed
                    if not p or not p.get("name"):
                        continue
                    
                    param_name = p.get("name")
                    param_pos = p.get("in")
                    
                    # Get type - support both OpenAPI 2.0 (direct type) and 3.x (schema.type)
                    param_type = p.get("type")
                    if not param_type and "schema" in p:
                        schema = p.get("schema", {})
                        param_type = schema.get("type", schema.get("$ref", ""))
                    
                    # Get default value - support both OpenAPI 2.0 and 3.x
                    param_default = p.get("default")
                    if param_default is None and "schema" in p:
                        param_default = p.get("schema", {}).get("default")
                    
                    # check if the param name is a reserved keyword
                    if keyword.iskeyword(param_name):
                        if param_pos == 'path':
                            api.query_path = api.query_path.replace("{%s}" % param_name, "{_%s}" % param_name)
                        param_name = f"_{param_name}"

                    param = Param(
                        name=param_name,
                        raw=p.get("name"),
                        pos=param_pos,
                        field=FieldDef(
                            required=p.get("required"),
                            type=param_type,
                            values=p.get("enum", []),
                            default=param_default,
                            minimum=p.get("minimum"),
                            maximum=p.get("maximum"))
                    )
                    api.params.append(param)

                # Handle OpenAPI 3.x requestBody
                if 'requestBody' in func:
                    request_body = func.get('requestBody', {})
                    is_required = request_body.get('required', False)
                    # Get schema type from content
                    content = request_body.get('content', {})
                    body_type = ""
                    if 'application/json' in content:
                        schema = content['application/json'].get('schema', {})
                        body_type = schema.get('$ref', schema.get('type', ''))
                    
                    body_param = Param(
                        name='body',
                        raw='body',
                        pos='body',
                        field=FieldDef(
                            required=is_required,
                            type=body_type,
                            values=[],
                            default=None,
                            minimum=None,
                            maximum=None)
                    )
                    api.params.append(body_param)

                # responses
                for code, r in func.get('responses', {}).items():
                    # OpenAPI 2.0 has schema in response directly
                    # OpenAPI 3.x has schema in content.application/json.schema
                    schema_ref = ""
                    if "schema" in r:
                        # OpenAPI 2.0 style
                        schema_ref = r.get("schema", {}).get("$ref", "")
                    elif "content" in r:
                        # OpenAPI 3.x style
                        content = r.get("content", {})
                        if "application/json" in content:
                            schema_ref = content["application/json"].get("schema", {}).get("$ref", "")
                    
                    # Remove the #/components/schemas/ or #/definitions/ prefix
                    schema_name = schema_ref.replace("#/components/schemas/", "").replace("#/definitions/", "")
                    if not schema_name:
                        schema_name = "inline_response_200"
                    
                    api.responses[int(code)] = Resp(
                        desc=r.get("description"),
                        schema=schema_name
                    )
                # create the method
                self._add_api_method(api)

    def _add_api_method(self, api):
        """add an api method to the client"""
        def api_method(*args, **kwargs):
            query_params = {}
            post_body = {}
            target_endpoint = api.endpoint
            for p in api.params:
                # get the value or default
                val, ok = self._get_param_val(kwargs, p)
                if not ok:
                    raise OpenAPIArgsException(f"missing required parameter {p.name}")
                # if none continue
                if val is None:
                    continue
                # check the type (skip for body parameters and schema references)
                if p.field.type and p.field.type.startswith("#/"):
                    # Schema reference - skip type validation
                    pass
                elif p.pos == 'body':
                    # Body parameter - skip type validation
                    pass
                elif not self._is_valid_type(val, p.field):
                    raise OpenAPIArgsException(f"type error for parameter {p.name}, expected: {p.field.type} got {type(val).__name__}", )
                # check the ranges
                if not self._is_valid_interval(val, p.field):
                    raise OpenAPIArgsException(f"value error for parameter {p.name}, expected: {p.minimum} =< {val} =< {p.maximum}", )
                # check allowed values
                if len(p.field.values) > 0 and val not in p.field.values:
                    raise OpenAPIArgsException(f"Invalid value for param {p.name}, allowed values are {','.join(p.values)}")
                # if in path substitute
                if p.pos == 'path':
                    target_endpoint = target_endpoint.replace('{%s}' % p.name, str(val))
                # if in query add to the query
                if p.pos == 'query':
                    query_params[p.raw] = val
                if p.pos == 'body':
                    post_body = val
            # make the request
            if api.http_method == 'get':
                http_reply = requests.get(target_endpoint, params=query_params, timeout=30)
                api_response = api.responses.get(http_reply.status_code, None)
                self.logger.debug(f"GET {target_endpoint}, params:{query_params} --> {http_reply.text}")
            else:
                http_reply = requests.post(target_endpoint, params=query_params, json=post_body, timeout=3000)
                api_response = api.responses.get(http_reply.status_code, None)
                self.logger.debug(f"POST {target_endpoint}, params:{query_params}, body: {post_body} --> {http_reply.text}", )
            
            # unknown error
            if api_response is None:
                # Try to extract error message from response
                error_msg = f"Unknown error {target_endpoint} {http_reply.status_code}"
                try:
                    error_data = http_reply.json()
                    # Extract reason from nested error structure
                    if isinstance(error_data, dict):
                        reason = error_data.get('reason') or error_data.get('message') or http_reply.text
                        error_msg = f"{error_msg} - {reason}"
                    else:
                        error_msg = f"{error_msg} - {http_reply.text}"
                except:
                    error_msg = f"{error_msg} - {http_reply.text}"
                raise OpenAPIClientException(error_msg, code=http_reply.status_code)
            
            # success
            if http_reply.status_code == 200:
                # Check for empty response
                if not http_reply.text or len(http_reply.text.strip()) == 0:
                    if len(api_response.schema) == 0:
                        return {}
                    raise OpenAPIClientException(f"Empty response from {target_endpoint}", code=200)
                
                # parse the http_reply
                try:
                    if len(api_response.schema) == 0:
                        return {}
                    
                    jr = http_reply.json()
                    
                    # For simple wrapper responses (like HeightResponse, HashResponse, etc.)
                    # that contain only a single value, return that value directly
                    # This handles both OpenAPI 2.0 inline_response_200 and 3.x named schemas
                    if isinstance(jr, dict) and len(jr) == 1:
                        single_value = list(jr.values())[0]
                        # Return primitive values directly, keep complex objects as Munch
                        if isinstance(single_value, (int, float, str, bool)):
                            return single_value
                    
                    # Return as Munch object for easy attribute access
                    return Munch.fromDict(jr)
                except (json.JSONDecodeError, requests.exceptions.JSONDecodeError) as e:
                    raise OpenAPIClientException(
                        f"Invalid JSON response from {target_endpoint}: {str(e)}",
                        code=200
                    )
            
            # error - extract detailed error information
            error_msg = f"{api_response.desc}"
            error_data = {}
            try:
                error_data = http_reply.json()
                # Try to extract reason from error structure (similar to Go SDK)
                if isinstance(error_data, dict):
                    reason = error_data.get('reason') or error_data.get('message')
                    if reason:
                        error_msg = f"{error_msg}: {reason}"
            except:
                pass
            
            raise OpenAPIClientException(error_msg, code=http_reply.status_code, data=error_data)
        # register the method
        api_method.__name__ = api.name
        api_method.__doc__ = api.doc
        setattr(self, api_method.__name__, api_method)
        self.api_methods.append(api)

    def _is_valid_interval(self, val, field):
        is_ok = True
        if not isinstance(val, int):
            return is_ok
        if field.minimum is not None and val < field.minimum:
            is_ok = False
        if field.maximum is not None and val > field.maximum:
            is_ok = False
        return is_ok

    def _is_valid_type(self, val, field):
        py_type = self.oa2py_type.get(field.type, "unknown")
        if py_type != type(val).__name__:
            return False
        return True

    def _get_param_val(self, params, param):
        """
        get the parameter "name" from the parameters,
        raise an exception if the parameter is required and is None
        """
        val = params.get(param.name, param.field.default)
        val = val if val is not None else param.field.default
        # check required
        if param.field.required and val is None:
            return val, False
        return val, True

    def get_api_methods(self):
        """get the api methods registered by the client"""
        return self.api_methods

    def get_version(self):
        return self.version
