# Copyright 2019 The Meson development team

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

# This class contains the basic functionality needed to run any interpreter
# or an interpreter-based tool.

from .common import CMakeException, CMakeConfiguration, CMakeBuildFile
from .. import mlog
from contextlib import contextmanager
from subprocess import Popen, PIPE, TimeoutExpired
from pathlib import Path
import typing as T
import json

if T.TYPE_CHECKING:
    from ..environment import Environment
    from .executor import CMakeExecutor

CMAKE_SERVER_BEGIN_STR = '[== "CMake Server" ==['
CMAKE_SERVER_END_STR = ']== "CMake Server" ==]'

CMAKE_MESSAGE_TYPES = {
    'error': ['cookie', 'errorMessage'],
    'hello': ['supportedProtocolVersions'],
    'message': ['cookie', 'message'],
    'progress': ['cookie'],
    'reply': ['cookie', 'inReplyTo'],
    'signal': ['cookie', 'name'],
}  # type: T.Dict[str, T.List[str]]

CMAKE_REPLY_TYPES = {
    'handshake': [],
    'configure': [],
    'compute': [],
    'cmakeInputs': ['buildFiles', 'cmakeRootDirectory', 'sourceDirectory'],
    'codemodel': ['configurations']
}  # type: T.Dict[str, T.List[str]]

# Base CMake server message classes

class MessageBase:
    def __init__(self, msg_type: str, cookie: str) -> None:
        self.type = msg_type
        self.cookie = cookie

    def to_dict(self) -> T.Dict[str, T.Union[str, T.List[str], T.Dict[str, int]]]:
        return {'type': self.type, 'cookie': self.cookie}

    def log(self) -> None:
        mlog.warning('CMake server message of type', mlog.bold(type(self).__name__), 'has no log function')

class RequestBase(MessageBase):
    cookie_counter = 0

    def __init__(self, msg_type: str) -> None:
        super().__init__(msg_type, self.gen_cookie())

    @staticmethod
    def gen_cookie() -> str:
        RequestBase.cookie_counter += 1
        return f'meson_{RequestBase.cookie_counter}'

class ReplyBase(MessageBase):
    def __init__(self, cookie: str, in_reply_to: str) -> None:
        super().__init__('reply', cookie)
        self.in_reply_to = in_reply_to

class SignalBase(MessageBase):
    def __init__(self, cookie: str, signal_name: str) -> None:
        super().__init__('signal', cookie)
        self.signal_name = signal_name

    def log(self) -> None:
        mlog.log(mlog.bold('CMake signal:'), mlog.yellow(self.signal_name))

# Special Message classes

class Error(MessageBase):
    def __init__(self, cookie: str, message: str) -> None:
        super().__init__('error', cookie)
        self.message = message

    def log(self) -> None:
        mlog.error(mlog.bold('CMake server error:'), mlog.red(self.message))

class Message(MessageBase):
    def __init__(self, cookie: str, message: str) -> None:
        super().__init__('message', cookie)
        self.message = message

    def log(self) -> None:
        #mlog.log(mlog.bold('CMake:'), self.message)
        pass

class Progress(MessageBase):
    def __init__(self, cookie: str) -> None:
        super().__init__('progress', cookie)

    def log(self) -> None:
        pass

class MessageHello(MessageBase):
    def __init__(self, supported_protocol_versions: T.List[T.Dict[str, int]]) -> None:
        super().__init__('hello', '')
        self.supported_protocol_versions = supported_protocol_versions

    def supports(self, major: int, minor: T.Optional[int] = None) -> bool:
        for i in self.supported_protocol_versions:
            assert 'major' in i
            assert 'minor' in i
            if major == i['major']:
                if minor is None or minor == i['minor']:
                    return True
        return False

# Request classes

class RequestHandShake(RequestBase):
    def __init__(self, src_dir: Path, build_dir: Path, generator: str, vers_major: int, vers_minor: T.Optional[int] = None) -> None:
        super().__init__('handshake')
        self.src_dir = src_dir
        self.build_dir = build_dir
        self.generator = generator
        self.vers_major = vers_major
        self.vers_minor = vers_minor

    def to_dict(self) -> T.Dict[str, T.Union[str, T.List[str], T.Dict[str, int]]]:
        vers = {'major': self.vers_major}
        if self.vers_minor is not None:
            vers['minor'] = self.vers_minor

        # Old CMake versions (3.7) want '/' even on Windows
        self.src_dir   = self.src_dir.resolve()
        self.build_dir = self.build_dir.resolve()

        return {
            **super().to_dict(),
            'sourceDirectory': self.src_dir.as_posix(),
            'buildDirectory': self.build_dir.as_posix(),
            'generator': self.generator,
            'protocolVersion': vers
        }

class RequestConfigure(RequestBase):
    def __init__(self, args: T.Optional[T.List[str]] = None):
        super().__init__('configure')
        self.args = args

    def to_dict(self) -> T.Dict[str, T.Union[str, T.List[str], T.Dict[str, int]]]:
        res = super().to_dict()
        if self.args:
            res['cacheArguments'] = self.args
        return res

class RequestCompute(RequestBase):
    def __init__(self) -> None:
        super().__init__('compute')

class RequestCMakeInputs(RequestBase):
    def __init__(self) -> None:
        super().__init__('cmakeInputs')

class RequestCodeModel(RequestBase):
    def __init__(self) -> None:
        super().__init__('codemodel')

# Reply classes

class ReplyHandShake(ReplyBase):
    def __init__(self, cookie: str) -> None:
        super().__init__(cookie, 'handshake')

class ReplyConfigure(ReplyBase):
    def __init__(self, cookie: str) -> None:
        super().__init__(cookie, 'configure')

class ReplyCompute(ReplyBase):
    def __init__(self, cookie: str) -> None:
        super().__init__(cookie, 'compute')

class ReplyCMakeInputs(ReplyBase):
    def __init__(self, cookie: str, cmake_root: Path, src_dir: Path, build_files: T.List[CMakeBuildFile]) -> None:
        super().__init__(cookie, 'cmakeInputs')
        self.cmake_root = cmake_root
        self.src_dir = src_dir
        self.build_files = build_files

    def log(self) -> None:
        mlog.log('CMake root: ', mlog.bold(self.cmake_root.as_posix()))
        mlog.log('Source dir: ', mlog.bold(self.src_dir.as_posix()))
        mlog.log('Build files:', mlog.bold(str(len(self.build_files))))
        with mlog.nested():
            for i in self.build_files:
                mlog.log(str(i))

class ReplyCodeModel(ReplyBase):
    def __init__(self, data: T.Dict[str, T.Any]) -> None:
        super().__init__(data['cookie'], 'codemodel')
        self.configs = []
        for i in data['configurations']:
            self.configs += [CMakeConfiguration(i)]

    def log(self) -> None:
        mlog.log('CMake code mode:')
        for idx, i in enumerate(self.configs):
            mlog.log(f'Configuration {idx}:')
            with mlog.nested():
                i.log()

# Main client class

class CMakeClient:
    def __init__(self, env: 'Environment') -> None:
        self.env = env
        self.proc = None  # type: T.Optional[Popen]
        self.type_map = {
            'error': lambda data: Error(data['cookie'], data['errorMessage']),
            'hello': lambda data: MessageHello(data['supportedProtocolVersions']),
            'message': lambda data: Message(data['cookie'], data['message']),
            'progress': lambda data: Progress(data['cookie']),
            'reply': self.resolve_type_reply,
            'signal': lambda data: SignalBase(data['cookie'], data['name'])
        }  # type: T.Dict[str, T.Callable[[T.Dict[str, T.Any]], MessageBase]]

        self.reply_map = {
            'handshake': lambda data: ReplyHandShake(data['cookie']),
            'configure': lambda data: ReplyConfigure(data['cookie']),
            'compute': lambda data: ReplyCompute(data['cookie']),
            'cmakeInputs': self.resolve_reply_cmakeInputs,
            'codemodel': lambda data: ReplyCodeModel(data),
        }  # type: T.Dict[str, T.Callable[[T.Dict[str, T.Any]], ReplyBase]]

    def readMessageRaw(self) -> T.Dict[str, T.Any]:
        assert self.proc is not None
        rawData = []
        begin = False
        while self.proc.poll() is None:
            line = self.proc.stdout.readline()
            if not line:
                break
            line = line.decode('utf-8')
            line = line.strip()

            if begin and line == CMAKE_SERVER_END_STR:
                break # End of the message
            elif begin:
                rawData += [line]
            elif line == CMAKE_SERVER_BEGIN_STR:
                begin = True # Begin of the message

        if rawData:
            res = json.loads('\n'.join(rawData))
            assert isinstance(res, dict)
            for i in res.keys():
                assert isinstance(i, str)
            return res
        raise CMakeException('Failed to read data from the CMake server')

    def readMessage(self) -> MessageBase:
        raw_data = self.readMessageRaw()
        if 'type' not in raw_data:
            raise CMakeException('The "type" attribute is missing from the message')
        msg_type = raw_data['type']
        func = self.type_map.get(msg_type, None)
        if not func:
            raise CMakeException(f'Recieved unknown message type "{msg_type}"')
        for i in CMAKE_MESSAGE_TYPES[msg_type]:
            if i not in raw_data:
                raise CMakeException(f'Key "{i}" is missing from CMake server message type {msg_type}')
        return func(raw_data)

    def writeMessage(self, msg: MessageBase) -> None:
        raw_data = '\n{}\n{}\n{}\n'.format(CMAKE_SERVER_BEGIN_STR, json.dumps(msg.to_dict(), indent=2), CMAKE_SERVER_END_STR)
        self.proc.stdin.write(raw_data.encode('ascii'))
        self.proc.stdin.flush()

    def query(self, request: RequestBase) -> MessageBase:
        self.writeMessage(request)
        while True:
            reply = self.readMessage()
            if reply.cookie == request.cookie and reply.type in ['reply', 'error']:
                return reply

            reply.log()

    def query_checked(self, request: RequestBase, message: str) -> MessageBase:
        reply = self.query(request)
        h = mlog.green('SUCCEEDED') if reply.type == 'reply' else mlog.red('FAILED')
        mlog.log(message + ':', h)
        if reply.type != 'reply':
            reply.log()
            raise CMakeException('CMake server query failed')
        return reply

    def do_handshake(self, src_dir: Path, build_dir: Path, generator: str, vers_major: int, vers_minor: T.Optional[int] = None) -> None:
        # CMake prints the hello message on startup
        msg = self.readMessage()
        if not isinstance(msg, MessageHello):
            raise CMakeException('Recieved an unexpected message from the CMake server')

        request = RequestHandShake(src_dir, build_dir, generator, vers_major, vers_minor)
        self.query_checked(request, 'CMake server handshake')

    def resolve_type_reply(self, data: T.Dict[str, T.Any]) -> ReplyBase:
        reply_type = data['inReplyTo']
        func = self.reply_map.get(reply_type, None)
        if not func:
            raise CMakeException(f'Recieved unknown reply type "{reply_type}"')
        for i in ['cookie'] + CMAKE_REPLY_TYPES[reply_type]:
            if i not in data:
                raise CMakeException(f'Key "{i}" is missing from CMake server message type {type}')
        return func(data)

    def resolve_reply_cmakeInputs(self, data: T.Dict[str, T.Any]) -> ReplyCMakeInputs:
        files = []
        for i in data['buildFiles']:
            for j in i['sources']:
                files += [CMakeBuildFile(Path(j), i['isCMake'], i['isTemporary'])]
        return ReplyCMakeInputs(data['cookie'], Path(data['cmakeRootDirectory']), Path(data['sourceDirectory']), files)

    @contextmanager
    def connect(self, cmake_exe: 'CMakeExecutor') -> T.Generator[None, None, None]:
        self.startup(cmake_exe)
        try:
            yield
        finally:
            self.shutdown()

    def startup(self, cmake_exe: 'CMakeExecutor') -> None:
        if self.proc is not None:
            raise CMakeException('The CMake server was already started')
        assert cmake_exe.found()

        mlog.debug('Starting CMake server with CMake', mlog.bold(' '.join(cmake_exe.get_command())), 'version', mlog.cyan(cmake_exe.version()))
        self.proc = Popen(cmake_exe.get_command() + ['-E', 'server', '--experimental', '--debug'], stdin=PIPE, stdout=PIPE)

    def shutdown(self) -> None:
        if self.proc is None:
            return

        mlog.debug('Shutting down the CMake server')

        # Close the pipes to exit
        self.proc.stdin.close()
        self.proc.stdout.close()

        # Wait for CMake to finish
        try:
            self.proc.wait(timeout=2)
        except TimeoutExpired:
            # Terminate CMake if there is a timeout
            # terminate() may throw a platform specific exception if the process has already
            # terminated. This may be the case if there is a race condition (CMake exited after
            # the timeout but before the terminate() call). Additionally, this behavior can
            # also be triggered on cygwin if CMake crashes.
            # See https://github.com/mesonbuild/meson/pull/4969#issuecomment-499413233
            try:
                self.proc.terminate()
            except Exception:
                pass

        self.proc = None