import json
import logging
from decimal import Decimal
from functools import lru_cache
from os.path import exists, expanduser
from typing import Any, Callable, Dict, List, Optional, Type, Union
from urllib.parse import urlparse

import requests
from cached_property import cached_property  # type: ignore
from deprecation import deprecated  # type: ignore

from pytezos.context.mixin import ContextMixin  # type: ignore
from pytezos.context.mixin import ExecutionContext
from pytezos.contract.data import ContractData
from pytezos.contract.entrypoint import ContractEntrypoint
from pytezos.contract.metadata import ContractMetadata
from pytezos.contract.result import ContractCallResult
from pytezos.contract.token_metadata import ContractTokenMetadata
from pytezos.contract.view import ContractView
from pytezos.crypto.key import Key
from pytezos.jupyter import get_class_docstring
from pytezos.logging import logger
from pytezos.michelson.format import micheline_to_michelson
from pytezos.michelson.micheline import MichelsonRuntimeError
from pytezos.michelson.parse import michelson_to_micheline
from pytezos.michelson.program import MichelsonProgram
from pytezos.michelson.sections import ViewSection
from pytezos.michelson.types import BigMapType, BytesType
from pytezos.michelson.types.base import generate_pydoc
from pytezos.operation.group import OperationGroup
from pytezos.rpc import ShellQuery


class ContractTokenMetadataProxy:
    """Get TZIP-21 contract token metadata by token_id"""

    def __init__(self, fn: Callable) -> None:
        self._fn = fn

    def __getitem__(self, item):
        return self._fn(item)


class ContractInterface(ContextMixin):
    """Proxy class for interacting with a contract."""

    program: MichelsonProgram

    def __init__(self, context: ExecutionContext) -> None:
        super().__init__(context=context)
        self._logger = logging.getLogger(__name__)
        self._storage: Optional[ContractData] = None
        self.entrypoints = self.program.parameter.list_entrypoints()
        self.views = {view.name: view for view in self.program.views}  # type: Dict[str, Type[ViewSection]]

        for entrypoint, ty in self.entrypoints.items():
            if entrypoint == 'token_metadata':
                continue
            attr = ContractEntrypoint(context=context, entrypoint=entrypoint)
            attr.__doc__ = generate_pydoc(ty, entrypoint)
            assert not hasattr(self, entrypoint), f'Entrypoint name collision {entrypoint}'
            setattr(self, entrypoint, attr)

        for view_name, view_ty in self.views.items():
            view_attr = ContractView(
                context=context,
                name=view_name,
                parameter=view_ty.args[1].as_micheline_expr(),
                return_type=view_ty.args[2].as_micheline_expr(),
                code=view_ty.args[3].as_micheline_expr(),  # type: ignore
            )
            view_attr.__doc__ = view_ty.generate_pydoc()  # type: ignore
            assert not hasattr(self, view_name), f'View name collision {view_name}'
            setattr(self, view_name, view_attr)

    def __repr__(self) -> str:
        res = [
            super().__repr__(),
            '.storage\t# access storage data at block `block_id`',
            '.parameter\t# root entrypoint',
            '\nEntrypoints',
            *list(map(lambda x: f'.{x}()', self.entrypoints)),
            '\nViews',
            *list(map(lambda x: f'.{x}()', self.views)),
            '\nHelpers',
            get_class_docstring(self.__class__, attr_filter=lambda x: x not in self.entrypoints),
        ]
        return '\n'.join(res)

    def __getattr__(self, item: str) -> ContractEntrypoint:
        raise AttributeError(f'unexpected entrypoint {item}')

    @staticmethod
    def from_url(url: str, context: Optional[ExecutionContext] = None) -> 'ContractInterface':
        """Create contract from michelson source code available via URL

        :param url: link to the Michelson file
        :param context: optional execution context
        :rtype: ContractInterface
        """
        res = requests.get(url)
        if res.status_code != 200:
            raise ValueError(f'cannot fetch `{url} {res.status_code}`', res.text)
        return ContractInterface.from_michelson(res.text, context)

    @staticmethod
    def from_file(path: str, context: Optional[ExecutionContext] = None) -> 'ContractInterface':
        """Create contract from michelson source code stored in a file.

        :param path: Path to the `.tz` file
        :param context: optional execution context
        :rtype: ContractInterface
        """
        with open(expanduser(path)) as f:
            return ContractInterface.from_michelson(f.read(), context)

    @staticmethod
    def from_michelson(source: str, context: Optional[ExecutionContext] = None) -> 'ContractInterface':
        """Create contract from michelson source code.

        :param source: Michelson source code
        :param context: optional execution context
        :rtype: ContractInterface
        """
        return ContractInterface.from_micheline(michelson_to_micheline(source), context)

    @staticmethod
    def from_micheline(expression: List[Dict[str, Any]], context: Optional[ExecutionContext] = None) -> 'ContractInterface':
        """Create contract from micheline expression.

        :param expression: [{'prim': 'parameter'}, {'prim': 'storage'}, {'prim': 'code'}]
        :param context: optional execution context
        :rtype: ContractInterface
        """
        if context is not None:
            code_expr = context.resolve_global_constants(expression)
        else:
            code_expr = expression
        program = MichelsonProgram.match(code_expr)
        cls = type(ContractInterface.__name__, (ContractInterface,), dict(program=program))
        context = ExecutionContext(
            shell=context.shell if context else None,
            key=context.key if context else None,
            script=dict(code=code_expr),
            global_constants=context.global_constants if context else None,
        )
        return cls(context)

    @staticmethod
    def from_context(context: ExecutionContext) -> 'ContractInterface':
        """Create contract from the previously loaded context data.

        :param context: execution context
        :return: ContractInterface
        """
        program = MichelsonProgram.load(context, with_code=True)
        cls = type(ContractInterface.__name__, (ContractInterface,), dict(program=program))
        return cls(context)

    @classmethod
    @deprecated(
        deprecated_in='3.0.0',
        removed_in='4.0.0',
        details='use one of `from_file`, `from_michelson`, `from_micheline`, `from_url`',
    )
    def create_from(cls, source):
        """Create contract interface from its code.

        :param source: Michelson code, filename, or Micheline JSON
        :rtype: ContractInterface
        """
        if isinstance(source, str):
            if exists(expanduser(source)):
                return ContractInterface.from_file(source)
            return ContractInterface.from_michelson(source)
        return ContractInterface.from_micheline(source)

    def to_micheline(self) -> List[Dict[str, Any]]:
        """Get contract script in Micheline JSON

        :return:  [{'prim': 'parameter'}, {'prim': 'storage'}, {'prim': 'code'}]
        """
        return self.program.as_micheline_expr()

    def to_michelson(self) -> str:
        """Get contract listing in formatted Michelson

        :return: string
        """
        return micheline_to_michelson(self.to_micheline())

    def to_file(self, path: str) -> None:
        """Write contract source to a .tz file

        :param path: path to the file
        """
        with open(path, 'w+') as f:
            f.write(self.to_michelson())

    @deprecated(deprecated_in='3.0.0', removed_in='4.0.0', details='use `.storage[path][to][big_map][key]()` instead')
    def big_map_get(self, path):
        """Get BigMap entry as Python object by plain key and block height.

        :param path: JSON path to the key (or just key to access default BigMap location).
            Use `/` to separate nodes and `::` to separate tuple args.
            In any other case you'd need to escape those symbols.
        :returns: object
        """
        node = self.storage
        for item in path.split('/'):
            if len(item) == 0:
                continue
            if isinstance(item, str):
                res = item.split('::')
                item = tuple(res) if len(res) > 1 else item
            node = node[item]
        return node() if node else None

    def using(
        self,
        shell: Optional[Union[ShellQuery, str]] = None,
        key: Optional[Union[Key, str]] = None,
        block_id: Optional[Union[str, int]] = None,
        mode: Optional[str] = None,
        ipfs_gateway: Optional[str] = None,
    ) -> 'ContractInterface':
        """Change the block at which the current contract is inspected.

        Also, if address is undefined you can specify RPC endpoint, and private key.

        :param shell: one of 'mainnet', '***net', or RPC node uri, or instance of :class:`pytezos.rpc.shell.ShellQuery`
        :param key: base58 encoded key, path to the faucet file, alias from tezos-client, or instance of `Key`
        :param block_id: block height / hash / offset to use, default is `head`
        :param mode: whether to use `readable` or `optimized` encoding for parameters/storage/other
        :param ipfs_gateway: override IPFS gateway URI
        :rtype: ContractInterface
        """
        has_address = self.context.address is not None
        return type(self)(
            self._spawn_context(
                shell=None if has_address else shell,
                key=None if has_address else key,
                address=self.context.address,
                block_id=block_id,
                mode=mode,
                ipfs_gateway=ipfs_gateway,
            )
        )

    @property
    def storage(self) -> ContractData:
        if self._storage:
            return self._storage
        elif self.address:
            expr = self.shell.blocks[self.context.block_id].context.contracts[self.address].storage()
            storage = self.program.storage.from_micheline_value(expr)
            storage.attach_context(self.context)
        else:
            storage = self.program.storage.dummy(self.context)
        return ContractData(self.context, storage.item, title="storage")

    @storage.setter
    def storage(self, storage: ContractData) -> None:
        if self.address:
            raise Exception('Can\'t set storage of deployed contract')
        self._storage = storage

    def storage_from_file(self, path: str) -> None:
        """Load contract storage from file

        :param path: path to .tz file
        """
        with open(path) as file:
            expr = michelson_to_micheline(file.read())
        self.storage_from_micheline(expr)

    def storage_from_micheline(self, expression) -> None:
        """Load contract storage from Micheline expression

        :param expression: Micheline expression
        """
        storage = self.program.storage.from_micheline_value(expression)
        storage.attach_context(self.context)
        self.storage = ContractData(self.context, storage.item, title="storage")

    def storage_from_michelson(self, source: str) -> None:
        """Load contract storage from Michelson code

        :param source: Michelson code
        """
        expr = michelson_to_micheline(source)
        self.storage_from_micheline(expr)

    @cached_property
    def metadata(self) -> Optional[ContractMetadata]:
        """Get TZIP-016 contract metadata, if exists

        :rtype: ContractMetadata
        """
        metadata_url = self.metadata_url
        if metadata_url is None:
            return None

        logger.info('Trying to fetch contract metadata from `%s`', metadata_url)
        parsed_url = urlparse(metadata_url)

        if parsed_url.scheme in ('http', 'https'):
            # NOTE: KT1B34qXVRfQrScEaqjjt6cJ5G8LtVFZ7fSc
            metadata = ContractMetadata.from_url(metadata_url, self.context)

        elif parsed_url.scheme == 'ipfs':
            # NOTE: KT1AFA2mwNUMNd4SsujE1YYp29vd8BZejyKW
            metadata = ContractMetadata.from_ipfs(parsed_url.netloc, self.context)

        elif parsed_url.scheme == 'tezos-storage':
            parts = parsed_url.path.split('/')
            if len(parts) == 1:
                # NOTE: KT1JBThDEqyqrEHimhxoUBCSnsKAqFcuHMkP
                storage = self.storage
            elif len(parts) == 2:
                # NOTE: KT1REEb5VxWRjcHm5GzDMwErMmNFftsE5Gpf
                context = self._spawn_context(address=parsed_url.netloc)
                storage = ContractInterface.from_context(context).storage
            else:
                raise NotImplementedError('Unknown metadata URL scheme')
            metadata_json = json.loads(storage['metadata'][parts[-1]]().decode())
            metadata = ContractMetadata.from_json(metadata_json, self.context)

        elif parsed_url.scheme == 'sha256':
            raise NotImplementedError

        else:
            raise NotImplementedError('Unknown metadata URL scheme')

        return metadata

    @property
    def token_metadata(self) -> ContractTokenMetadataProxy:
        """Get TZIP-021 contract token metadata proxy

        :rtype: ContractTokenMetadataProxy
        """
        return ContractTokenMetadataProxy(self._get_token_metadata)  # type: ignore

    @lru_cache(maxsize=None)
    def _get_token_metadata(self, token_id: int) -> Optional[ContractTokenMetadata]:
        token_metadata = self._get_token_metadata_from_view(token_id)
        if token_metadata is None:
            token_metadata = self._get_token_metadata_from_storage(token_id)
        return token_metadata

    def _get_token_metadata_from_storage(self, token_id: int) -> Optional[ContractTokenMetadata]:
        self._logger.info('Trying to fetch token %s metadata from storage', token_id)
        try:
            token_metadata_url = self.storage['token_metadata'][token_id]['token_info']['']().decode()
        # FIXME: Dirty
        except (KeyError, AssertionError):
            self._logger.info('Storage doesn\'t contain metadata URL for token %s', token_id)
            return None

        self._logger.info('Trying to fetch contract token metadata from `%s`', token_metadata_url)
        parsed_url = urlparse(token_metadata_url)

        if parsed_url.scheme in ('http', 'https'):
            token_metadata = ContractTokenMetadata.from_url(token_metadata_url, self.context)

        elif parsed_url.scheme == 'ipfs':
            token_metadata = ContractTokenMetadata.from_ipfs(parsed_url.netloc, self.context)

        elif parsed_url.scheme == 'tezos-storage':
            parts = parsed_url.path.split('/')
            if len(parts) == 1:
                storage = self.storage
            elif len(parts) == 2:
                context = self._spawn_context(address=parsed_url.netloc)
                storage = ContractInterface.from_context(context).storage
            else:
                raise NotImplementedError('Unknown metadata URL scheme')
            token_metadata_json = json.loads(storage['metadata'][parts[-1]]().decode())
            token_metadata = ContractTokenMetadata.from_json(token_metadata_json, self.context)

        elif parsed_url.scheme == 'sha256':
            raise NotImplementedError

        else:
            raise NotImplementedError('Unknown metadata URL scheme')

        return token_metadata

    def _get_token_metadata_from_view(self, token_id: int) -> Optional[ContractTokenMetadata]:
        self._logger.info('Trying to fetch token %s metadata from off-chain view', token_id)
        try:
            token_metadata_json = self.metadata.tokenMetadata(token_id).storage_view()[1]
            return ContractTokenMetadata.from_json(token_metadata_json)
        except KeyError:
            self._logger.info('There\'s no off-chain view named `token_metadata`')
            return None
        except MichelsonRuntimeError:
            self._logger.info('Off-chain view has no token metadata for token_id %s', token_id)
            return None

    @cached_property
    def metadata_url(self) -> Optional[str]:
        try:
            metadata = self.storage.data.find(lambda x: x.field_name == 'metadata' and isinstance(x, BigMapType))
            if metadata is not None:
                metadata_url = metadata['']
                if isinstance(metadata_url, BytesType):
                    return metadata_url.value.decode()
                else:
                    self._logger.info('Empty string key is not found in metadata big map')
            else:
                self._logger.info('Metadata big map not found')
        # FIXME: Dirty
        except (KeyError, AssertionError):
            self._logger.info('Failed to get metadata URI')
        return None

    @property
    def parameter(self) -> ContractEntrypoint:
        root_name = self.program.parameter.root_name
        assert root_name in self.entrypoints, 'root entrypoint is undefined'
        return getattr(self, root_name)

    @property  # type: ignore
    @deprecated(deprecated_in='3.0.0', removed_in='4.0.0', details='access `ContractInterface` directly')
    def contract(self) -> 'ContractInterface':
        return self

    @property  # type: ignore
    @deprecated(deprecated_in='3.0.0', removed_in='4.0.0', details='use `to_michelson()` instead')
    def text(self) -> str:
        return self.to_michelson()

    @property  # type: ignore
    @deprecated(deprecated_in='3.0.0', removed_in='4.0.0', details='use `to_micheline()` instead')
    def code(self):
        return self.to_micheline()

    @property  # type: ignore
    @deprecated(deprecated_in='3.0.0', removed_in='4.0.0', details='use `default()` instead')
    def call(self) -> ContractEntrypoint:
        return self.parameter

    def operation_result(self, operation_group: Dict[str, Any]) -> List[ContractCallResult]:
        """Get operation parameters, and resulting storage as Python objects.
        Can locate operation inside operation groups with multiple contents and/or internal operations.

        :param operation_group: {'branch', 'protocol', 'contents', 'signature'}
        :rtype: ContractCallResult
        """
        return ContractCallResult.from_run_operation(operation_group, context=self.context)

    def script(self, initial_storage=None, mode: Optional[str] = None) -> Dict[str, Any]:
        """Generate script for contract origination.

        :param initial_storage: Python object, leave None to generate default (attach shell/key for smart fill)
        :param mode: whether to use `readable` or `optimized` (or `legacy_optimized`) encoding for initial storage
        :return: {"code": $Micheline, "storage": $Micheline}
        """
        if initial_storage:
            storage = self.program.storage.from_python_object(initial_storage)
        else:
            storage = self.program.storage.dummy(self.context)
        return {
            'code': self.program.as_micheline_expr(),
            'storage': storage.to_micheline_value(mode=mode or self.context.mode, lazy_diff=True),
        }

    def originate(
        self,
        initial_storage=None,
        mode: Optional[str] = None,
        balance: Union[int, Decimal] = 0,
        delegate: Optional[str] = None,
    ) -> OperationGroup:
        """Create an origination operation

        :param initial_storage: Python object, leave None to generate default
        :param mode: whether to use `readable` or `optimized` (or `legacy_optimized`) encoding for initial storage
        :param balance: initial balance
        :param delegate: initial delegator
        :rtype: OperationGroup
        """
        return OperationGroup(context=self._spawn_context()).origination(
            script=self.script(initial_storage, mode=mode),
            balance=balance,
            delegate=delegate,
        )


@deprecated(deprecated_in='3.0.0', removed_in='4.0.0', details='use `ContractInterface` instead')
class Contract(ContractInterface):
    pass
