#!/usr/bin/env python3
#
# Copyright (c) 2022, Nordic Semiconductor ASA
#
# SPDX-License-Identifier: Apache-2.0

'''Internal snippets tool.
This is part of the build system's support for snippets.
It is not meant for use outside of the build system.

Output CMake variables:

- SNIPPET_NAMES: CMake list of discovered snippet names
- SNIPPET_FOUND_{snippet}: one per discovered snippet
'''

from collections import defaultdict, UserDict
from dataclasses import dataclass, field
from pathlib import Path, PurePosixPath
from typing import Dict, Iterable, List, Set
import argparse
import logging
import os
import pykwalify.core
import pykwalify.errors
import re
import sys
import textwrap
import yaml
import platform

# Marker type for an 'append:' configuration. Maps variables
# to the list of values to append to them.
Appends = Dict[str, List[str]]

def _new_append():
    return defaultdict(list)

def _new_board2appends():
    return defaultdict(_new_append)

@dataclass
class Snippet:
    '''Class for keeping track of all the settings discovered for an
    individual snippet.'''

    name: str
    appends: Appends = field(default_factory=_new_append)
    board2appends: Dict[str, Appends] = field(default_factory=_new_board2appends)

    def process_data(self, pathobj: Path, snippet_data: dict):
        '''Process the data in a snippet.yml file, after it is loaded into a
        python object and validated by pykwalify.'''
        def append_value(variable, value):
            if variable in ('EXTRA_DTC_OVERLAY_FILE', 'EXTRA_CONF_FILE'):
                path = pathobj.parent / value
                if not path.is_file():
                    _err(f'snippet file {pathobj}: {variable}: file not found: {path}')
                return f'"{path}"'
            _err(f'unknown append variable: {variable}')

        for variable, value in snippet_data.get('append', {}).items():
            self.appends[variable].append(append_value(variable, value))
        for board, settings in snippet_data.get('boards', {}).items():
            if board.startswith('/') and not board.endswith('/'):
                _err(f"snippet file {pathobj}: board {board} starts with '/', so "
                     "it must end with '/' to use a regular expression")
            for variable, value in settings.get('append', {}).items():
                self.board2appends[board][variable].append(
                    append_value(variable, value))

class Snippets(UserDict):
    '''Type for all the information we have discovered about all snippets.
    As a dict, this maps a snippet's name onto the Snippet object.
    Any additional global attributes about all snippets go here as
    instance attributes.'''

    def __init__(self, requested: Iterable[str] = None):
        super().__init__()
        self.paths: Set[Path] = set()
        self.requested: List[str] = list(requested or [])

class SnippetsError(Exception):
    '''Class for signalling expected errors'''

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

class SnippetToCMakePrinter:
    '''Helper class for printing a Snippets's semantics to a .cmake
    include file for use by snippets.cmake.'''

    def __init__(self, snippets: Snippets, out_file):
        self.snippets = snippets
        self.out_file = out_file
        self.section = '#' * 79

    def print_cmake(self):
        '''Print to the output file provided to the constructor.'''
        # TODO: add source file info
        snippets = self.snippets
        snippet_names = sorted(snippets.keys())

        if platform.system() == "Windows":
            # Change to linux-style paths for windows to avoid cmake escape character code issues
            snippets.paths = set(map(lambda x: str(PurePosixPath(x)), snippets.paths))

            for this_snippet in snippets:
                for snippet_append in (snippets[this_snippet].appends):
                    snippets[this_snippet].appends[snippet_append] = \
                        set(map(lambda x: str(x.replace("\\", "/")), \
                            snippets[this_snippet].appends[snippet_append]))

        snippet_path_list = " ".join(
            sorted(f'"{path}"' for path in snippets.paths))

        self.print('''\
# WARNING. THIS FILE IS AUTO-GENERATED. DO NOT MODIFY!
#
# This file contains build system settings derived from your snippets.
# Its contents are an implementation detail that should not be used outside
# of Zephyr's snippets CMake module.
#
# See the Snippets guide in the Zephyr documentation for more information.
''')

        self.print(f'''\
{self.section}
# Global information about all snippets.

# The name of every snippet that was discovered.
set(SNIPPET_NAMES {' '.join(f'"{name}"' for name in snippet_names)})
# The paths to all the snippet.yml files. One snippet
# can have multiple snippet.yml files.
set(SNIPPET_PATHS {snippet_path_list})

# Create variable scope for snippets build variables
zephyr_create_scope(snippets)
''')

        for snippet_name in snippets.requested:
            self.print_cmake_for(snippets[snippet_name])
            self.print()

    def print_cmake_for(self, snippet: Snippet):
        self.print(f'''\
{self.section}
# Snippet '{snippet.name}'

# Common variable appends.''')
        self.print_appends(snippet.appends, 0)
        for board, appends in snippet.board2appends.items():
            self.print_appends_for_board(board, appends)

    def print_appends_for_board(self, board: str, appends: Appends):
        if board.startswith('/'):
            board_re = board[1:-1]
            self.print(f'''\
# Appends for board regular expression '{board_re}'
if("${{BOARD}}" MATCHES "^{board_re}$")''')
        else:
            self.print(f'''\
# Appends for board '{board}'
if("${{BOARD}}" STREQUAL "{board}")''')
        self.print_appends(appends, 1)
        self.print('endif()')

    def print_appends(self, appends: Appends, indent: int):
        space = '  ' * indent
        for name, values in appends.items():
            for value in values:
                self.print(f'{space}zephyr_set({name} {value} SCOPE snippets APPEND)')

    def print(self, *args, **kwargs):
        kwargs['file'] = self.out_file
        print(*args, **kwargs)

# Name of the file containing the pykwalify schema for snippet.yml
# files.
SCHEMA_PATH = str(Path(__file__).parent / 'schemas' / 'snippet-schema.yml')
with open(SCHEMA_PATH, 'r') as f:
    SNIPPET_SCHEMA = yaml.safe_load(f.read())

# The name of the file which contains metadata about the snippets
# being defined in a directory.
SNIPPET_YML = 'snippet.yml'

# Regular expression for validating snippet names. Snippet names must
# begin with an alphanumeric character, and may contain alphanumeric
# characters or underscores. This is intentionally very restrictive to
# keep things consistent and easy to type and remember. We can relax
# this a bit later if needed.
SNIPPET_NAME_RE = re.compile('[A-Za-z0-9][A-Za-z0-9_-]*')

# Logger for this module.
LOG = logging.getLogger('snippets')

def _err(msg):
    raise SnippetsError(f'error: {msg}')

def parse_args():
    parser = argparse.ArgumentParser(description='snippets helper',
                                     allow_abbrev=False)
    parser.add_argument('--snippet-root', default=[], action='append', type=Path,
                        help='''a SNIPPET_ROOT element; may be given
                        multiple times''')
    parser.add_argument('--snippet', dest='snippets', default=[], action='append',
                        help='''a SNIPPET element; may be given
                        multiple times''')
    parser.add_argument('--cmake-out', type=Path,
                        help='''file to write cmake output to; include()
                        this file after calling this script''')
    return parser.parse_args()

def setup_logging():
    # Silence validation errors from pykwalify, which are logged at
    # logging.ERROR level. We want to handle those ourselves as
    # needed.
    logging.getLogger('pykwalify').setLevel(logging.CRITICAL)
    logging.basicConfig(level=logging.INFO,
                        format='  %(name)s: %(message)s')

def process_snippets(args: argparse.Namespace) -> Snippets:
    '''Process snippet.yml files under each *snippet_root*
    by recursive search. Return a Snippets object describing
    the results of the search.
    '''
    # This will contain information about all the snippets
    # we discover in each snippet_root element.
    snippets = Snippets(requested=args.snippets)

    # Process each path in snippet_root in order, adjusting
    # snippets as needed for each one.
    for root in args.snippet_root:
        process_snippets_in(root, snippets)

    return snippets

def process_snippets_in(root_dir: Path, snippets: Snippets) -> None:
    '''Process snippet.yml files in *root_dir*,
    updating *snippets* as needed.'''

    if not root_dir.is_dir():
        LOG.warning(f'SNIPPET_ROOT {root_dir} '
                    'is not a directory; ignoring it')
        return

    snippets_dir = root_dir / 'snippets'
    if not snippets_dir.is_dir():
        return

    for dirpath, _, filenames in os.walk(snippets_dir):
        if SNIPPET_YML not in filenames:
            continue

        snippet_yml = Path(dirpath) / SNIPPET_YML
        snippet_data = load_snippet_yml(snippet_yml)
        name = snippet_data['name']
        if name not in snippets:
            snippets[name] = Snippet(name=name)
        snippets[name].process_data(snippet_yml, snippet_data)
        snippets.paths.add(snippet_yml)

def load_snippet_yml(snippet_yml: Path) -> dict:
    '''Load a snippet.yml file *snippet_yml*, validate the contents
    against the schema, and do other basic checks. Return the dict
    of the resulting YAML data.'''

    with open(snippet_yml, 'r') as f:
        try:
            snippet_data = yaml.safe_load(f.read())
        except yaml.scanner.ScannerError:
            _err(f'snippets file {snippet_yml} is invalid YAML')

    def pykwalify_err(e):
        return f'''\
invalid {SNIPPET_YML} file: {snippet_yml}
{textwrap.indent(e.msg, '  ')}
'''

    try:
        pykwalify.core.Core(source_data=snippet_data,
                            schema_data=SNIPPET_SCHEMA).validate()
    except pykwalify.errors.PyKwalifyException as e:
        _err(pykwalify_err(e))

    name = snippet_data['name']
    if not SNIPPET_NAME_RE.fullmatch(name):
        _err(f"snippet file {snippet_yml}: invalid snippet name '{name}'; "
             'snippet names must begin with a letter '
             'or number, and may only contain letters, numbers, '
             'dashes (-), and underscores (_)')

    return snippet_data

def check_for_errors(snippets: Snippets) -> None:
    unknown_snippets = sorted(snippet for snippet in snippets.requested
                              if snippet not in snippets)
    if unknown_snippets:
        all_snippets = '\n    '.join(sorted(snippets))
        _err(f'''\
snippets not found: {', '.join(unknown_snippets)}
  Please choose from among the following snippets:
    {all_snippets}''')

def write_cmake_out(snippets: Snippets, cmake_out: Path) -> None:
    '''Write a cmake include file to *cmake_out* which
    reflects the information in *snippets*.

    The contents of this file should be considered an implementation
    detail and are not meant to be used outside of snippets.cmake.'''
    if not cmake_out.parent.exists():
        cmake_out.parent.mkdir()
    with open(cmake_out, 'w', encoding="utf-8") as f:
        SnippetToCMakePrinter(snippets, f).print_cmake()

def main():
    args = parse_args()
    setup_logging()
    try:
        snippets = process_snippets(args)
        check_for_errors(snippets)
    except SnippetsError as e:
        LOG.critical(e.msg)
        sys.exit(1)
    write_cmake_out(snippets, args.cmake_out)

if __name__ == "__main__":
    main()
