# cspell:ignore jsons

import configparser
import dataclasses
import functools
import pathlib
import re
import time
from dataclasses import dataclass
from enum import Enum
from logging import Logger
from typing import Optional

from ..allspice import AllSpice
from ..apiobject import Ref, Repository
from ..exceptions import NotYetGeneratedException

SLEEP_FOR_GENERATED_JSON = 1
"""The amount of time to sleep between attempts to fetch generated JSON files."""

MAX_RETRIES_FOR_GENERATED_JSON = 10
"""The maximum number of times to retry fetching generated JSON files."""

PCB_FOOTPRINT_ATTR_NAME = "PCB Footprint"

PART_REFERENCE_ATTR_NAME = "Part Reference"
"""The name of the part reference attribute in OrCAD projects."""

REPETITIONS_REGEX = re.compile(r"Repeat\((\w+),(\d+),(\d+)\)")

DESIGNATOR_COLUMN_NAME = "Designator"
"""The name of the designator attribute in Altium projects."""

LOGICAL_DESIGNATOR = "_logical_designator"

ComponentAttributes = dict[str, str]


class SupportedTool(Enum):
    """
    ECAD tools supported by list_components.
    """

    ALTIUM = "altium"
    ORCAD = "orcad"
    SYSTEM_CAPTURE = "system_capture"


class VariationKind(Enum):
    FITTED_MOD_PARAMS = 0
    NOT_FITTED = 1
    ALT_COMP = 2


@dataclass
class AltiumChildSheet:
    """
    A child sheet of a schematic in the hierarchy. The fields here are
    Altium-specific.
    """

    class ChildSheetKind(Enum):
        """
        The type of child sheet.
        """

        DOCUMENT = "Document"
        """
        A document in the project.
        """

        DEVICE_SHEET = "DeviceSheet"
        """
        A Device Sheet external to the project.
        """

    kind: ChildSheetKind
    """
    The type of child sheet referenced.
    """

    unique_id: str
    """
    The Unique ID of the SheetRef instance. Note that this may not be unique
    for child sheets, as the same sheetref can be expanded into multiple
    instances via channels.
    """

    name: str
    """
    The name of the child sheet. This can be a path for documents or a name for
    a device sheet.
    """

    channel_name: str
    """
    See https://www.altium.com/documentation/altium-designer/creating-multi-channel-design#the-repeat-keyword
    """


SchdocHierarchy = dict[str, list[AltiumChildSheet]]
"""
Mapping of the name of a sheet to its children.
"""


def list_components(
    allspice_client: AllSpice,
    repository: Repository,
    source_file: str,
    variant: Optional[str] = None,
    ref: Ref = "main",
    combine_multi_part: bool = False,
    design_reuse_repos: list[Repository] = [],
) -> list[ComponentAttributes]:
    """
    Get a list of all components in a schematic.


    Note that special attributes are added by this function depending on
    the project tool. For Altium projects, these are "_part_id",
    "_description", "_unique_id" and "_kind", which are the Library
    Reference, Description, Unique ID and Component Type respectively. For
    OrCAD and System Capture projects, "_name" is added, which is the name
    of the component, and "_reference" and "_logical_reference" may be
    added, which are the name of the component, and the logical reference
    of a multi-part component respectively.

    :param client: An AllSpice client instance.
    :param repository: The repository containing the schematic.
    :param source_file: The path to the schematic file from the repo root. The
        source file must be a PrjPcb file for Altium projects, a DSN file for
        OrCAD projects or an SDAX file for System Capture projects. For
        example, if the schematic is in the folder "Schematics" and the file is
        named "example.DSN", the path would be "Schematics/example.DSN".
    :param variant: The variant to apply to the components. If not None, the
        components will be filtered and modified according to the variant. Only
        applies to Altium projects.
    :param ref: Optional git ref to check. This can be a commit hash, branch
        name, or tag name. Default is "main", i.e. the main branch.
    :param combine_multi_part: If True, multi-part components will be combined
        into a single component.
    :return: A list of all components in the schematic. Each component is a
        dictionary with the keys being the attributes of the component and the
        values being the values of the attributes.
    """

    project_tool = infer_project_tool(source_file)
    match project_tool:
        case SupportedTool.ALTIUM:
            return list_components_for_altium(
                allspice_client,
                repository,
                source_file,
                variant=variant,
                ref=ref,
                combine_multi_part=combine_multi_part,
                design_reuse_repos=design_reuse_repos,
            )
        case SupportedTool.ORCAD:
            if variant:
                raise ValueError("Variant is not supported for OrCAD projects.")

            return list_components_for_orcad(
                allspice_client,
                repository,
                source_file,
                ref=ref,
                combine_multi_part=combine_multi_part,
            )
        case SupportedTool.SYSTEM_CAPTURE:
            if variant:
                raise ValueError("Variant is not supported for System Capture projects.")

            return list_components_for_system_capture(
                allspice_client,
                repository,
                source_file,
                ref=ref,
            )


def list_components_for_altium(
    allspice_client: AllSpice,
    repository: Repository,
    prjpcb_file: str,
    variant: Optional[str] = None,
    ref: Ref = "main",
    combine_multi_part: bool = False,
    design_reuse_repos: list[Repository] = [],
) -> list[ComponentAttributes]:
    """
    Get a list of all components in an Altium project.

    :param client: An AllSpice client instance.
    :param repository: The repository containing the Altium project.
    :param prjpcb_file: The path to the PrjPcb file from the repo root. For
        example, if the PrjPcb file is in the folder "Project" and the file
        is named "example.prjpcb", the path would be "Project/example.prjpcb".
    :param variant: The variant to apply to the components. If not None, the
        components will be filtered and modified according to the variant.
    :param ref: Optional git ref to check. This can be a commit hash, branch
        name, or tag name. Default is "main", i.e. the main branch.
    :param combine_multi_part: If True, multi-part components will be combined
        into a single component.
    :return: A list of all components in the Altium project. Each component is
        a dictionary with the keys being the attributes of the component and the
        values being the values of the attributes. Additionally, `_part_id`,
        `_description`, `_unique_id`, and `_kind` attributes are added to each
        component to store the Library Reference, Description, Unique ID, and
        Component Type respectively.
    """

    allspice_client.logger.info(f"Fetching {prjpcb_file=}")

    # Altium adds the Byte Order Mark to UTF-8 files, so we need to decode the
    # file content with utf-8-sig to remove it.
    prjpcb_file_contents = repository.get_raw_file(prjpcb_file, ref=ref).decode("utf-8-sig")

    prjpcb_ini = configparser.ConfigParser()
    prjpcb_ini.read_string(prjpcb_file_contents)

    if variant is not None:
        try:
            variant_details = _extract_variations(variant, prjpcb_ini)
        except ValueError:
            raise ValueError(
                f"Variant {variant} not found in PrjPcb file. "
                "Please check the name of the variant."
            )
    else:
        # Ensuring variant_details is always bound, even if it is not used.
        variant_details = None

    project_documents, device_sheets = _extract_schdoc_list_from_prjpcb(prjpcb_ini)
    allspice_client.logger.info("Found %d SchDoc files", len(project_documents))
    allspice_client.logger.info("Found %d Device Sheet files", len(device_sheets))

    if not project_documents:
        raise ValueError("No Project Documents found in the PrjPcb file.")

    try:
        annotations_data = _fetch_and_parse_annotation_file(repository, prjpcb_file, ref)
        allspice_client.logger.info("Found annotations file, %d entries", len(annotations_data))
    except Exception as e:
        if device_sheets:
            allspice_client.logger.warning("Failed to fetch annotations file: %s", e)
            allspice_client.logger.warning("Component designators may not be correct.")
        annotations_data = {}

    # Mapping of schdoc file paths from the project file to their JSON
    schdoc_jsons: dict[str, dict] = {}
    # Mapping of device sheet *names* to their JSON.
    device_sheet_jsons: dict[str, dict] = {}

    # When working with Device Sheets, there are three different paths to keep
    # in mind:
    #
    # 1. The path as given in the PrjPcb file: Used to find a possible match in
    #    the current repo, e.g. if it is a monorepo. If not found, we get the
    #    basename and use that to match in the design reuse repos, yielding (2)
    #    after which this is no longer used.
    # 2. The path of the file in the design reuse repository: Used for fetching
    #    the JSON, after which this is no longer used.
    # 3. The name stored in the `filename` property of the SheetRef: This is the
    #    stem of the path. We need to match a SheetRef to the device sheet it is
    #    referring to using this, which is why `device_sheet_jsons` uses these
    #    as the keys.

    for schdoc_file in project_documents:
        schdoc_path_from_repo_root = _resolve_prjpcb_relative_path(schdoc_file, prjpcb_file)

        schdoc_json = _fetch_generated_json(
            repository,
            schdoc_path_from_repo_root,
            ref,
        )
        schdoc_jsons[schdoc_path_from_repo_root] = schdoc_json

    for device_sheet in device_sheets:
        device_sheet_repo, device_sheet_path = _find_device_sheet(
            device_sheet,
            repository,
            prjpcb_file,
            design_reuse_repos,
            allspice_client.logger,
        )
        device_sheet_json = _fetch_generated_json(
            device_sheet_repo,
            device_sheet_path.as_posix(),
            # Note the default branch here - we can't assume the same ref is
            # available.
            device_sheet_repo.default_branch,
        )
        device_sheet_jsons[device_sheet_path.stem] = device_sheet_json

    independent_sheets, hierarchy = _build_schdoc_hierarchy(schdoc_jsons, device_sheet_jsons)

    unique_ids_mapping = _create_unique_ids_mapping(prjpcb_ini)
    if device_sheets:
        hierarchy = _correct_device_sheet_reference_unique_ids(
            hierarchy,
            prjpcb_ini,
            allspice_client.logger,
        )

    allspice_client.logger.debug("Independent sheets: %s", independent_sheets)
    allspice_client.logger.debug("Hierarchy: %s", hierarchy)

    # Now we can build a combined mapping of documents and device sheets:
    sheets_to_components = {}
    for schdoc_file, schdoc_json in schdoc_jsons.items():
        schdoc_components = [
            value
            for value in schdoc_json.values()
            if isinstance(value, dict) and value.get("type") == "Component"
        ]
        sheets_to_components[schdoc_file] = schdoc_components
    for device_sheet_name, device_sheet_json in device_sheet_jsons.items():
        device_sheet_components = [
            value
            for value in device_sheet_json.values()
            if isinstance(value, dict) and value.get("type") == "Component"
        ]
        sheets_to_components[device_sheet_name] = device_sheet_components

    components = []

    for independent_sheet in independent_sheets:
        components.extend(
            _extract_components_altium(
                independent_sheet,
                sheets_to_components,
                hierarchy,
                parent_sheet_id="",
                current_sheet=None,
            )
        )

    # At this stage, we have components where the designators are not final.

    if annotations_data:
        components = _apply_annotation_file(
            components,
            annotations_data,
            unique_ids_mapping,
            allspice_client.logger,
        )
    else:
        components = _compute_repetitions(components)

    if combine_multi_part:
        # Multi part components must be combined *after* we've processed
        # repetitions and before we apply variations. This is because each
        # repetition of a multi-part component is treated as a separate
        # component, and they can be present across sheets. We need to combine
        # them into a single component before applying variations, as Altium
        # variations will apply to the combined component.
        components = _combine_multi_part_components_for_altium(components)

    if variant is not None:
        if variant_details is None:
            # This should never happen, but mypy doesn't know that.
            raise ValueError(f"Variant {variant} not found in PrjPcb file.")

        components = _apply_variations(components, variant_details, allspice_client.logger)

    return _filter_blank_components(components, allspice_client.logger)


def list_components_for_orcad(
    allspice_client: AllSpice,
    repository: Repository,
    dsn_path: str,
    ref: Ref = "main",
    combine_multi_part: bool = False,
) -> list[ComponentAttributes]:
    """
    Get a list of all components in an OrCAD DSN schematic.

    :param client: An AllSpice client instance.
    :param repository: The repository containing the OrCAD schematic.
    :param dsn_path: The path to the OrCAD DSN file from the repo root. For
        example, if the schematic is in the folder "Schematics" and the file
        is named "example.dsn", the path would be "Schematics/example.dsn".
    :param ref: Optional git ref to check. This can be a commit hash, branch
        name, or tag name. Default is "main", i.e. the main branch.
    :param combine_multi_part: If True, multi-part components will be combined
        into a single component.
    :return: A list of all components in the OrCAD schematic. Each component is
        a dictionary with the keys being the attributes of the component and the
        values being the values of the attributes. A `_name` attribute is added
        to each component to store the name of the component.
    """

    components = _list_components_multi_page_schematic(allspice_client, repository, dsn_path, ref)

    if combine_multi_part:
        components = _combine_multi_part_components_for_orcad(components)

    return components


def list_components_for_system_capture(
    allspice_client: AllSpice,
    repository: Repository,
    sdax_path: str,
    ref: Ref = "main",
) -> list[ComponentAttributes]:
    """
    Get a list of all components in a System Capture SDAX schematic.

    :param client: An AllSpice client instance.
    :param repository: The repository containing the System Capture schematic.
    :param sdax_path: The path to the System Capture SDAX file from the repo
        root. For example, if the schematic is in the folder "Schematics" and
        the file is named "example.sdax", the path would be
        "Schematics/example.sdax".
    :param ref: Optional git ref to check. This can be a commit hash, branch
        name, or tag name. Default is "main", i.e. the main branch.
    """

    return _list_components_multi_page_schematic(allspice_client, repository, sdax_path, ref)


def infer_project_tool(source_file: str) -> SupportedTool:
    """
    Infer the ECAD tool used in a project from the file extension.
    """

    if source_file.lower().endswith(".prjpcb"):
        return SupportedTool.ALTIUM
    elif source_file.lower().endswith(".dsn"):
        return SupportedTool.ORCAD
    elif source_file.lower().endswith(".sdax"):
        return SupportedTool.SYSTEM_CAPTURE
    else:
        raise ValueError("""
The source file for generate_bom must be:

- A PrjPcb file for Altium projects; or
- A DSN file for OrCAD projects; or
- An SDAX file for System Capture projects.
        """)


def _list_components_multi_page_schematic(
    allspice_client: AllSpice,
    repository: Repository,
    schematic_path: str,
    ref: Ref,
) -> list[dict[str, str]]:
    """
    Internal function for getting all components from a multi-page schematic.

    This pattern is followed by OrCAD and System Capture, and potentially other
    formats in the future.
    """

    allspice_client.logger.debug(
        f"Listing components in {schematic_path=} from {repository.get_full_name()} on {ref=}"
    )

    # Get the generated JSON for the schematic.
    schematic_json = _fetch_generated_json(repository, schematic_path, ref)
    pages = schematic_json["pages"]
    components = []

    for page in pages:
        for component in page["components"].values():
            component_attributes = _component_attributes_multi_page(component)
            components.append(component_attributes)

    return _filter_blank_components(components, allspice_client.logger)


def _fetch_generated_json(repo: Repository, file_path: str, ref: Ref) -> dict:
    attempts = 0
    while attempts < MAX_RETRIES_FOR_GENERATED_JSON:
        try:
            return repo.get_generated_json(file_path, ref=ref)
        except NotYetGeneratedException:
            attempts += 1
            time.sleep(SLEEP_FOR_GENERATED_JSON)

    raise TimeoutError(
        f"Failed to fetch JSON for {file_path} after {MAX_RETRIES_FOR_GENERATED_JSON} attempts."
    )


@functools.cache
def _fetch_all_files_in_repo(repo: Repository) -> list[str]:
    """
    Fetch a list of all files in a repository at the default branch.

    :param repo: The repository.
    :returns: A list of all file paths in the repo.
    """

    tree = repo.get_tree(recursive=True)

    return [file.path for file in tree]


def _extract_schdoc_list_from_prjpcb(
    prjpcb_ini: configparser.ConfigParser,
) -> tuple[set[str], set[str]]:
    """
    Extract sets of all schematic files used in this project.

    :param prjpcb_ini: The contents of the PrjPcb file as a ConfigParser.
    :returns: two sets. The first set is of relative paths from the project
        file to all the Schematic Documents in this project, and the second is
        of relative paths to device sheets from the project file.
    """

    schdoc_files = set()
    device_sheets = set()

    for section in prjpcb_ini.sections():
        if section.casefold().startswith("document"):
            document_path = prjpcb_ini[section].get("DocumentPath")
            if document_path and document_path.casefold().endswith(".schdoc"):
                schdoc_files.add(document_path)
        elif section.casefold().startswith("devicesheet"):
            document_path = prjpcb_ini[section].get("DocumentPath")
            if document_path and document_path.casefold().endswith(".schdoc"):
                device_sheets.add(document_path)

    return (schdoc_files, device_sheets)


def _resolve_prjpcb_relative_path(schdoc_path: str, prjpcb_path: str) -> str:
    """
    Convert a relative path to the SchDoc file to an absolute path from the git
    root based on the path to the PrjPcb file.
    """

    # The paths in the PrjPcb file are Windows paths, and ASH will store the
    # paths as Posix paths. We need to resolve the SchDoc path relative to the
    # PrjPcb path (which is a Posix Path, since it is from ASH), and then
    # convert the result into a posix path as a string for use in ASH.
    schdoc = pathlib.PureWindowsPath(schdoc_path)
    prjpcb = pathlib.PurePosixPath(prjpcb_path)
    return (prjpcb.parent / schdoc).as_posix()


def _build_schdoc_hierarchy(
    schematic_document_jsons: dict[str, dict],
    device_sheet_jsons: dict[str, dict],
) -> tuple[set[str], SchdocHierarchy]:
    """
    Build a hierarchy of sheets from a mapping of sheet names to the references
    of their children.

    :param document_jsons: A mapping of document sheet paths from project root
        to their JSON.
    :param device_sheet_jsons: A mapping of device sheet *names* to their JSON.
    :returns: The output of this function is a tuple of two values:

    1. A set of "independent" sheets, which can be taken to be roots of the
       hierarchy.
    2. A mapping of each sheet that has children to a list of tuples, where
       each tuple is a child sheet and the number of repetitions of that child
       sheet in the parent sheet. If a sheet has no children and is not a child
       of any other sheet, it will be mapped to an empty list.
    """

    hierarchy: SchdocHierarchy = {}

    schematic_document_entries = {
        schdoc_file: [value for value in schdoc_json.values() if isinstance(value, dict)]
        for schdoc_file, schdoc_json in schematic_document_jsons.items()
    }
    schematic_document_refs = {
        schdoc_file: [entry for entry in entries if entry.get("type") == "SheetRef"]
        for schdoc_file, entries in schematic_document_entries.items()
    }

    device_sheet_entries = {
        device_sheet_name: [
            value for value in device_sheet_json.values() if isinstance(value, dict)
        ]
        for device_sheet_name, device_sheet_json in device_sheet_jsons.items()
    }
    device_sheet_refs = {
        device_sheet_name: [entry for entry in entries if entry.get("type") == "SheetRef"]
        for device_sheet_name, entries in device_sheet_entries.items()
    }

    # We start by assuming all the document sheets are independent. Device
    # sheets cannot be independent, as they must be referred to by a document
    # sheet or another device sheet.
    independent_sheets = set(schematic_document_refs.keys())
    # This is what we'll use to compare with the sheet names in repetitions.
    schematic_document_names_downcased = {sheet.casefold(): sheet for sheet in independent_sheets}
    device_sheet_names_downcased = {sheet.casefold(): sheet for sheet in device_sheet_refs.keys()}

    # First, we'll build the hierarchy for device sheets, since they cannot
    # point to document sheets.
    for device_sheet_name, device_sheet_refs in device_sheet_refs.items():
        if not device_sheet_refs:
            continue

        repetitions = _extract_repetitions(device_sheet_refs)
        for child_sheet in repetitions:
            # child_sheet should just be a filename
            child_name = device_sheet_names_downcased[child_sheet.name.casefold()]
            # We have to replace the name in the sheet ref with the name in the
            # project file we expect
            child_sheet = dataclasses.replace(
                child_sheet,
                name=child_name,
                kind=AltiumChildSheet.ChildSheetKind.DEVICE_SHEET,
            )
            hierarchy.setdefault(device_sheet_name, []).append(child_sheet)

    # Now for the document sheets, which can point to both other document
    # sheets and device sheets
    for schematic_document_sheet, refs in schematic_document_refs.items():
        if not refs or len(refs) == 0:
            continue

        repetitions = _extract_repetitions(refs)

        for child_sheet in repetitions:
            child_path = _resolve_child_relative_path(
                child_sheet.name, schematic_document_sheet
            ).casefold()
            if child_path in schematic_document_names_downcased:
                child_name = schematic_document_names_downcased[child_path]
            else:
                # Note the `child_sheet` below - we use the bare text without
                # any path resolution for device sheets.
                child_name = device_sheet_names_downcased[child_sheet.name.casefold()]
                # Now we know that this child sheet is a Device Sheet, we can
                # replace the kind.
                child_sheet = dataclasses.replace(
                    child_sheet,
                    kind=AltiumChildSheet.ChildSheetKind.DEVICE_SHEET,
                )

            # We have to replace the name in the sheet ref with the name in the project file we expect
            child_sheet = dataclasses.replace(child_sheet, name=child_name)
            hierarchy.setdefault(schematic_document_sheet, []).append(child_sheet)
            independent_sheets.discard(child_name)

    return (independent_sheets, hierarchy)


def _resolve_child_relative_path(child_path: str, parent_path: str) -> str:
    """
    Converts a relative path in a sheet ref to a POSIX relative path from the
    prjpcb file.

    The returned path is POSIX as we convert them to POSIX paths when fetching
    JSONs.
    """

    child = pathlib.PureWindowsPath(child_path)
    parent = pathlib.PureWindowsPath(parent_path)

    return (parent.parent / child).as_posix()


def _extract_repetitions(sheet_refs: list[dict]) -> list[AltiumChildSheet]:
    """
    Takes a list of sheet references and returns all child sheets, which include
    the repetition count for each child sheet.
    """

    repetitions: list[AltiumChildSheet] = []

    for sheet_ref in sheet_refs:
        sheet_name = (sheet_ref.get("sheet_name", {}) or {}).get("name", "") or ""
        try:
            sheet_file_name = sheet_ref["filename"]
        except Exception:
            raise ValueError(f"Could not find sheet filename in {sheet_ref=}")
        if sheet_file_name is None:
            raise ValueError(
                "Sheet filename is null in for a sheet. Please check sheet references in this "
                "project for an empty file path."
            )

        if match := REPETITIONS_REGEX.search(sheet_name):
            channel_identifier = match.group(1)
            start = int(match.group(2))
            end = int(match.group(3))

            for channel_index in range(start, end + 1):
                repetitions.append(
                    AltiumChildSheet(
                        # At this stage, we assume everything is a document, as
                        # we can't know if it is a device sheet or not.
                        kind=AltiumChildSheet.ChildSheetKind.DOCUMENT,
                        name=sheet_file_name,
                        # The UniqueID of this specific instance has to include
                        # the channel index before it.
                        unique_id=f"{channel_index}{sheet_ref['unique_id']}",
                        channel_name=f"{channel_identifier}{channel_index}",
                    )
                )
        else:
            repetitions.append(
                AltiumChildSheet(
                    # At this stage, we assume everything is a document, as
                    # we can't know if it is a device sheet or not.
                    kind=AltiumChildSheet.ChildSheetKind.DOCUMENT,
                    name=sheet_file_name,
                    unique_id=sheet_ref["unique_id"],
                    channel_name=sheet_name,
                )
            )

    return repetitions


def _component_attributes_altium(component: dict) -> ComponentAttributes:
    """
    Extract the attributes of a component into a dict.

    This also adds some top-level properties of the component that are not
    attributes into the dict.
    """

    attributes = {}

    for key, value in component["attributes"].items():
        attributes[key] = value["text"]

    # The designator attribute has a `value` key which contains the unchanged
    # designator from the schematic file. This is useful when combining multi-
    # part components.
    attributes[LOGICAL_DESIGNATOR] = component["attributes"][DESIGNATOR_COLUMN_NAME]["value"]

    if "part_id" in component:
        attributes["_part_id"] = component["part_id"]
    if "name" in component:
        attributes["_name"] = component["name"]
    if "description" in component:
        attributes["_description"] = component["description"]
    if "unique_id" in component:
        attributes["_unique_id"] = component["unique_id"]
    if "kind" in component:
        attributes["_kind"] = component["kind"]
    if "part_count" in component:
        attributes["_part_count"] = component["part_count"]
        attributes["_current_part_id"] = component["current_part_id"]
    if "pins" in component:
        attributes["_pins"] = component["pins"]

    return attributes


def _component_attributes_multi_page(component: dict) -> ComponentAttributes:
    """
    Extract attributes of components from a multi-page document into a dict.

    This also adds some of the top-level properties of the component that
    are not attributes into the dict.
    """

    component_attributes = {}

    component_attributes["_name"] = component["name"]
    if "reference" in component:
        component_attributes["_reference"] = component.get("reference")
    if "logical_reference" in component:
        component_attributes["_logical_reference"] = component.get("logical_reference")
    if "pins" in component:
        # Multi-sheet documents have pins as an object of ids to pin objects.
        # The id is also inside the pin object, so we can flatten this into an
        # array, which is also compatible with how Altium outputs them.
        component_attributes["_pins"] = list(component["pins"].values())

    for attribute in component["attributes"].values():
        component_attributes[attribute["name"]] = attribute["value"]

    return component_attributes


def _letters_for_repetition(rep: int) -> str:
    """
    Generate the letter suffix for a repetition number. If the repetition is
    more than 26, the suffix will be a combination of letters.
    """

    first = ord("A")
    suffix = ""

    while rep > 0:
        u = (rep - 1) % 26
        letter = chr(u + first)
        suffix = letter + suffix
        rep = (rep - u) // 26

    return suffix


def _extract_components_altium(
    sheet_name: str,
    sheets_to_entries: dict[str, list[dict]],
    hierarchy: SchdocHierarchy,
    parent_sheet_id: str,
    current_sheet: AltiumChildSheet | None = None,
) -> list[ComponentAttributes]:
    """
    Extract the components from a sheet in an Altium project.

    :param sheet_name: The name of the sheet to extract components from. This
        is required because independent sheets do not have an AltiumChildSheet
        instance, and we need the name to look up the components.
    :param sheets_to_entries: A mapping of sheet names to the entries in the
        JSON for that sheet.
    :param hierarchy: The hierarchy of the sheets in the project.
    :param parent_sheet_id: The Unique ID of the parent sheet in the hierarchy.
        For independent sheets, this should be empty.
    :param current_sheet: The AltiumChildSheet instance for the current sheet.
        If this is an independent sheet, this should be None.
    :returns: A list of components in the sheet. The designators of the
        components here are not final and should be patched, either with the
        Annotation file or through the repetitions logic.
    """

    components = []

    if current_sheet is not None:
        current_sheet_id = f"{parent_sheet_id}\\{current_sheet.unique_id}"
    else:
        current_sheet_id = parent_sheet_id

    if sheet_name not in sheets_to_entries:
        return components

    for entry in sheets_to_entries[sheet_name]:
        if entry["type"] != "Component":
            continue

        component = _component_attributes_altium(entry)
        component_unique_id = f"{current_sheet_id}\\{component['_unique_id']}"
        component["_unique_id"] = component_unique_id
        if current_sheet is not None:
            component["_channel_name"] = current_sheet.channel_name

        components.append(component)

    if sheet_name not in hierarchy:
        return components

    for child_sheet in hierarchy[sheet_name]:
        child_components = _extract_components_altium(
            child_sheet.name,
            sheets_to_entries,
            hierarchy,
            current_sheet_id,
            child_sheet,
        )

        components.extend(child_components)

    return components


def _combine_multi_part_components_for_altium(
    components: list[dict[str, str]],
) -> list[dict[str, str]]:
    """
    Combine multi-part Altium components into a single component.

    Altium multi-part components can be distinguished by the `_part_count` and
    `_current_part_id` attributes being present, which respectively store the
    total number of parts and the current part number. If that is the case, the
    `_logical_designator` attribute ties together the different parts of the
    component.
    """

    combined_components = []
    multi_part_components_by_designator = {}

    for component in components:
        if "_part_count" in component and "_current_part_id" in component:
            designator = component[LOGICAL_DESIGNATOR]
            multi_part_components_by_designator.setdefault(designator, []).append(component)
        else:
            combined_components.append(component)

    for designator, multi_part_components in multi_part_components_by_designator.items():
        combined_component = multi_part_components[0].copy()
        combined_component[DESIGNATOR_COLUMN_NAME] = designator
        # The combined component shouldn't have the current part id, as it is
        # not any of the parts.
        del combined_component["_current_part_id"]
        combined_components.append(combined_component)

    return combined_components


def _combine_multi_part_components_for_orcad(
    components: list[dict[str, str]],
) -> list[dict[str, str]]:
    """
    Combine multi-part OrCAD components into a single component.

    Multi-part OrCAD components can be distinguished by the "logical_reference"
    attribute, which ties together the different parts of the component.
    """

    combined_components = []
    multi_part_components_by_designator = {}

    for component in components:
        if "_logical_reference" in component:
            designator = component["_logical_reference"]
            multi_part_components_by_designator.setdefault(designator, []).append(component)
        else:
            combined_components.append(component)

    for designator, multi_part_components in multi_part_components_by_designator.items():
        combined_component = multi_part_components[0].copy()
        combined_component[PART_REFERENCE_ATTR_NAME] = designator
        combined_component["_reference"] = designator
        combined_components.append(combined_component)

    return combined_components


def _extract_variations(
    variant: str,
    prjpcb_ini: configparser.ConfigParser,
) -> configparser.SectionProxy:
    """
    Extract the details of a variant from a PrjPcb file.
    """

    available_variants = set()

    for section in prjpcb_ini.sections():
        if section.startswith("ProjectVariant"):
            if prjpcb_ini[section].get("Description") == variant:
                return prjpcb_ini[section]
            else:
                available_variants.add(prjpcb_ini[section].get("Description"))
    raise ValueError(
        f"Variant {variant} not found in PrjPcb file.\n"
        f"Available variants: {', '.join(available_variants)}"
    )


def _apply_variations(
    components: list[dict[str, str]],
    variant_details: configparser.SectionProxy,
    logger: Logger,
) -> list[dict[str, str]]:
    """
    Apply the variations of a specific variant to the components. This should be
    done before the components are mapped to columns or grouped.

    :param components: The components to apply the variations to.
    :param variant_details: The section of the config file dealing with a
        specific variant.

    :returns: The components with the variations applied.
    """

    # List of component UniqueIDs to remove from the BOM.
    components_to_remove: list[str] = []
    # When patching components, the ParamVariation doesn't have the unique ID,
    # only a designator. However, ParamVariations follow the Variation entry, so
    # if we note down the last unique id we saw for a designator when going
    # through the variations, we can use that unique id when handling a param
    # variation. This dict holds that information.
    patch_component_unique_id: dict[str, str] = {}
    # The keys are the unique IDs, and the values are a key-value of the
    # parameter to patch and the value to patch it to.
    components_to_patch: dict[str, list[tuple[str, str]]] = {}

    for key, value in variant_details.items():
        # Note that this is in lowercase, as configparser stores all keys in
        # lowercase.
        if re.match(r"variation[\d+]", key):
            variation_details = dict(details.split("=", 1) for details in value.split("|"))
            try:
                designator = variation_details["Designator"]
                unique_id = variation_details["UniqueId"]
                kind = variation_details["Kind"]
            except KeyError:
                logger.warning(
                    "Designator, UniqueId, or Kind not found in details of variation "
                    f"{variation_details}; skipping this variation."
                )
                continue
            try:
                kind = VariationKind(int(variation_details["Kind"]))
            except ValueError:
                logger.warning(
                    f"Kind {variation_details['Kind']} of variation {variation_details} must be "
                    "either 0, 1 or 2; skipping this variation."
                )
                continue

            if kind == VariationKind.NOT_FITTED:
                components_to_remove.append(unique_id)
            else:
                patch_component_unique_id[designator] = unique_id
        elif re.match(r"paramvariation[\d]+", key):
            variation_id = key.split("paramvariation")[-1]
            designator = variant_details[f"ParamDesignator{variation_id}"]
            variation_details = dict(details.split("=", 1) for details in value.split("|"))
            try:
                unique_id = patch_component_unique_id[designator]
            except KeyError:
                # This can happen sometimes - Altium allows param variations
                # even when the component is not fitted, so we just log and
                # ignore.
                logger.warning(
                    f"ParamVariation{variation_id} found for component {designator} either before "
                    "the corresponding Variation or for a component that is not fitted.\n"
                    "Ignoring this ParamVariation."
                )
                continue

            try:
                parameter_patch = (
                    variation_details["ParameterName"],
                    variation_details["VariantValue"],
                )
            except KeyError:
                logger.warning(
                    f"ParameterName or VariantValue not found in ParamVariation{variation_id} "
                    "details."
                )
                continue

            components_to_patch.setdefault(unique_id, []).append(parameter_patch)

    final_components = []

    for component in components:
        unique_id = component["_unique_id"]
        if unique_id in components_to_remove:
            continue

        if unique_id in components_to_patch:
            new_component = component.copy()
            for parameter, value in components_to_patch[unique_id]:
                new_component[parameter] = value
            final_components.append(new_component)
        else:
            final_components.append(component)

    return final_components


def _filter_blank_components(
    components: list[ComponentAttributes],
    logger: Logger,
) -> list[ComponentAttributes]:
    """
    Remove components that have no attributes, or components for which all
    attributes are empty strings.

    This funtion also debug logs a warning for components that have no attributes.
    """

    final_components = []

    for component in components:
        if not any(component.values()):
            logger.debug(f"Component {component} has no attributes; skipping.")
            continue
        final_components.append(component)

    return final_components


def _find_device_sheet(
    device_sheet: str,
    project_repository: Repository,
    project_file_path: str,
    design_reuse_repos: list[Repository],
    logger: Logger,
) -> tuple[Repository, pathlib.PurePosixPath]:
    """
    Find a device sheet by name in the design reuse repositories.

    This currently does not use the directory structure of the device sheet
    path from the PrjPcb file, and instead only matches on the filename. If
    multiple matches are found, they are logged.

    :param design_reuse_repos: The design reuse repositories to search.
    :param device_sheet: The path of the device sheet as given in the PrjPcb.
    :returns: The first matching repository and the path to the device sheet in
        that repository.
    """

    matches: list[tuple[Repository, pathlib.PurePosixPath]] = []

    # Paths stored in the PrjPcb are Windows paths
    device_sheet_path = pathlib.PureWindowsPath(device_sheet)
    # Note that this will include the .SchDoc extension.
    device_sheet_name = device_sheet_path.name.casefold()

    # First, we'll resolve the actual path of the device sheet in the project
    # repo, and if that's a real file then we use that.
    device_sheet_path_from_repo_root = _resolve_prjpcb_relative_path(
        device_sheet,
        project_file_path,
    ).casefold()
    project_repo_tree = _fetch_all_files_in_repo(project_repository)
    for filepath in project_repo_tree:
        if device_sheet_path_from_repo_root == filepath.casefold():
            logger.info("Found device sheet %s in project repository; using that.", device_sheet)
            return (
                project_repository,
                pathlib.PurePosixPath(filepath),
            )

    for repo in design_reuse_repos:
        files_in_repo = _fetch_all_files_in_repo(repo)
        for file_in_repo in files_in_repo:
            # Paths as reported by ASH are Unix paths
            file_path = pathlib.PurePosixPath(file_in_repo)
            if device_sheet_name == file_path.name.casefold():
                matches.append((repo, file_path))

    if len(matches) == 0:
        raise ValueError(
            f"No matching device sheet found for {device_sheet_path} in the design reuse "
            "repositories.",
        )

    if len(matches) > 1:
        logger.info(
            "Multiple matches found for device sheet %s in design reuse repositories; set log "
            "level to debug to see all matches.",
            device_sheet,
        )
        for match in matches:
            logger.debug("Matching repository: %s, matching file path: %s", match[0].url, match[1])

        first_match = matches[0]
        logger.info(
            "Picking first match, i.e. Repository: %s, File Path: %s",
            first_match[0].url,
            first_match[1],
        )

    return matches[0]


def _fetch_and_parse_annotation_file(
    repository: Repository,
    prjpcb_path: str,
    ref: Ref,
) -> dict:
    """
    Determine the path to the annotation file, fetch it and parse it.

    :param repository: The repository containing the annotation file.
    :param prjpcb_path: The path to the PrjPcb file from the repo root.
    :param ref: The git ref to check.
    :returns: The parsed contents of the annotation file. The returned
        dictionary maps the full component unique id to a list of changes. For
        example, a component with id \\A\\B\\C with channel name CN that should
        change from C1 to C12 will be stored as
        {"\\A\\B\\C": [{"from": "C1", "to": "C12", "channel_name": "CN"}]}.
    """

    # According to the Altium documentation, the annotation file is stored with
    # the PrjPcb file and has the same name as the PrjPcb file, but with a
    # .Annotation extension.

    prjpcb_posix_path = pathlib.PurePosixPath(prjpcb_path)
    prjpcb_directory = prjpcb_posix_path.parent
    annotation_file_path = prjpcb_directory / (prjpcb_posix_path.stem + ".Annotation")
    caseless_annotation_file_path = annotation_file_path.as_posix().casefold()

    # We need to check case-insensitively because the project is made on
    # Windows, but ASH has a Unix-like filesystem.

    files_in_repo = _fetch_all_files_in_repo(repository)
    # Since we're checking case insensitively, we need to make sure there's only
    # one match for the annotation file path.
    matches = []
    for file_path in files_in_repo:
        if file_path.casefold() == caseless_annotation_file_path:
            matches.append(file_path)

    if len(matches) == 0:
        raise Exception(
            f"Could not find annotation file {annotation_file_path} in repository {repository.url}."
        )
    if len(matches) > 1:
        raise Exception(
            f"Multiple matches found for annotation file {annotation_file_path} in repository "
            f"{repository.url} when checking case-insensitively: {matches}."
        )

    annotation_file_path = matches[0]

    annotation_file_contents = repository.get_raw_file(annotation_file_path, ref=ref).decode(
        "utf-8-sig"
    )

    annotation_file_ini = configparser.ConfigParser()
    annotation_file_ini.read_string(annotation_file_contents)

    designator_manager_section = annotation_file_ini["DesignatorManager"]
    changes = {}

    # We'll manually loop through change numbers until we find one which
    # doesn't exist.
    change_number = 0

    while True:
        unique_id_key = f"UniqueID{change_number}"
        if unique_id_key not in designator_manager_section:
            break
        unique_id = designator_manager_section[unique_id_key]
        logical_designator = designator_manager_section[f"LogicalDesignator{change_number}"]
        physical_designator = designator_manager_section[f"PhysicalDesignator{change_number}"]

        if unique_id in changes:
            repository.allspice_client.logger.warning("Multiple changes found for %s", unique_id)
            continue

        changes[unique_id] = {"from": logical_designator, "to": physical_designator}
        change_number += 1

    return changes


def _apply_annotation_file(
    components: list[ComponentAttributes],
    annotations_data: dict,
    unique_ids_mapping: dict[str, str],
    logger: Logger,
) -> list[ComponentAttributes]:
    """
    Apply a Board Level Annotations file to get the compnents with their final
    designators.
    """

    final_components = []

    # Map sheet id to dict of component id to change.
    annotations_by_sheet = {}
    # Map sheet id to dict of designator to list of changes.
    annotations_by_sheet_and_designator = {}

    for unique_id, change in annotations_data.items():
        sheet_id, component_id = unique_id.rsplit("\\", 1)
        annotations_by_sheet.setdefault(sheet_id, {})[component_id] = change
        annotations_by_sheet_and_designator.setdefault(sheet_id, {}).setdefault(
            change["from"], []
        ).append(change)

    for component in components:
        unique_id = component["_unique_id"]
        sheet_id, component_id = unique_id.rsplit("\\", 1)
        ids_to_test = [component_id]
        if component_id in unique_ids_mapping:
            logger.debug("Found component %s in unique ID mapping", component_id)
            other_component_ids = unique_ids_mapping[component_id]
            ids_to_test.extend(other_component_ids)

        annotation_for_component = None
        for id_to_test in ids_to_test:
            annotation_for_component = annotations_by_sheet.get(id_to_test)
            if annotation_for_component:
                break

        if not annotation_for_component:
            logger.debug(
                "No annotation found for component %s by unique ID, trying by designator.",
                component_id,
            )
            component_designator = component[DESIGNATOR_COLUMN_NAME]
            annotations_for_designator = annotations_by_sheet_and_designator.get(sheet_id, {}).get(
                component_designator
            )
            if annotations_for_designator and len(annotations_for_designator) == 1:
                logger.debug(
                    "Found exact match for component %s, using annotation.", component_designator
                )
                annotation_for_component = annotations_for_designator[0]
            else:
                logger.debug(
                    "Found multiple annotations for designator %s; skipping.", component_designator
                )

        if not annotation_for_component:
            final_components.append(component)
            continue

        new_component = component.copy()
        new_component[DESIGNATOR_COLUMN_NAME] = annotation_for_component["to"]
        if LOGICAL_DESIGNATOR in new_component:
            new_component[LOGICAL_DESIGNATOR] = annotation_for_component["to"]
        final_components.append(new_component)
        continue

    return final_components


def _compute_repetitions(components: list[ComponentAttributes]) -> list[ComponentAttributes]:
    """
    In the absence of an Annotation file, manually append suffixes to repeated
    components.
    """

    final_components = []

    # The rough logic here is that for all components with the same ID, i.e.
    # the LAST segment of their unique ID, we sort them by channel name and
    # suffix the index based on its position in the sorted list.

    components_by_id = {}

    for component in components:
        unique_id = component["_unique_id"]
        last_segment = unique_id.split("\\")[-1]
        components_by_id.setdefault(last_segment, []).append(component)

    for components in components_by_id.values():
        if len(components) == 1:
            final_components.append(components[0])
            continue

        # If there are multiple components with the same ID, they *must* be
        # from repeated sheets, so we they *must* have the _channel_name field
        # in the component attributes.
        components.sort(key=lambda component: component["_channel_name"])
        for i, component in enumerate(components):
            new_component = component.copy()
            new_component[DESIGNATOR_COLUMN_NAME] += _letters_for_repetition(i + 1)
            new_component[LOGICAL_DESIGNATOR] += _letters_for_repetition(i + 1)
            final_components.append(new_component)

    return final_components


def _correct_device_sheet_reference_unique_ids(
    hierarchy: SchdocHierarchy,
    prjpcb_ini: configparser.ConfigParser,
    logger: Logger,
) -> SchdocHierarchy:
    """
    When using device sheets, the UniqueIDs of the sheet references in Device
    sheets are updated by Altium to be unique to the project. All UniqueID
    paths to the files are stored in the PrjPcb file, so this function compares
    the hierarchy to the paths in the PrjPcb path to correct the UniqueIDs of
    the SheetRefs to be what they should be in the project.

    This is known to not work in the following cases:

    - There are multiple top sheets in the project. Altium warns in this case,
      so this is not supported.
    - There are multiple sheets with the same name in the project. Altium also
      doesn't support this well and will do unusual things, so we don't
      support this.
    """

    try:
        annotate_section = prjpcb_ini["Annotate"]
    except KeyError:
        logger.warning(
            "Annotate section not found in PrjPcb; Designators of componets within Device Sheets may be incorrect."
        )
        return hierarchy

    paths_to_documents: dict[str, list[str]] = {}
    document_index = 0

    while True:
        try:
            document_name = annotate_section[f"DocumentName{document_index}"]
            unique_id_path = annotate_section[f"UniqueIDPath{document_index}"]
        except KeyError:
            break

        document_index += 1

        if not unique_id_path:
            # The unique ID path can be blank for the top sheet - we'll just
            # skip it, because we already know the top sheet.
            continue

        paths_to_documents.setdefault(document_name, []).append(unique_id_path)

    # How this works:
    #
    # We have a list of paths which are based on the hierarchy of sheet ref
    # unique IDs, and a list of the sheetrefs that are on each sheet. We also
    # know that all instances of a sheet have the same SheetRefs with the same
    # IDs. So if a Sheet1.SchDoc has two children Sheet2.SchDoc and
    # Sheet3.SchDoc, it will have two sheet refs, and across all instances of
    # Sheet1.SchDoc those sheetrefs will have the same unique Id.
    #
    # Therefore, we can take one of the paths to Sheet1, which gives us the
    # path for an instance of Sheet1. Then we take the list of children of
    # Sheet1 and pick a child, say Sheet2, and find all paths to Sheet2 that
    # passes through this instance of Sheet1. We also filter out paths that
    # aren't immediate children of this instance, and so we'll have a list of
    # unique IDs for the sheet refs, which we then reconcile with the sheetrefs
    # we do have.

    changed_hierarchy = {}

    for sheet_name, children in hierarchy.items():
        if not sheet_name.endswith(".SchDoc"):
            sheet_name_with_extension = sheet_name + ".SchDoc"
        else:
            sheet_name_with_extension = sheet_name

        if sheet_name_with_extension not in paths_to_documents:
            changed_hierarchy[sheet_name] = children
            continue

        unique_id_paths = paths_to_documents[sheet_name_with_extension]
        # Since all instances of this sheet should have the same children, we
        # only need one of the unique paths.
        path_to_this_sheet = unique_id_paths[0]
        children_by_name: dict[str, list[AltiumChildSheet]] = {}

        final_children = []
        for child in children:
            if child.kind == AltiumChildSheet.ChildSheetKind.DOCUMENT:
                # We don't need to change the IDs of documents - they're
                # already correct.
                final_children.append(child)
                continue

            children_by_name.setdefault(child.name, []).append(child)

        for child_name, children_of_name in children_by_name.items():
            # The name in the paths_to_documents dict has a SchDoc appended to
            # it
            child_name += ".SchDoc"
            paths_to_children = paths_to_documents.get(child_name, [])

            if not paths_to_children:
                logger.warning(
                    f"Could not find any paths for {child_name} in the PrjPcb file; skipping."
                )
                final_children.extend(children_of_name)
                continue

            ids: set[str] = set()
            for path in paths_to_children:
                if not path.startswith(path_to_this_sheet):
                    continue

                path = path.removeprefix(path_to_this_sheet + "\\")

                if "\\" in path:
                    # This path is deeper in the hierarchy, we can't use it.
                    continue

                # Now the path is the UniqueID of the child sheet
                ids.add(path)

            # Filter out children that already have the correct ids.
            children_to_correct = []
            for child in children_of_name:
                if child.unique_id in ids:
                    ids.remove(child.unique_id)
                    final_children.append(child)
                else:
                    children_to_correct.append(child)

            # Now we have the children who need new ids and the new ids
            for child, unique_id in zip(children_to_correct, ids, strict=True):
                final_children.append(dataclasses.replace(child, unique_id=unique_id))

        changed_hierarchy[sheet_name] = final_children

    return changed_hierarchy


def _create_unique_ids_mapping(prjpcb_ini: configparser.ConfigParser) -> dict:
    """
    If multiple components in the same project have the same UniqueIDs, Altium
    creates a mapping between them. This function reads that mapping from the
    PrjPcb file.
    """

    if "UniqueIdsMappings" not in prjpcb_ini.sections():
        return {}

    unique_ids_mapping = {}
    unique_ids_section = prjpcb_ini["UniqueIdsMappings"]
    # this section starts with one for some reason
    mapping_index = 1

    while True:
        try:
            mapping = unique_ids_section[f"Mapping{mapping_index}"]
        except KeyError:
            break
        (sch_handle, unique_id_mapping) = mapping.split("|")
        _, sch_handle = sch_handle.split("=", 1)
        _, unique_id_mapping = unique_id_mapping.split("=", 1)
        # The sch handle is a path, we only need the last part of it.
        component_id = sch_handle.split("\\")[-1]
        unique_ids_mapping.setdefault(component_id, []).append(unique_id_mapping)
        mapping_index += 1

    return unique_ids_mapping
