#!/usr/bin/env python3

# Copyright (c) 2021 Nordic Semiconductor ASA
# SPDX-License-Identifier: Apache-2.0

"""
Pinctrl Migration Utility Script for nRF Boards
###############################################

This script can be used to automatically migrate the Devicetree files of
nRF-based boards using the old <signal>-pin properties to select peripheral
pins. The script will parse a board Devicetree file and will first adjust that
file by removing old pin-related properties replacing them with pinctrl states.
A board-pinctrl.dtsi file will be generated containing the configuration for
all pinctrl states. Note that script will also work on files that have been
partially ported.

.. warning::
    This script uses a basic line based parser, therefore not all valid
    Devicetree files will be converted correctly. **ADJUSTED/GENERATED FILES
    MUST BE MANUALLY REVIEWED**.

Known limitations: All SPI nodes will be assumed to be a master device.

Usage::

    python3 pinctrl_nrf_migrate.py
        -i path/to/board.dts
        [--no-backup]
        [--skip-nrf-check]
        [--header ""]

Example:

.. code-block:: devicetree

    /* Old board.dts */
    ...
    &uart0 {
        ...
        tx-pin = <5>;
        rx-pin = <33>;
        rx-pull-up;
        ...
    };

    /* Adjusted board.dts */
    ...
    #include "board-pinctrl.dtsi"
    ...
    &uart0 {
        ...
        pinctrl-0 = <&uart0_default>;
        pinctrl-1 = <&uart0_sleep>;
        pinctrl-names = "default", "sleep";
        ...
    };

    /* Generated board-pinctrl.dtsi */
    &pinctrl {
        uart0_default: uart0_default {
            group1 {
                psels = <NRF_PSEL(UART_TX, 0, 5);
            };
            group2 {
                psels = <NRF_PSEL(UART_RX, 1, 1)>;
                bias-pull-up;
            };
        };

        uart0_sleep: uart0_sleep {
            group1 {
                psels = <NRF_PSEL(UART_TX, 0, 5)>,
                        <NRF_PSEL(UART_RX, 1, 1)>;
                low-power-enable;
            };
        };
    };
"""

import argparse
import enum
from pathlib import Path
import re
import shutil
from typing import Callable, Optional, Dict, List


#
# Data types and containers
#


class PIN_CONFIG(enum.Enum):
    """Pin configuration attributes"""

    PULL_UP = "bias-pull-up"
    PULL_DOWN = "bias-pull-down"
    LOW_POWER = "low-power-enable"
    NORDIC_INVERT = "nordic,invert"


class Device(object):
    """Device configuration class"""

    def __init__(
        self,
        pattern: str,
        callback: Callable,
        signals: Dict[str, str],
        needs_sleep: bool,
    ) -> None:
        self.pattern = pattern
        self.callback = callback
        self.signals = signals
        self.needs_sleep = needs_sleep
        self.attrs = {}


class SignalMapping(object):
    """Signal mapping (signal<>pin)"""

    def __init__(self, signal: str, pin: int) -> None:
        self.signal = signal
        self.pin = pin


class PinGroup(object):
    """Pin group"""

    def __init__(self, pins: List[SignalMapping], config: List[PIN_CONFIG]) -> None:
        self.pins = pins
        self.config = config


class PinConfiguration(object):
    """Pin configuration (mapping and configuration)"""

    def __init__(self, mapping: SignalMapping, config: List[PIN_CONFIG]) -> None:
        self.mapping = mapping
        self.config = config


class DeviceConfiguration(object):
    """Device configuration"""

    def __init__(self, name: str, pins: List[PinConfiguration]) -> None:
        self.name = name
        self.pins = pins

    def add_signal_config(self, signal: str, config: PIN_CONFIG) -> None:
        """Add configuration to signal"""
        for pin in self.pins:
            if signal == pin.mapping.signal:
                pin.config.append(config)
                return

        self.pins.append(PinConfiguration(SignalMapping(signal, -1), [config]))

    def set_signal_pin(self, signal: str, pin: int) -> None:
        """Set signal pin"""
        for pin_ in self.pins:
            if signal == pin_.mapping.signal:
                pin_.mapping.pin = pin
                return

        self.pins.append(PinConfiguration(SignalMapping(signal, pin), []))


#
# Content formatters and writers
#


def gen_pinctrl(
    configs: List[DeviceConfiguration], input_file: Path, header: str
) -> None:
    """Generate board-pinctrl.dtsi file

    Args:
        configs: Board configs.
        input_file: Board DTS file.
    """

    last_line = 0

    pinctrl_file = input_file.parent / (input_file.stem + "-pinctrl.dtsi")
    # append content before last node closing
    if pinctrl_file.exists():
        content = open(pinctrl_file).readlines()
        for i, line in enumerate(content[::-1]):
            if re.match(r"\s*};.*", line):
                last_line = len(content) - (i + 1)
                break

    out = open(pinctrl_file, "w")

    if not last_line:
        out.write(header)
        out.write("&pinctrl {\n")
    else:
        for line in content[:last_line]:
            out.write(line)

    for config in configs:
        # create pin groups with common configuration (default state)
        default_groups: List[PinGroup] = []
        for pin in config.pins:
            merged = False
            for group in default_groups:
                if group.config == pin.config:
                    group.pins.append(pin.mapping)
                    merged = True
                    break
            if not merged:
                default_groups.append(PinGroup([pin.mapping], pin.config))

        # create pin group for low power state
        group = PinGroup([], [PIN_CONFIG.LOW_POWER])
        for pin in config.pins:
            group.pins.append(pin.mapping)
        sleep_groups = [group]

        # generate default and sleep state entries
        out.write(f"\t{config.name}_default: {config.name}_default {{\n")
        out.write(fmt_pinctrl_groups(default_groups))
        out.write("\t};\n\n")

        out.write(f"\t{config.name}_sleep: {config.name}_sleep {{\n")
        out.write(fmt_pinctrl_groups(sleep_groups))
        out.write("\t};\n\n")

    if not last_line:
        out.write("};\n")
    else:
        for line in content[last_line:]:
            out.write(line)

    out.close()


def board_is_nrf(content: List[str]) -> bool:
    """Check if board is nRF based.

    Args:
        content: DT file content as list of lines.

    Returns:
        True if board is nRF based, False otherwise.
    """

    for line in content:
        m = re.match(r'^#include\s+(?:"|<).*nrf.*(?:>|").*', line)
        if m:
            return True

    return False


def fmt_pinctrl_groups(groups: List[PinGroup]) -> str:
    """Format pinctrl groups.

    Example generated content::

        group1 {
            psels = <NRF_PSEL(UART_TX, 0, 5)>;
        };
        group2 {
            psels = <NRF_PSEL(UART_RX, 1, 1)>;
            bias-pull-up;
        };

    Returns:
        Generated groups.
    """

    content = ""

    for i, group in enumerate(groups):
        content += f"\t\tgroup{i + 1} {{\n"

        # write psels entries
        for i, mapping in enumerate(group.pins):
            prefix = "psels = " if i == 0 else "\t"
            suffix = ";" if i == len(group.pins) - 1 else ","
            pin = mapping.pin
            port = 0 if pin < 32 else 1
            if port == 1:
                pin -= 32
            content += (
                f"\t\t\t{prefix}<NRF_PSEL({mapping.signal}, {port}, {pin})>{suffix}\n"
            )

        # write all pin configuration (bias, low-power, etc.)
        for entry in group.config:
            content += f"\t\t\t{entry.value};\n"

        content += "\t\t};\n"

    return content


def fmt_states(device: str, indent: str, needs_sleep: bool) -> str:
    """Format state entries for the given device.

    Args:
        device: Device name.
        indent: Indentation.
        needs_sleep: If sleep entry is needed.

    Returns:
        State entries to be appended to the device.
    """

    if needs_sleep:
        return "\n".join(
            (
                f"{indent}pinctrl-0 = <&{device}_default>;",
                f"{indent}pinctrl-1 = <&{device}_sleep>;",
                f'{indent}pinctrl-names = "default", "sleep";\n',
            )
        )
    else:
        return "\n".join(
            (
                f"{indent}pinctrl-0 = <&{device}_default>;",
                f'{indent}pinctrl-names = "default";\n',
            )
        )


def insert_pinctrl_include(content: List[str], board: str) -> None:
    """Insert board pinctrl include if not present.

    Args:
        content: DT file content as list of lines.
        board: Board name
    """

    already = False
    include_last_line = -1
    root_line = -1

    for i, line in enumerate(content):
        # check if file already includes a board pinctrl file
        m = re.match(r'^#include\s+".*-pinctrl\.dtsi".*', line)
        if m:
            already = True
            continue

        # check if including
        m = re.match(r'^#include\s+(?:"|<)(.*)(?:>|").*', line)
        if m:
            include_last_line = i
            continue

        # check for root entry
        m = re.match(r"^\s*/\s*{.*", line)
        if m:
            root_line = i
            break

    if include_last_line < 0 and root_line < 0:
        raise ValueError("Unexpected DT file content")

    if not already:
        if include_last_line >= 0:
            line = include_last_line + 1
        else:
            line = max(0, root_line - 1)

        content.insert(line, f'#include "{board}-pinctrl.dtsi"\n')


def adjust_content(content: List[str], board: str) -> List[DeviceConfiguration]:
    """Adjust content

    Args:
        content: File content to be adjusted.
        board: Board name.
    """

    configs: List[DeviceConfiguration] = []
    level = 0
    in_device = False
    states_written = False

    new_content = []

    for line in content:
        # look for a device reference node (e.g. &uart0)
        if not in_device:
            m = re.match(r"^[^&]*&([a-z0-9]+)\s*{[^}]*$", line)
            if m:
                # check if device requires processing
                current_device = None
                for device in DEVICES:
                    if re.match(device.pattern, m.group(1)):
                        current_device = device
                        indent = ""
                        config = DeviceConfiguration(m.group(1), [])
                        configs.append(config)
                        break

                # we are now inside a device node
                level = 1
                in_device = True
                states_written = False
        else:
            # entering subnode (must come after all properties)
            if re.match(r"[^\/\*]*{.*", line):
                level += 1
            # exiting subnode (or device node)
            elif re.match(r"[^\/\*]*}.*", line):
                level -= 1
                in_device = level > 0
            elif current_device:
                # device already ported, drop
                if re.match(r"[^\/\*]*pinctrl-\d+.*", line):
                    current_device = None
                    configs.pop()
                # determine indentation
                elif not indent:
                    m = re.match(r"(\s+).*", line)
                    if m:
                        indent = m.group(1)

            # process each device line, append states at the end
            if current_device:
                if level == 1:
                    line = current_device.callback(config, current_device.signals, line)
                if (level == 2 or not in_device) and not states_written:
                    line = (
                        fmt_states(config.name, indent, current_device.needs_sleep)
                        + line
                    )
                    states_written = True
                    current_device = None

        if line:
            new_content.append(line)

    if configs:
        insert_pinctrl_include(new_content, board)

    content[:] = new_content

    return configs


#
# Processing utilities
#


def match_and_store_pin(
    config: DeviceConfiguration, signals: Dict[str, str], line: str
) -> Optional[str]:
    """Match and store a pin mapping.

    Args:
        config: Device configuration.
        signals: Signals name mapping.
        line: Line containing potential pin mapping.

    Returns:
        Line if found a pin mapping, None otherwise.
    """

    # handle qspi special case for io-pins (array case)
    m = re.match(r"\s*io-pins\s*=\s*([\s<>,0-9]+).*", line)
    if m:
        pins = re.sub(r"[<>,]", "", m.group(1)).split()
        for i, pin in enumerate(pins):
            config.set_signal_pin(signals[f"io{i}"], int(pin))
        return

    m = re.match(r"\s*([a-z]+\d?)-pins?\s*=\s*<(\d+)>.*", line)
    if m:
        config.set_signal_pin(signals[m.group(1)], int(m.group(2)))
        return

    return line


#
# Device processing callbacks
#


def process_uart(config: DeviceConfiguration, signals, line: str) -> Optional[str]:
    """Process UART/UARTE devices."""

    # check if line specifies a pin
    if not match_and_store_pin(config, signals, line):
        return

    # check if pull-up is specified
    m = re.match(r"\s*([a-z]+)-pull-up.*", line)
    if m:
        config.add_signal_config(signals[m.group(1)], PIN_CONFIG.PULL_UP)
        return

    return line


def process_spi(config: DeviceConfiguration, signals, line: str) -> Optional[str]:
    """Process SPI devices."""

    # check if line specifies a pin
    if not match_and_store_pin(config, signals, line):
        return

    # check if pull-up is specified
    m = re.match(r"\s*miso-pull-up.*", line)
    if m:
        config.add_signal_config(signals["miso"], PIN_CONFIG.PULL_UP)
        return

    # check if pull-down is specified
    m = re.match(r"\s*miso-pull-down.*", line)
    if m:
        config.add_signal_config(signals["miso"], PIN_CONFIG.PULL_DOWN)
        return

    return line


def process_pwm(config: DeviceConfiguration, signals, line: str) -> Optional[str]:
    """Process PWM devices."""

    # check if line specifies a pin
    if not match_and_store_pin(config, signals, line):
        return

    # check if channel inversion is specified
    m = re.match(r"\s*([a-z0-9]+)-inverted.*", line)
    if m:
        config.add_signal_config(signals[m.group(1)], PIN_CONFIG.NORDIC_INVERT)
        return

    return line


DEVICES = [
    Device(
        r"uart\d",
        process_uart,
        {
            "tx": "UART_TX",
            "rx": "UART_RX",
            "rts": "UART_RTS",
            "cts": "UART_CTS",
        },
        needs_sleep=True,
    ),
    Device(
        r"i2c\d",
        match_and_store_pin,
        {
            "sda": "TWIM_SDA",
            "scl": "TWIM_SCL",
        },
        needs_sleep=True,
    ),
    Device(
        r"spi\d",
        process_spi,
        {
            "sck": "SPIM_SCK",
            "miso": "SPIM_MISO",
            "mosi": "SPIM_MOSI",
        },
        needs_sleep=True,
    ),
    Device(
        r"pdm\d",
        match_and_store_pin,
        {
            "clk": "PDM_CLK",
            "din": "PDM_DIN",
        },
        needs_sleep=False,
    ),
    Device(
        r"qdec",
        match_and_store_pin,
        {
            "a": "QDEC_A",
            "b": "QDEC_B",
            "led": "QDEC_LED",
        },
        needs_sleep=True,
    ),
    Device(
        r"qspi",
        match_and_store_pin,
        {
            "sck": "QSPI_SCK",
            "io0": "QSPI_IO0",
            "io1": "QSPI_IO1",
            "io2": "QSPI_IO2",
            "io3": "QSPI_IO3",
            "csn": "QSPI_CSN",
        },
        needs_sleep=True,
    ),
    Device(
        r"pwm\d",
        process_pwm,
        {
            "ch0": "PWM_OUT0",
            "ch1": "PWM_OUT1",
            "ch2": "PWM_OUT2",
            "ch3": "PWM_OUT3",
        },
        needs_sleep=True,
    ),
    Device(
        r"i2s\d",
        match_and_store_pin,
        {
            "sck": "I2S_SCK_M",
            "lrck": "I2S_LRCK_M",
            "sdout": "I2S_SDOUT",
            "sdin": "I2S_SDIN",
            "mck": "I2S_MCK",
        },
        needs_sleep=False,
    ),
]
"""Supported devices and associated configuration"""


def main(input_file: Path, no_backup: bool, skip_nrf_check: bool, header: str) -> None:
    """Entry point

    Args:
        input_file: Input DTS file.
        no_backup: Do not create backup files.
    """

    board_name = input_file.stem
    content = open(input_file).readlines()

    if not skip_nrf_check and not board_is_nrf(content):
        print(f"Board {board_name} is not nRF based, terminating")
        return

    if not no_backup:
        backup_file = input_file.parent / (board_name + ".bck" + input_file.suffix)
        shutil.copy(input_file, backup_file)

    configs = adjust_content(content, board_name)

    if configs:
        with open(input_file, "w") as f:
            f.writelines(content)

        gen_pinctrl(configs, input_file, header)

        print(f"Board {board_name} Devicetree file has been converted")
    else:
        print(f"Nothing to be converted for {board_name}")


if __name__ == "__main__":
    parser = argparse.ArgumentParser("pinctrl migration utility for nRF", allow_abbrev=False)
    parser.add_argument(
        "-i", "--input", type=Path, required=True, help="Board DTS file"
    )
    parser.add_argument(
        "--no-backup", action="store_true", help="Do not create backup files"
    )
    parser.add_argument(
        "--skip-nrf-check",
        action="store_true",
        help="Skip checking if board is nRF-based",
    )
    parser.add_argument(
        "--header", default="", type=str, help="Header to be prepended to pinctrl files"
    )
    args = parser.parse_args()

    main(args.input, args.no_backup, args.skip_nrf_check, args.header)
