# Copyright (c) 2014-present PlatformIO <contact@platformio.org>
#
# 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.

from __future__ import absolute_import

import atexit
import re
import sys
from os import environ, remove, walk
from os.path import basename, isdir, isfile, join, relpath, sep
from tempfile import mkstemp

from SCons.Action import Action
from SCons.Script import ARGUMENTS

from platformio import util
from platformio.managers.core import get_core_package_dir


class InoToCPPConverter(object):

    PROTOTYPE_RE = re.compile(r"""^(
        (?:template\<.*\>\s*)?      # template
        ([a-z_\d]+\*?\s+){1,2}      # return type
        ([a-z_\d]+\s*)              # name of prototype
        \([a-z_,\.\*\&\[\]\s\d]*\)  # arguments
        )\s*\{                      # must end with {
        """, re.X | re.M | re.I)
    DETECTMAIN_RE = re.compile(r"void\s+(setup|loop)\s*\(", re.M | re.I)
    PROTOPTRS_TPLRE = r"\([^&\(]*&(%s)[^\)]*\)"

    def __init__(self, env):
        self.env = env
        self._main_ino = None

    def is_main_node(self, contents):
        return self.DETECTMAIN_RE.search(contents)

    def convert(self, nodes):
        contents = self.merge(nodes)
        if not contents:
            return
        return self.process(contents)

    def merge(self, nodes):
        assert nodes
        lines = []
        for node in nodes:
            contents = node.get_text_contents()
            _lines = [
                '# 1 "%s"' % node.get_path().replace("\\", "/"), contents
            ]
            if self.is_main_node(contents):
                lines = _lines + lines
                self._main_ino = node.get_path()
            else:
                lines.extend(_lines)

        if not self._main_ino:
            self._main_ino = nodes[0].get_path()

        return "\n".join(["#include <Arduino.h>"] + lines) if lines else None

    def process(self, contents):
        out_file = self._main_ino + ".cpp"
        assert self._gcc_preprocess(contents, out_file)
        with open(out_file) as fp:
            contents = fp.read()
        contents = self._join_multiline_strings(contents)
        with open(out_file, "w") as fp:
            fp.write(self.append_prototypes(contents))
        return out_file

    def _gcc_preprocess(self, contents, out_file):
        tmp_path = mkstemp()[1]
        with open(tmp_path, "w") as fp:
            fp.write(contents)
        self.env.Execute(
            self.env.VerboseAction(
                '$CXX -o "{0}" -x c++ -fpreprocessed -dD -E "{1}"'.format(
                    out_file,
                    tmp_path), "Converting " + basename(out_file[:-4])))
        atexit.register(_delete_file, tmp_path)
        return isfile(out_file)

    def _join_multiline_strings(self, contents):
        if "\\\n" not in contents:
            return contents
        newlines = []
        linenum = 0
        stropen = False
        for line in contents.split("\n"):
            _linenum = self._parse_preproc_line_num(line)
            if _linenum is not None:
                linenum = _linenum
            else:
                linenum += 1

            if line.endswith("\\"):
                if line.startswith('"'):
                    stropen = True
                    newlines.append(line[:-1])
                    continue
                elif stropen:
                    newlines[len(newlines) - 1] += line[:-1]
                    continue
            elif stropen and line.endswith(('",', '";')):
                newlines[len(newlines) - 1] += line
                stropen = False
                newlines.append('#line %d "%s"' %
                                (linenum, self._main_ino.replace("\\", "/")))
                continue

            newlines.append(line)

        return "\n".join(newlines)

    @staticmethod
    def _parse_preproc_line_num(line):
        if not line.startswith("#"):
            return None
        tokens = line.split(" ", 3)
        if len(tokens) > 2 and tokens[1].isdigit():
            return int(tokens[1])
        return None

    def _parse_prototypes(self, contents):
        prototypes = []
        reserved_keywords = set(["if", "else", "while"])
        for match in self.PROTOTYPE_RE.finditer(contents):
            if (set([match.group(2).strip(),
                     match.group(3).strip()]) & reserved_keywords):
                continue
            prototypes.append(match)
        return prototypes

    def _get_total_lines(self, contents):
        total = 0
        if contents.endswith("\n"):
            contents = contents[:-1]
        for line in contents.split("\n")[::-1]:
            linenum = self._parse_preproc_line_num(line)
            if linenum is not None:
                return total + linenum
            total += 1
        return total

    def append_prototypes(self, contents):
        prototypes = self._parse_prototypes(contents)
        if not prototypes:
            return contents

        prototype_names = set([m.group(3).strip() for m in prototypes])
        split_pos = prototypes[0].start()
        match_ptrs = re.search(self.PROTOPTRS_TPLRE %
                               ("|".join(prototype_names)),
                               contents[:split_pos], re.M)
        if match_ptrs:
            split_pos = contents.rfind("\n", 0, match_ptrs.start()) + 1

        result = []
        result.append(contents[:split_pos].strip())
        result.append("%s;" % ";\n".join([m.group(1) for m in prototypes]))
        result.append('#line %d "%s"' %
                      (self._get_total_lines(contents[:split_pos]),
                       self._main_ino.replace("\\", "/")))
        result.append(contents[split_pos:].strip())
        return "\n".join(result)


def ConvertInoToCpp(env):
    src_dir = util.glob_escape(env.subst("$PROJECTSRC_DIR"))
    ino_nodes = (
        env.Glob(join(src_dir, "*.ino")) + env.Glob(join(src_dir, "*.pde")))
    if not ino_nodes:
        return
    c = InoToCPPConverter(env)
    out_file = c.convert(ino_nodes)

    atexit.register(_delete_file, out_file)


def _delete_file(path):
    try:
        if isfile(path):
            remove(path)
    except:  # pylint: disable=bare-except
        pass


def GetCompilerType(env):
    try:
        sysenv = environ.copy()
        sysenv['PATH'] = str(env['ENV']['PATH'])
        result = util.exec_command([env.subst("$CC"), "-v"], env=sysenv)
    except OSError:
        return None
    if result['returncode'] != 0:
        return None
    output = "".join([result['out'], result['err']]).lower()
    if "clang" in output and "LLVM" in output:
        return "clang"
    elif "gcc" in output:
        return "gcc"
    return None


def GetActualLDScript(env):

    def _lookup_in_ldpath(script):
        for d in env.get("LIBPATH", []):
            path = join(env.subst(d), script)
            if isfile(path):
                return path
        return None

    script = None
    for f in env.get("LINKFLAGS", []):
        if f.startswith("-Wl,-T"):
            script = env.subst(f[6:].replace('"', "").strip())
            if isfile(script):
                return script
            path = _lookup_in_ldpath(script)
            if path:
                return path

    if script:
        sys.stderr.write(
            "Error: Could not find '%s' LD script in LDPATH '%s'\n" %
            (script, env.subst("$LIBPATH")))
        env.Exit(1)

    if not script and "LDSCRIPT_PATH" in env:
        path = _lookup_in_ldpath(env['LDSCRIPT_PATH'])
        if path:
            return path

    sys.stderr.write("Error: Could not find LD script\n")
    env.Exit(1)


def VerboseAction(_, act, actstr):
    if int(ARGUMENTS.get("PIOVERBOSE", 0)):
        return act
    return Action(act, actstr)


def PioClean(env, clean_dir):
    if not isdir(clean_dir):
        print "Build environment is clean"
        env.Exit(0)
    for root, _, files in walk(clean_dir):
        for file_ in files:
            remove(join(root, file_))
            print "Removed %s" % relpath(join(root, file_))
    print "Done cleaning"
    util.rmtree_(clean_dir)
    env.Exit(0)


def ProcessDebug(env):
    if not env.subst("$PIODEBUGFLAGS"):
        env.Replace(PIODEBUGFLAGS=["-Og", "-g3", "-ggdb"])
    env.Append(
        BUILD_FLAGS=env.get("PIODEBUGFLAGS", []),
        BUILD_UNFLAGS=["-Os", "-O0", "-O1", "-O2", "-O3"])


def ProcessTest(env):
    env.Append(
        CPPDEFINES=["UNIT_TEST", "UNITY_INCLUDE_CONFIG_H"],
        CPPPATH=[join("$BUILD_DIR", "UnityTestLib")])
    unitylib = env.BuildLibrary(
        join("$BUILD_DIR", "UnityTestLib"), get_core_package_dir("tool-unity"))
    env.Prepend(LIBS=[unitylib])

    src_filter = None
    if "PIOTEST" in env:
        src_filter = "+<output_export.cpp>"
        src_filter += " +<%s%s>" % (env['PIOTEST'], sep)

    return env.CollectBuildFiles(
        "$BUILDTEST_DIR",
        "$PROJECTTEST_DIR",
        src_filter=src_filter,
        duplicate=False)


def exists(_):
    return True


def generate(env):
    env.AddMethod(ConvertInoToCpp)
    env.AddMethod(GetCompilerType)
    env.AddMethod(GetActualLDScript)
    env.AddMethod(VerboseAction)
    env.AddMethod(PioClean)
    env.AddMethod(ProcessDebug)
    env.AddMethod(ProcessTest)
    return env
