# 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 fnmatch
import os
import sys

from SCons import Builder, Util  # pylint: disable=import-error
from SCons.Node import FS  # pylint: disable=import-error
from SCons.Script import COMMAND_LINE_TARGETS  # pylint: disable=import-error
from SCons.Script import AlwaysBuild  # pylint: disable=import-error
from SCons.Script import DefaultEnvironment  # pylint: disable=import-error
from SCons.Script import Export  # pylint: disable=import-error
from SCons.Script import SConscript  # pylint: disable=import-error

from platformio import __version__, fs
from platformio.compat import IS_MACOS, string_types
from platformio.package.version import pepver_to_semver

SRC_HEADER_EXT = ["h", "hpp"]
SRC_ASM_EXT = ["S", "spp", "SPP", "sx", "s", "asm", "ASM"]
SRC_C_EXT = ["c"]
SRC_CXX_EXT = ["cc", "cpp", "cxx", "c++"]
SRC_BUILD_EXT = SRC_C_EXT + SRC_CXX_EXT + SRC_ASM_EXT
SRC_FILTER_DEFAULT = ["+<*>", "-<.git%s>" % os.sep, "-<.svn%s>" % os.sep]


def scons_patched_match_splitext(path, suffixes=None):
    """Patch SCons Builder, append $OBJSUFFIX to the end of each target"""
    tokens = Util.splitext(path)
    if suffixes and tokens[1] and tokens[1] in suffixes:
        return (path, tokens[1])
    return tokens


def GetBuildType(env):
    return (
        "debug"
        if (
            set(["__debug", "sizedata"]) & set(COMMAND_LINE_TARGETS)
            or env.GetProjectOption("build_type") == "debug"
        )
        else "release"
    )


def BuildProgram(env):
    env.ProcessProgramDeps()
    env.ProcessProjectDeps()

    # append into the beginning a main LD script
    if env.get("LDSCRIPT_PATH") and not any("-Wl,-T" in f for f in env["LINKFLAGS"]):
        env.Prepend(LINKFLAGS=["-T", env.subst("$LDSCRIPT_PATH")])

    # enable "cyclic reference" for linker
    if (
        env.get("LIBS")
        and env.GetCompilerType() == "gcc"
        and (env.PioPlatform().is_embedded() or not IS_MACOS)
    ):
        env.Prepend(_LIBFLAGS="-Wl,--start-group ")
        env.Append(_LIBFLAGS=" -Wl,--end-group")

    program = env.Program(
        os.path.join("$BUILD_DIR", env.subst("$PROGNAME$PROGSUFFIX")),
        env["PIOBUILDFILES"],
    )
    env.Replace(PIOMAINPROG=program)

    AlwaysBuild(
        env.Alias(
            "checkprogsize",
            program,
            env.VerboseAction(env.CheckUploadSize, "Checking size $PIOMAINPROG"),
        )
    )

    print("Building in %s mode" % env.GetBuildType())

    return program


def ProcessProgramDeps(env):
    def _append_pio_macros():
        core_version = pepver_to_semver(__version__)
        env.AppendUnique(
            CPPDEFINES=[
                (
                    "PLATFORMIO",
                    int(
                        "{0:02d}{1:02d}{2:02d}".format(
                            core_version.major, core_version.minor, core_version.patch
                        )
                    ),
                )
            ]
        )

    _append_pio_macros()

    env.PrintConfiguration()

    # fix ASM handling under non case-sensitive OS
    if not Util.case_sensitive_suffixes(".s", ".S"):
        env.Replace(AS="$CC", ASCOM="$ASPPCOM")

    # process extra flags from board
    if "BOARD" in env and "build.extra_flags" in env.BoardConfig():
        env.ProcessFlags(env.BoardConfig().get("build.extra_flags"))

    # apply user flags
    env.ProcessFlags(env.get("BUILD_FLAGS"))

    # process framework scripts
    env.BuildFrameworks(env.get("PIOFRAMEWORK"))

    if env.GetBuildType() == "debug":
        env.ConfigureDebugFlags()

    # remove specified flags
    env.ProcessUnFlags(env.get("BUILD_UNFLAGS"))

    if "__test" in COMMAND_LINE_TARGETS:
        env.ConfigureTestTarget()


def ProcessProjectDeps(env):
    project_lib_builder = env.ConfigureProjectLibBuilder()

    # prepend project libs to the beginning of list
    env.Prepend(LIBS=project_lib_builder.build())
    # prepend extra linker related options from libs
    env.PrependUnique(
        **{
            key: project_lib_builder.env.get(key)
            for key in ("LIBS", "LIBPATH", "LINKFLAGS")
            if project_lib_builder.env.get(key)
        }
    )

    projenv = env.Clone()

    # CPPPATH from dependencies
    projenv.PrependUnique(CPPPATH=project_lib_builder.env.get("CPPPATH"))
    # extra build flags from `platformio.ini`
    projenv.ProcessFlags(env.get("SRC_BUILD_FLAGS"))

    is_test = "__test" in COMMAND_LINE_TARGETS
    if is_test:
        projenv.BuildSources(
            "$BUILD_TEST_DIR", "$PROJECT_TEST_DIR", "$PIOTEST_SRC_FILTER"
        )
    if not is_test or env.GetProjectOption("test_build_project_src"):
        projenv.BuildSources(
            "$BUILD_SRC_DIR", "$PROJECT_SRC_DIR", env.get("SRC_FILTER")
        )

    if not env.get("PIOBUILDFILES") and not COMMAND_LINE_TARGETS:
        sys.stderr.write(
            "Error: Nothing to build. Please put your source code files "
            "to '%s' folder\n" % env.subst("$PROJECT_SRC_DIR")
        )
        env.Exit(1)

    Export("projenv")


def ParseFlagsExtended(env, flags):  # pylint: disable=too-many-branches
    if not isinstance(flags, list):
        flags = [flags]
    result = {}
    for raw in flags:
        for key, value in env.ParseFlags(str(raw)).items():
            if key not in result:
                result[key] = []
            result[key].extend(value)

    cppdefines = []
    for item in result["CPPDEFINES"]:
        if not Util.is_Sequence(item):
            cppdefines.append(item)
            continue
        name, value = item[:2]
        if '"' in value:
            value = value.replace('"', '\\"')
        elif value.isdigit():
            value = int(value)
        elif value.replace(".", "", 1).isdigit():
            value = float(value)
        cppdefines.append((name, value))
    result["CPPDEFINES"] = cppdefines

    # fix relative CPPPATH & LIBPATH
    for k in ("CPPPATH", "LIBPATH"):
        for i, p in enumerate(result.get(k, [])):
            if os.path.isdir(p):
                result[k][i] = os.path.realpath(p)

    # fix relative path for "-include"
    for i, f in enumerate(result.get("CCFLAGS", [])):
        if isinstance(f, tuple) and f[0] == "-include":
            result["CCFLAGS"][i] = (f[0], env.File(os.path.realpath(f[1].get_path())))

    return result


def ProcessFlags(env, flags):  # pylint: disable=too-many-branches
    if not flags:
        return
    env.Append(**env.ParseFlagsExtended(flags))

    # Cancel any previous definition of name, either built in or
    # provided with a -U option // Issue #191
    undefines = [
        u
        for u in env.get("CCFLAGS", [])
        if isinstance(u, string_types) and u.startswith("-U")
    ]
    if undefines:
        for undef in undefines:
            env["CCFLAGS"].remove(undef)
            if undef[2:] in env["CPPDEFINES"]:
                env["CPPDEFINES"].remove(undef[2:])
        env.Append(_CPPDEFFLAGS=" %s" % " ".join(undefines))


def ProcessUnFlags(env, flags):
    if not flags:
        return
    parsed = env.ParseFlagsExtended(flags)

    # get all flags and copy them to each "*FLAGS" variable
    all_flags = []
    for key, unflags in parsed.items():
        if key.endswith("FLAGS"):
            all_flags.extend(unflags)
    for key, unflags in parsed.items():
        if key.endswith("FLAGS"):
            parsed[key].extend(all_flags)

    for key, unflags in parsed.items():
        for unflag in unflags:
            for current in env.get(key, []):
                conditions = [
                    unflag == current,
                    isinstance(current, (tuple, list)) and unflag[0] == current[0],
                ]
                if any(conditions):
                    env[key].remove(current)


def MatchSourceFiles(env, src_dir, src_filter=None):
    src_filter = env.subst(src_filter) if src_filter else None
    src_filter = src_filter or SRC_FILTER_DEFAULT
    return fs.match_src_files(
        env.subst(src_dir), src_filter, SRC_BUILD_EXT + SRC_HEADER_EXT
    )


def CollectBuildFiles(
    env, variant_dir, src_dir, src_filter=None, duplicate=False
):  # pylint: disable=too-many-locals
    sources = []
    variants = []

    src_dir = env.subst(src_dir)
    if src_dir.endswith(os.sep):
        src_dir = src_dir[:-1]

    for item in env.MatchSourceFiles(src_dir, src_filter):
        _reldir = os.path.dirname(item)
        _src_dir = os.path.join(src_dir, _reldir) if _reldir else src_dir
        _var_dir = os.path.join(variant_dir, _reldir) if _reldir else variant_dir

        if _var_dir not in variants:
            variants.append(_var_dir)
            env.VariantDir(_var_dir, _src_dir, duplicate)

        if fs.path_endswith_ext(item, SRC_BUILD_EXT):
            sources.append(env.File(os.path.join(_var_dir, os.path.basename(item))))

    middlewares = env.get("__PIO_BUILD_MIDDLEWARES")
    if not middlewares:
        return sources

    new_sources = []
    for node in sources:
        new_node = node
        for callback, pattern in middlewares:
            if pattern and not fnmatch.fnmatch(node.srcnode().get_path(), pattern):
                continue
            new_node = callback(new_node)
        if new_node:
            new_sources.append(new_node)

    return new_sources


def AddBuildMiddleware(env, callback, pattern=None):
    env.Append(__PIO_BUILD_MIDDLEWARES=[(callback, pattern)])


def BuildFrameworks(env, frameworks):
    if not frameworks:
        return

    if "BOARD" not in env:
        sys.stderr.write(
            "Please specify `board` in `platformio.ini` to use "
            "with '%s' framework\n" % ", ".join(frameworks)
        )
        env.Exit(1)

    board_frameworks = env.BoardConfig().get("frameworks", [])
    if frameworks == ["platformio"]:
        if board_frameworks:
            frameworks.insert(0, board_frameworks[0])
        else:
            sys.stderr.write("Error: Please specify `board` in `platformio.ini`\n")
            env.Exit(1)

    for f in frameworks:
        if f == "arduino":
            # Arduino IDE appends .o the end of filename
            Builder.match_splitext = scons_patched_match_splitext
            if "nobuild" not in COMMAND_LINE_TARGETS:
                env.ConvertInoToCpp()

        if f in board_frameworks:
            SConscript(env.GetFrameworkScript(f), exports="env")
        else:
            sys.stderr.write("Error: This board doesn't support %s framework!\n" % f)
            env.Exit(1)


def BuildLibrary(env, variant_dir, src_dir, src_filter=None, nodes=None):
    env.ProcessUnFlags(env.get("BUILD_UNFLAGS"))
    nodes = nodes or env.CollectBuildFiles(variant_dir, src_dir, src_filter)
    return env.StaticLibrary(env.subst(variant_dir), nodes)


def BuildSources(env, variant_dir, src_dir, src_filter=None):
    nodes = env.CollectBuildFiles(variant_dir, src_dir, src_filter)
    DefaultEnvironment().Append(
        PIOBUILDFILES=[
            env.Object(node) if isinstance(node, FS.File) else node for node in nodes
        ]
    )


def exists(_):
    return True


def generate(env):
    env.AddMethod(GetBuildType)
    env.AddMethod(BuildProgram)
    env.AddMethod(ProcessProgramDeps)
    env.AddMethod(ProcessProjectDeps)
    env.AddMethod(ParseFlagsExtended)
    env.AddMethod(ProcessFlags)
    env.AddMethod(ProcessUnFlags)
    env.AddMethod(MatchSourceFiles)
    env.AddMethod(CollectBuildFiles)
    env.AddMethod(AddBuildMiddleware)
    env.AddMethod(BuildFrameworks)
    env.AddMethod(BuildLibrary)
    env.AddMethod(BuildSources)
    return env
