# Copyright (c) 2024-2025 The Linux Foundation
# SPDX-License-Identifier: Apache-2.0

import logging
import os
import pickle
import re
import subprocess
import sys
from collections import namedtuple
from pathlib import Path

import list_boards
import list_hardware
import list_shields
import yaml
import zephyr_module
from gen_devicetree_rest import VndLookup
from runners.core import ZephyrBinaryRunner

ZEPHYR_BASE = Path(__file__).parents[2]
ZEPHYR_BINDINGS = ZEPHYR_BASE / "dts/bindings"
EDT_PICKLE_PATHS = (
    "zephyr/edt.pickle",
    "hello_world/zephyr/edt.pickle",  # for board targets using sysbuild
)
RUNNERS_YAML_PATHS = (
    "zephyr/runners.yaml",
    "hello_world/zephyr/runners.yaml",  # for board targets using sysbuild
)

logger = logging.getLogger(__name__)


class DeviceTreeUtils:
    _compat_description_cache = {}

    @classmethod
    def get_first_sentence(cls, text):
        """Extract the first sentence from a text block (typically a node description).

        Args:
            text: The text to extract the first sentence from.

        Returns:
            The first sentence found in the text, or the entire text if no sentence
            boundary is found.
        """
        if not text:
            return ""

        text = text.replace('\n', ' ')
        # Split by double spaces to get paragraphs
        paragraphs = text.split('  ')
        first_paragraph = paragraphs[0].strip()

        # Look for a period followed by a space in the first paragraph
        period_match = re.search(r'(.*?)\.(?:\s|$)', first_paragraph)
        if period_match:
            return period_match.group(1).strip()

        # If no period in the first paragraph, return the entire first paragraph
        return first_paragraph

    @classmethod
    def get_cached_description(cls, node):
        """Get the cached description for a devicetree node.

        Args:
            node: A devicetree node object with matching_compat and description attributes.

        Returns:
            The cached description for the node's compatible, creating it if needed.
        """
        return cls._compat_description_cache.setdefault(
            node.matching_compat, cls.get_first_sentence(node.description)
        )


def guess_file_from_patterns(directory, patterns, name, extensions):
    for pattern in patterns:
        for ext in extensions:
            matching_file = next(directory.glob(pattern.format(name=name, ext=ext)), None)
            if matching_file:
                return matching_file
    return None


def guess_image(board_or_shield):
    img_exts = ("webp", "png", "jpg", "jpeg")

    name_parts = board_or_shield.name.split('_')
    prefix_matches = []
    for i in range(len(name_parts) - 1, 0, -1):
        partial_name = '_'.join(name_parts[:i])
        prefix_matches.append(f"**/{partial_name}.{{ext}}")

    patterns = (
        *prefix_matches,
        "**/*{name}*.{ext}",
        "**/*.{ext}",
    )

    img_file = guess_file_from_patterns(
        board_or_shield.dir, patterns, board_or_shield.name, img_exts
    )

    return (img_file.relative_to(ZEPHYR_BASE)).as_posix() if img_file else None


def guess_doc_page(board_or_shield):
    patterns = (
        "doc/index.{ext}",
        "**/{name}.{ext}",
        "**/*{name}*.{ext}",
        "**/*.{ext}",
    )
    doc_file = guess_file_from_patterns(
        board_or_shield.dir, patterns, board_or_shield.name, ["rst"]
    )
    return doc_file


def gather_board_build_info(twister_out_dir):
    """Gather EDT objects and runners info for each board from twister output directory.

    Args:
        twister_out_dir: Path object pointing to twister output directory

    Returns:
        A tuple of two dictionaries:
           - A dictionary mapping board names to a dictionary of board targets and their EDT.
             objects.
             The structure is: {board_name: {board_target: edt_object}}
           - A dictionary mapping board names to a dictionary of board targets and their runners
             info.
             The structure is: {board_name: {board_target: runners_info}}
    """
    board_devicetrees = {}
    board_runners = {}
    if not twister_out_dir.exists():
        return board_devicetrees, board_runners

    # Find all build_info.yml files in twister-out
    build_info_files = list(twister_out_dir.glob("*/**/build_info.yml"))

    for build_info_file in build_info_files:
        edt_pickle_file = None
        for pickle_path in EDT_PICKLE_PATHS:
            maybe_file = build_info_file.parent / pickle_path
            if maybe_file.exists():
                edt_pickle_file = maybe_file
                break

        if not edt_pickle_file:
            continue

        runners_yaml_file = None
        for runners_yaml_path in RUNNERS_YAML_PATHS:
            maybe_file = build_info_file.parent / runners_yaml_path
            if maybe_file.exists():
                runners_yaml_file = maybe_file
                break

        try:
            with open(build_info_file) as f:
                build_info = yaml.safe_load(f)
                board_info = build_info.get('cmake', {}).get('board', {})
                board_name = board_info.get('name')
                qualifier = board_info.get('qualifiers', '')
                revision = board_info.get('revision', '')

                board_target = board_name
                if revision != '':
                    board_target = f"{board_target}@{revision}"
                if qualifier:
                    board_target = f"{board_target}/{qualifier}"

                with open(edt_pickle_file, 'rb') as f:
                    edt = pickle.load(f)
                    board_devicetrees.setdefault(board_name, {})[board_target] = edt

                if runners_yaml_file:
                    with open(runners_yaml_file) as f:
                        runners_yaml = yaml.safe_load(f)
                        board_runners.setdefault(board_name, {})[board_target] = runners_yaml

        except Exception as e:
            logger.error(f"Error processing build info file {build_info_file}: {e}")

    return board_devicetrees, board_runners


def run_twister_cmake_only(outdir, vendor_filter):
    """Run twister in cmake-only mode to generate build info files.

    Args:
        outdir: Directory where twister should output its files
        vendor_filter: Limit build info to boards from listed vendors
    """
    twister_cmd = [
        sys.executable,
        f"{ZEPHYR_BASE}/scripts/twister",
        "-T",
        "samples/hello_world/",
        "-M",
        *[arg for path in EDT_PICKLE_PATHS for arg in ('--keep-artifacts', path)],
        *[arg for path in RUNNERS_YAML_PATHS for arg in ('--keep-artifacts', path)],
        "--cmake-only",
        "-v",
        "--outdir",
        str(outdir),
    ]

    if vendor_filter:
        for vendor in vendor_filter:
            twister_cmd += ["--vendor", vendor]
    else:
        twister_cmd += ["--all"]

    minimal_env = {
        'PATH': os.environ.get('PATH', ''),
        'ZEPHYR_BASE': str(ZEPHYR_BASE),
        'HOME': os.environ.get('HOME', ''),
        'PYTHONPATH': os.environ.get('PYTHONPATH', ''),
    }

    try:
        subprocess.run(twister_cmd, check=True, cwd=ZEPHYR_BASE, env=minimal_env)
    except subprocess.CalledProcessError as e:
        logger.warning(f"Failed to run Twister, list of hw features might be incomplete.\n{e}")


def get_catalog(generate_hw_features=False, hw_features_vendor_filter=None):
    """Get the board catalog.

    Args:
        generate_hw_features: If True, run twister to generate hardware features information.
        hw_features_vendor_filter: If generate_hw_features is True, limit hardware feature
                                   information generation to boards from this list of vendors.
    """
    import tempfile

    vnd_lookup = VndLookup(ZEPHYR_BASE / "dts/bindings/vendor-prefixes.txt", [])

    module_settings = {
        "arch_root": [ZEPHYR_BASE],
        "board_root": [ZEPHYR_BASE],
        "soc_root": [ZEPHYR_BASE],
    }

    for module in zephyr_module.parse_modules(ZEPHYR_BASE):
        for key in module_settings:
            root = module.meta.get("build", {}).get("settings", {}).get(key)
            if root is not None:
                module_settings[key].append(Path(module.project) / root)

    Args = namedtuple(
        "args", ["arch_roots", "board_roots", "soc_roots", "board_dir", "board", "arch"]
    )
    args_find_boards = Args(
        arch_roots=module_settings["arch_root"],
        board_roots=module_settings["board_root"],
        soc_roots=module_settings["soc_root"],
        board_dir=[],
        board=None,
        arch=None,
    )

    boards = list_boards.find_v2_boards(args_find_boards)
    shields = list_shields.find_shields(args_find_boards)
    systems = list_hardware.find_v2_systems(args_find_boards)
    archs = list_hardware.find_v2_archs(args_find_boards)
    board_catalog = {}
    shield_catalog = {}
    board_devicetrees = {}
    board_runners = {}

    if generate_hw_features:
        logger.info("Running twister in cmake-only mode to get Devicetree files for all boards")
        with tempfile.TemporaryDirectory() as tmp_dir:
            run_twister_cmake_only(tmp_dir, hw_features_vendor_filter)
            board_devicetrees, board_runners = gather_board_build_info(Path(tmp_dir))
    else:
        logger.info("Skipping generation of supported hardware features.")

    for board in boards.values():
        vendor = board.vendor or "others"
        socs = {soc.name for soc in board.socs}
        full_name = board.full_name or board.name
        doc_page = guess_doc_page(board)

        supported_features = {}
        compatibles = {}

        # Use pre-gathered build info and DTS files
        if board.name in board_devicetrees:
            for board_target, edt in board_devicetrees[board.name].items():
                features = {}
                target_compatibles = set()
                for node in edt.nodes:
                    if node.binding_path is None:
                        continue

                    binding_path = Path(node.binding_path)
                    is_custom_binding = False
                    if binding_path.is_relative_to(ZEPHYR_BINDINGS):
                        binding_type = binding_path.relative_to(ZEPHYR_BINDINGS).parts[0]
                    else:
                        binding_type = "misc"
                        is_custom_binding = True

                    if node.matching_compat is None:
                        continue

                    # skip "zephyr,xxx" compatibles (unless board is native_sim, since in this
                    # case the "zephyr,"-prefixed peripherals are legitimate)
                    if node.matching_compat.startswith("zephyr,") and board.name != "native_sim":
                        continue

                    description = DeviceTreeUtils.get_cached_description(node)
                    title = node.title
                    filename = node.filename
                    lineno = node.lineno
                    locations = set()
                    if Path(filename).is_relative_to(ZEPHYR_BASE):
                        filename = Path(filename).relative_to(ZEPHYR_BASE)
                        if filename.parts[0] == "boards":
                            locations.add("board")
                        else:
                            locations.add("soc")

                    existing_feature = features.get(binding_type, {}).get(node.matching_compat)
                    target_compatibles.add(node.matching_compat)

                    node_info = {
                        "filename": str(filename),
                        "lineno": lineno,
                        "dts_path": Path(node.filename),
                        "binding_path": Path(node.binding_path),
                    }
                    node_list_key = "okay_nodes" if node.status == "okay" else "disabled_nodes"

                    if existing_feature:
                        locations.update(existing_feature["locations"])
                        existing_feature.setdefault(node_list_key, []).append(node_info)
                        continue

                    feature_data = {
                        "description": description,
                        "title": title,
                        "custom_binding": is_custom_binding,
                        "locations": locations,
                        "okay_nodes": [],
                        "disabled_nodes": [],
                    }
                    feature_data[node_list_key].append(node_info)

                    features.setdefault(binding_type, {})[node.matching_compat] = feature_data

                # Store features and compatibles for this specific target
                supported_features[board_target] = features
                compatibles[board_target] = list(target_compatibles)

        board_runner_info = {}
        if board.name in board_runners:
            # Assume all board targets have the same runners so only consider the runners
            # for the first board target.
            r = list(board_runners[board.name].values())[0]
            board_runner_info["runners"] = r.get("runners")
            board_runner_info["flash-runner"] = r.get("flash-runner")
            board_runner_info["debug-runner"] = r.get("debug-runner")

        # Grab all the twister files for this board and use them to figure out all the archs it
        # supports.
        board_archs = set()
        for pattern in (f"{board.name}*.yaml", "twister.yaml"):
            for twister_file in board.dir.glob(pattern):
                try:
                    with open(twister_file) as f:
                        board_data = yaml.safe_load(f)
                        board_archs.add(board_data.get("arch"))
                except Exception as e:
                    logger.error(f"Error parsing twister file {twister_file}: {e}")

        if doc_page and doc_page.is_relative_to(ZEPHYR_BASE):
            doc_page_path = doc_page.relative_to(ZEPHYR_BASE).as_posix()
        else:
            doc_page_path = None

        board_catalog[board.name] = {
            "name": board.name,
            "full_name": full_name,
            "doc_page": doc_page_path,
            "vendor": vendor,
            "archs": list(board_archs),
            "socs": list(socs),
            "revision_default": board.revision_default,
            "supported_features": supported_features,
            "compatibles": compatibles,
            "image": guess_image(board),
            # runners
            "supported_runners": board_runner_info.get("runners", []),
            "flash_runner": board_runner_info.get("flash-runner", ""),
            "debug_runner": board_runner_info.get("debug-runner", ""),
        }

    socs_hierarchy = {}
    for soc in systems.get_socs():
        family = soc.family or "<no family>"
        series = soc.series or "<no series>"
        socs_hierarchy.setdefault(family, {}).setdefault(series, []).append(soc.name)

    available_runners = {}
    for runner in ZephyrBinaryRunner.get_runners():
        available_runners[runner.name()] = {
            "name": runner.name(),
            "commands": runner.capabilities().commands,
        }

    arch_catalog = {
        arch['name']: {
            "name": arch['name'],
            "full_name": arch.get('full_name', arch['name']),
        }
        for arch in archs['archs']
    }

    for shield in shields:
        doc_page = guess_doc_page(shield)
        if doc_page and doc_page.is_relative_to(ZEPHYR_BASE):
            doc_page_path = doc_page.relative_to(ZEPHYR_BASE).as_posix()
        else:
            doc_page_path = None

        shield_catalog[shield.name] = {
            "name": shield.name,
            "full_name": shield.full_name or shield.name,
            "vendor": shield.vendor or "others",
            "doc_page": doc_page_path,
            "image": guess_image(shield),
            "supported_features": shield.supported_features or [],
        }

    return {
        "boards": board_catalog,
        "shields": shield_catalog,
        "vendors": {**vnd_lookup.vnd2vendor, "others": "Other/Unknown"},
        "socs": socs_hierarchy,
        "archs": arch_catalog,
        "runners": available_runners,
    }
