# This file is part of the faebryk project
# SPDX-License-Identifier: MIT

import logging
import os
from datetime import datetime, timedelta
from pathlib import Path
from typing import Sequence

import faebryk.core.node as fabll
import faebryk.library._F as F
from atopile.config import (
    BuildTargetConfig,
    ProjectConfig,
    UserConfigurationError,
    find_project_config_file,
)
from atopile.config import config as Gcfg
from atopile.errors import UserResourceException, UserValueError, accumulate
from atopile.layout import in_sub_pcb
from atopile.logging import ALERT
from faebryk.exporters.pcb.kicad.transformer import PCB_Transformer
from faebryk.libs.ato_part import AtoPart
from faebryk.libs.kicad.fileformats import (
    C_kicad_model_file,
    Property,
    kicad,
)
from faebryk.libs.picker.lcsc import (
    EasyEDA3DModel,
    EasyEDAAPIResponse,
    EasyEDAFootprint,
    EasyEDAPart,
    EasyEDASymbol,
    PickedPartLCSC,
)
from faebryk.libs.util import (
    KeyErrorNotFound,
    find,
    indented_container,
    md_list,
    once,
    path_replace,
    re_in,
    robustly_rm_dir,
    sanitize_filepath_part,
)

logger = logging.getLogger(__name__)


MANAGED_LIB_PREFIX = "atopile: part lib:"


class PartLifecycleError(Exception):
    """
    Base class for part lifecycle errors.
    """

    pass


class IngestFootprintError(PartLifecycleError):
    """
    Error while ingesting part.
    """

    pass


class PartIsNotAutoGenerated(PartLifecycleError):
    """
    Part exists but is not auto-generated.
    """

    def __init__(self, part: AtoPart):
        super().__init__(f"Part `{part.path}` is not purely auto-generated.")
        self.part = part


class PartLifecycle:
    """

    ```
    EasyEDA API => easyeda2kicad => picker/lcsc.py
        -> build/cache/easyeda/parts/<id>/<id>.json => picker/lcsc.py
        -> build/cache/easyeda/parts/<id>/*.kicad*|.step => picker/lcsc.py
        -> <component_lib>/parts/<mfr_pn>/*.kicad*|.step|.ato => transformer.py
        -> <layout_path>/<build>/<build>.kicad_pcb
    ```

    Notes:
     - `layout_path` determined by config.py (defaults to elec/layout)
     - `component_lib` determined by config.py (defaults to build/kicad/libs)
    """

    class EasyEDA_API:
        DELTA_REFRESH = timedelta(days=1)

        @property
        def _PATH(self) -> Path:
            return Gcfg.project.paths.build / "cache" / "parts" / "easyeda"

        def _get_part_path(self, partno: str) -> Path:
            return self._PATH.joinpath(partno) / f"{partno}.json"

        def _exists(self, partno: str) -> bool:
            return self._get_part_path(partno).exists()

        def shall_refresh(self, partno: str) -> bool:
            if not self._exists(partno):
                return True
            date_queried = self.load(partno).query_time
            return date_queried < datetime.now() - self.DELTA_REFRESH

        def ingest(self, partno: str, data: EasyEDAAPIResponse) -> EasyEDAAPIResponse:
            if self._exists(partno):
                existing = self.load(partno)
                # check whether footprint changed
                if existing.packageDetail.updateTime == data.packageDetail.updateTime:
                    return existing
                # TODO question user
                logger.warning(f"Updating cached {partno}")

            path = self._get_part_path(partno)
            data.dump(path)
            return data

        def load(self, partno: str) -> EasyEDAAPIResponse:
            return EasyEDAAPIResponse.load(self._get_part_path(partno))

    class Easyeda2Kicad:
        @property
        def _PATH(self) -> Path:
            return Gcfg.project.paths.build / "cache" / "parts" / "easyeda"

        def _get_part_path(self, partno: str) -> Path:
            return self._PATH / partno

        def get_fp_path(self, partno: str, footprint_name: str) -> Path:
            # public because needed by lcsc.py
            return self._get_part_path(partno) / f"{footprint_name}.kicad_mod"

        def _get_sym_path(self, partno: str, sym_name: str) -> Path:
            return self._get_part_path(partno) / f"{sym_name}.kicad_sym"

        def get_model_path(self, partno: str, model_name: str) -> Path:
            # public because needed by lcsc.py

            # TODO: Note: There are two names for models
            # 1. filename derived from footprint
            # 2. internal step file name
            # The only way to find out the internal is to download and read the step
            return self._get_part_path(partno) / f"{model_name}.step"

        def shall_refresh_model(self, part: EasyEDAPart) -> bool:
            # no model in api
            if not part.model and not part._pre_model:
                return False

            # TODO: consider timing out?
            if self.get_model_path(part.identifier, part.model_name).exists():
                return False

            return True

        def load_model(self, part: EasyEDAPart) -> EasyEDA3DModel:
            return EasyEDA3DModel.load(
                self.get_model_path(part.identifier, part.model_name)
            )

        def ingest_part(self, part: EasyEDAPart) -> EasyEDAPart:
            footprint = part.footprint
            fp_path = self.get_fp_path(part.identifier, part.footprint.base_name)
            if fp_path.exists():
                existing = EasyEDAFootprint.load(fp_path)
                if diff := existing.compare(footprint):
                    logger.warning(
                        f"Updating cached footprint {part.footprint.base_name}:"
                        f" {indented_container(diff, recursive=True)}"
                    )
                    footprint.dump(fp_path)
            else:
                footprint.dump(fp_path)

            if part.model or part._pre_model:
                model_path = self.get_model_path(part.identifier, part.model_name)
                if part.model:
                    part.model.dump(model_path)
                elif model_path.exists():
                    part.model = EasyEDA3DModel.load(model_path)

            # TODO not sure about name
            sym_name = part.symbol.kicad_symbol.name
            sym_path = self._get_sym_path(part.identifier, sym_name)
            if sym_path.exists():
                existing = EasyEDASymbol.load(sym_path)
                if diff := existing.compare(part.symbol):
                    logger.warning(
                        f"Updating cached symbol {sym_name}:"
                        f" {indented_container(diff, recursive=True)}"
                    )
                    part.symbol.dump(sym_path)
            else:
                part.symbol.dump(sym_path)

            return part

    class Library:
        @property
        def _PATH(self) -> Path:
            return Gcfg.project.paths.parts

        def _get_part_identifier(self, part: EasyEDAPart) -> str:
            sanitized = [sanitize_filepath_part(x) for x in part.mfn_pn]
            manufacturer, partno = sanitized
            if not manufacturer:
                manufacturer = "UNKNOWN"
            assert partno

            return f"{manufacturer}_{partno}"

        def _get_part_path(self, part: EasyEDAPart) -> Path:
            return self._PATH / self._get_part_identifier(part)

        def fp_table(
            self, build: BuildTargetConfig
        ) -> tuple[Path, kicad.fp_lib_table.FpLibTableFile]:
            fp_table_path = build.paths.fp_lib_table

            try:
                fp_table = kicad.loads(kicad.fp_lib_table.FpLibTableFile, fp_table_path)
                for lib in fp_table.fp_lib_table.libs:
                    if not lib.descr.startswith(MANAGED_LIB_PREFIX):
                        logger.warning(
                            f"Removing unmanaged footprint library `{lib.name}`"
                        )
            except FileNotFoundError:
                # just generate if missing (e.g. on first run)
                fp_table = kicad.fp_lib_table.FpLibTableFile(
                    fp_lib_table=kicad.fp_lib_table.FpLibTable(version=7, libs=[])
                )

            # load all existing parts into new table
            if Gcfg.project.paths.parts.exists():
                for part_dir in sorted(
                    Gcfg.project.paths.parts.iterdir(), key=lambda x: x.name
                ):
                    part_identifier = part_dir.name
                    if not part_dir.is_dir():
                        continue
                    if not (part_dir / (part_identifier + ".ato")).is_file():
                        continue
                    self.__insert_fp_lib(part_identifier, fp_table)

            return fp_table_path, fp_table

        def _insert_fp_lib(self, lib_name: str, fppath: Path | None = None):
            for build in Gcfg.project.builds.values():
                fp_table_path, fp_table = self.fp_table(build)
                self.__insert_fp_lib(lib_name, fp_table, fppath)
                kicad.dumps(fp_table, fp_table_path)

        def __insert_fp_lib(
            self,
            lib_name: str,
            fp_table: kicad.fp_lib_table.FpLibTableFile,
            fppath: Path | None = None,
        ):
            fppath = fppath or self._PATH / lib_name

            if not fppath.resolve().exists():
                raise FileNotFoundError(f"Footprint does not exist: {fppath.resolve()}")

            fpuri = Gcfg.project.get_relative_to_kicad_project(fppath.resolve())

            if lib := kicad.try_get(fp_table.fp_lib_table.libs, lib_name):
                if Path(lib.uri) != fpuri:
                    # TODO better exception
                    raise Exception(
                        f"Footprint library {lib_name} already exists at different"
                        f" location: `{lib.uri}` != `{fpuri}`."
                        f" Manual ingestion required."
                    )
                return fp_table

            lib = kicad.fp_lib_table.FpLibEntry(
                name=lib_name,
                type="KiCad",
                uri=fpuri.as_posix(),
                options="",
                descr=f"{MANAGED_LIB_PREFIX} {lib_name}",
            )
            lib = kicad.set(
                fp_table.fp_lib_table, "libs", fp_table.fp_lib_table.libs, lib
            )
            # TODO move somewhere else
            if not getattr(self, "_printed_alert", False):
                # check if any running pcbnew instances
                # TODO: actually pass pcb
                try:
                    from faebryk.libs.kicad.ipc import opened_in_pcbnew

                    if opened_in_pcbnew(pcb_path=None):
                        logger.log(
                            ALERT, "pcbnew restart required (updated fp-lib-table)"
                        )
                        self._printed_alert = True
                except Exception:
                    pass

            return fp_table

        def ingest_part(self, part: AtoPart) -> AtoPart:
            from faebryk.library.is_auto_generated import _FileManuallyModified

            def _filter_datasheet_downgrades(diff: dict) -> dict:
                """
                EasyEDA ingestion only sometimes returns a datasheet. In the case where
                we did have one previously, filter that update out of the diff so we
                don't then remove it from the part definition.
                """

                if (datasheet := diff.get(".datasheet")) is not None:
                    before, after = datasheet
                    if before is not None and after is None:
                        del diff[".datasheet"]

                return diff

            if part.path.exists():
                try:
                    existing = AtoPart.load(part.path)
                except _FileManuallyModified as ex:
                    raise UserValueError(
                        f"Part `{part.path}` has been manually modified ({ex}). Please"
                        " revert the changes or remove the `is_auto_generated` trait."
                    ) from ex
                if not existing.auto_generated:
                    raise PartIsNotAutoGenerated(existing)

                diff = existing.compare(part)
                for filter_func in [
                    _filter_datasheet_downgrades,
                ]:
                    diff = filter_func(diff)

                if diff:
                    str_diff = str(diff)
                    if len(str_diff) > 1000:
                        str_diff = "Too long to show..."
                    else:
                        str_diff = indented_container(diff, recursive=True)
                    logger.warning(
                        f"Updating library part {part.identifier}: {str_diff}"
                    )
                    robustly_rm_dir(part.path)
                    part.dump()
            else:
                part.dump()

            self._insert_fp_lib(part.path.name)

            return part

        def ingest_part_from_easyeda(self, epart: EasyEDAPart) -> AtoPart:
            out_path = self._get_part_path(epart)
            identifier = self._get_part_identifier(epart)

            # cache
            # we assume that during runtime identical part ids are identical
            # only used for performance atm
            if not hasattr(self, "_cache_ingest_part_from_easyeda"):
                self._cache_ingest_part_from_easyeda = dict[str, AtoPart]()
            if identifier in self._cache_ingest_part_from_easyeda:
                return self._cache_ingest_part_from_easyeda[identifier]

            ato_part = AtoPart(
                identifier=identifier,
                mfn=epart.mfn_pn,
                path=out_path,
                fp=epart.footprint.footprint,
                symbol=epart.symbol.symbol,
                model=C_kicad_model_file.loads(epart.model.step)
                if epart.model
                else None,
                auto_generated=AtoPart.AutoGenerated(
                    source=f"easyeda:{epart.identifier}",
                ),
                datasheet=epart.datasheet_url,
                docstring=epart.description,
                pick_part=PickedPartLCSC(
                    manufacturer=epart.mfn_pn[0],
                    partno=epart.mfn_pn[1],
                    supplier_partno=epart.lcsc_id,
                ),
            )
            out = self.ingest_part(ato_part)

            self._cache_ingest_part_from_easyeda[identifier] = out
            return out

        def get_part_from_footprint_identifier(
            self, identifier: str, component: fabll.Module
        ) -> Path:
            # TODO this is old code, avoid reading fp-lib-table
            fp_lib_path = Gcfg.build.paths.fp_lib_table

            class LibNotInTable(Exception):
                def __init__(
                    self, *args: object, lib_id: str, lib_table_path: Path
                ) -> None:
                    super().__init__(
                        f"Footprint library {lib_id} not found in table "
                        f"{lib_table_path}",
                        *args,
                    )
                    self.lib_id = lib_id
                    self.lib_table_path = lib_table_path

            def _find_footprint(
                lib_tables: Sequence[os.PathLike], lib_id: str
            ) -> kicad.fp_lib_table.FpLibEntry:
                lib_tables = [Path(lib_table) for lib_table in lib_tables]

                err_accumulator = accumulate(LibNotInTable, FileNotFoundError)

                for lib_table_path in lib_tables:
                    with err_accumulator.collect():
                        lib_table = kicad.loads(
                            kicad.fp_lib_table.FpLibTableFile, lib_table_path
                        )
                        try:
                            return find(
                                lib_table.fp_lib_table.libs,
                                lambda x: x.name == lib_id,
                            )
                        except KeyErrorNotFound as ex:
                            raise LibNotInTable(
                                lib_id=lib_id, lib_table_path=lib_table_path
                            ) from ex

                if ex := err_accumulator.get_exception():
                    raise ex

                raise ValueError("No footprint libraries provided")

            lib_id, _ = identifier.split(":")
            try:
                lib = _find_footprint([fp_lib_path], lib_id)
            except* (LibNotInTable, FileNotFoundError):
                # For atomic parts, get the source directory from the trait
                is_atomic_part_ = component.try_get_trait(F.is_atomic_part)
                if is_atomic_part_ is not None:
                    # insert footprint on first use from dependency package
                    try:
                        source_dir = is_atomic_part_.get_source_dir()
                        self._insert_fp_lib(lib_id, source_dir)
                    except (FileNotFoundError, ValueError):
                        raise

                    lib = _find_footprint([fp_lib_path], lib_id)
                else:
                    raise

            part_path = Path(
                str(lib.uri).replace(
                    "${KIPRJMOD}", str(Gcfg.build.paths.fp_lib_table.parent)
                )
            )

            return part_path

        @staticmethod
        def _get_project_from_part_path(part_path: Path) -> Path | None:
            # TODO: hate this
            manifest_path = find_project_config_file(part_path)
            if manifest_path is None:
                return None
            return manifest_path.parent

        def _fix_package_footprint_model(
            self, fp: kicad.footprint.FootprintFile, fp_path: Path
        ):
            layout_path = Gcfg.build.paths.layout.parent
            project_path = self._get_project_from_part_path(fp_path)
            if project_path is None:
                raise PartLifecycleError(f"No project path found for {fp_path}")
            package_project_config = ProjectConfig.from_path(project_path)
            if not package_project_config:
                raise PartLifecycleError(
                    f"No project config found for {fp_path} at {project_path}"
                )
            inner_layout_path = package_project_config.paths.layout
            rel_path = inner_layout_path.relative_to(layout_path, walk_up=True)
            for m in fp.footprint.models:
                # "${KIPRJMOD}/
                #   ../../parts/<part>/<model>"
                # ->
                # "${KIPRJMOD}/../../.ato/modules/<package>/<layout_dir>/
                #   ../parts/<part>/<model>"
                if Path(m.path).is_relative_to(Path("${KIPRJMOD}") / rel_path):
                    continue
                m.path = path_replace(
                    Path(m.path),
                    Path("${KIPRJMOD}") / "..",
                    Path("${KIPRJMOD}") / rel_path,
                ).as_posix()

        def get_footprint_from_identifier(
            self, identifier: str, component: fabll.Module
        ) -> tuple[Path, kicad.footprint.FootprintFile]:
            lib_id, fp_name = identifier.split(":")
            part_path = self.get_part_from_footprint_identifier(identifier, component)
            fp_path = part_path / f"{fp_name}.kicad_mod"
            try:
                fp = kicad.loads(kicad.footprint.FootprintFile, fp_path)

                # TODO: associate source project with component, so all that's needed
                # here is to substitute ${KIPRJMOD} + rel_path for ${KIPRJMOD}
                if ".ato" in fp_path.parts:
                    self._fix_package_footprint_model(fp, fp_path)

                return fp_path, fp
            except FileNotFoundError as ex:
                raise UserResourceException(
                    f"Footprint `{fp_name}` doesn't exist in library `{lib_id}`"
                ) from ex

    class PCB:
        def __init__(self) -> None:
            pass

        def ingest_footprint(
            self,
            transformer: PCB_Transformer,
            associated_fp_trait: F.Footprints.has_associated_footprint,
            logger: logging.Logger,
            insert_point: kicad.pcb.Xyr | None = None,
        ) -> tuple[kicad.pcb.Footprint, bool]:
            # TODO this is old code taken from PCB_Transformer
            # need to decouple some actions from here
            # e.g insert point is not really at the right place here

            # TODO also implement the ingest logic correctly
            # if the footprint is already on the pcb, we should run our typical
            # resolution flow

            lifecycle = PartLifecycle.singleton()

            f_fp = associated_fp_trait.get_footprint()
            component = fabll.Traits(associated_fp_trait).get_obj_raw()

            if atomic_part := component.try_get_trait(F.is_atomic_part):
                k_lib_file_fp_t = atomic_part.get_kicad_library_footprint()
            else:
                k_lib_file_fp_t = f_fp.try_get_trait(
                    F.KiCadFootprints.has_associated_kicad_library_footprint
                )

            # At this point, all footprints MUST have a KiCAD identifier
            # Prioritize library footprint (from picker) over PCB footprint (existing)
            # This ensures that when a part changes, we use the NEW footprint identifier
            k_pcb_fp_t = f_fp.try_get_trait(
                F.KiCadFootprints.has_associated_kicad_pcb_footprint
            )
            if k_lib_file_fp_t:
                fp_id = k_lib_file_fp_t.get_kicad_identifier()
                logger.debug(f"Using KiCAD-Library-File footprint identifier: {fp_id}")
            elif k_pcb_fp_t:
                fp_id = k_pcb_fp_t.get_kicad_identifier()
                logger.debug(f"Using KiCAD-PCB footprint identifier: {fp_id}")
            else:
                raise IngestFootprintError(
                    f"Footprint {f_fp.get_name()} has no "
                    "KiCAD-PCB or KiCAD-Library-File footprint associated"
                )

            # This is the component which is being stuck on the board
            address = component.get_full_name()

            # All modules MUST have a designator by this point
            designator_t = component.try_get_trait(F.has_designator)
            if not designator_t or not (ref := designator_t.get_designator()):
                raise IngestFootprintError(f"Component {address} has no designator")

            pcb_fp_t = f_fp.try_get_trait(
                F.KiCadFootprints.has_associated_kicad_pcb_footprint
            )
            new_fp = pcb_fp_t is None

            ## Update existing footprint
            if not new_fp:
                assert pcb_fp_t is not None
                pcb_fp = pcb_fp_t.get_footprint()

                # Copy the data structure so if we later mutate it we don't
                # end up w/ those changes everywhere
                _, lib_fp = lifecycle.library.get_footprint_from_identifier(
                    fp_id, component
                )

                logger.info(
                    f"Updating `{pcb_fp.name}`->`{fp_id}` on `{address}` ({ref})",
                    extra={"markdown": True},
                )
                # We need to manually override the name because the
                # footprint's data could've ultimately come from anywhere
                lib_fp.footprint.name = fp_id
                transformer.update_footprint_from_lib(pcb_fp, lib_fp.footprint)
                transformer.bind_footprint(pcb_fp, component)

            ## Add new footprint
            else:
                # Components and footprints MUST have the same linking by this point
                # This should be enforced through attach, and bind_footprint
                assert not component.has_trait(
                    F.KiCadFootprints.has_associated_kicad_pcb_footprint
                )

                logger.info(f"Adding `{fp_id}` as `{address}` ({ref})")
                # Copy the data structure so if we later mutate it we don't
                # end up w/ those changes everywhere
                _, lib_fp = lifecycle.library.get_footprint_from_identifier(
                    fp_id, component
                )
                # We need to manually override the name because the
                # footprint's data could've ultimately come from anywhere
                lib_fp.footprint.name = fp_id
                pcb_fp = transformer.insert_footprint(lib_fp.footprint, insert_point)
                transformer.bind_footprint(pcb_fp, component)

            def _prop_factory(prop_name: str, prop_value: str) -> kicad.pcb.Property:
                return kicad.pcb.Property(
                    name=prop_name,
                    value=prop_value,
                    at=kicad.pcb.Xyr(x=0, y=0, r=0),
                    layer="User.9",
                    uuid=PCB_Transformer.gen_uuid(mark=True),
                    unlocked=None,
                    hide=True,
                    effects=None,
                )

            ## Apply propertys, Reference and atopile_address
            # Take any descriptive properties defined on the component
            properties = F.SerializableMetadata.get_properties(component)
            for prop_name, prop_value in properties.items():
                if re_in(
                    prop_name,
                    PCB_Transformer.INCLUDE_DESCRIPTIVE_PROPERTIES_FROM_PCB(),
                ):
                    Property.set_property(pcb_fp, _prop_factory(prop_name, prop_value))

            if c_props_t := component.try_get_trait(F.has_datasheet):
                Property.set_property(
                    pcb_fp, _prop_factory("Datasheet", c_props_t.get_datasheet())
                )

            Property.set_property(pcb_fp, _prop_factory("Reference", ref))

            if value_t := component.try_get_trait(F.has_simple_value_representation):
                # Get all specs and create separate properties for each
                specs = value_t.get_specs()
                if specs:
                    # First spec goes into the standard "Value" field
                    first_spec = specs[0]
                    first_value = first_spec.get_value(show_tolerance=False)
                    Property.set_property(pcb_fp, _prop_factory("Value", first_value))

                    # Additional specs become separate properties
                    for spec in specs:
                        param_name = spec.param.get_name()
                        spec_value = spec.get_value()
                        if spec_value:  # Only add if not empty
                            Property.set_property(
                                pcb_fp, _prop_factory(param_name, spec_value)
                            )
                else:
                    Property.set_property(pcb_fp, _prop_factory("Value", ""))
            else:
                Property.set_property(pcb_fp, _prop_factory("Value", ""))

            Property.set_property(
                pcb_fp,
                _prop_factory(
                    "atopile_address", component.get_full_name(include_uuid=False)
                ),
            )
            if sub_pcb_t := component.try_get_trait(in_sub_pcb):
                subaddresses = (
                    "["
                    + ", ".join(
                        sorted(
                            subaddress.serialize() for subaddress in sub_pcb_t.addresses
                        )
                    )
                    + "]"
                )
                Property.set_property(
                    pcb_fp,
                    _prop_factory(
                        "atopile_subaddresses",
                        subaddresses,
                    ),
                )

            # Add LCSC ID from picked part
            if picked_t := component.try_get_trait(F.Pickable.has_part_picked):
                if picked_part := picked_t.try_get_part():
                    if picked_part.supplier_partno:
                        Property.set_property(
                            pcb_fp,
                            _prop_factory("lcsc_id", picked_part.supplier_partno),
                        )

            # Add package size from package requirements
            if pkg_t := component.try_get_trait(F.has_package_requirements):
                # Get size enum and format as string (e.g., "0402", "M1608")
                from faebryk.libs.smd import SMDSize

                sizes = pkg_t.size.get().try_extract_superset()
                if sizes:
                    size_values = sizes.get_values_typed(SMDSize)
                    # Strip I prefix from imperial sizes (default), keep M for metric
                    # e.g., "I0603" -> "0603", "M1608" -> "M1608"
                    size_str = ", ".join(
                        s.name[1:] if s.name.startswith("I") else s.name
                        for s in size_values
                    )
                    Property.set_property(
                        pcb_fp,
                        _prop_factory("package", size_str),
                    )

            # delete checksum
            Property.checksum.delete_checksum(pcb_fp)
            return pcb_fp, new_fp

    def __init__(self):
        self.easyeda_api = self.EasyEDA_API()
        self.easyeda2kicad = self.Easyeda2Kicad()
        self.library = self.Library()
        self.pcb = self.PCB()

        self._delete_deprecated_cache()

    def _delete_deprecated_cache(self):
        cfg_paths = Gcfg.project.paths
        build = Gcfg.project.paths.build

        old_fps = cfg_paths.root / "elec" / "footprints"
        old_ecache = build / "cache" / "easyeda"
        old_kifps = build / "kicad" / "libs" / "footprints" / "lcsc.pretty"
        old_ki3d = build / "kicad" / "libs" / "lcsc.3dshapes"

        if old_fps.is_dir() and (fps := list(old_fps.rglob("*.kicad_mod"))):
            raise UserConfigurationError(
                "The `footprints` directory is deprecated and not empty. "
                "Please transition your manual footprints to atomic parts."
                f"\n{md_list(fps)}"
            )

        for path in [old_ecache, old_kifps, old_ki3d]:
            if path.exists():
                logger.warning(f"Deleting deprecated cache {path}")
                robustly_rm_dir(path)

    @classmethod
    @once
    def singleton(cls) -> "PartLifecycle":
        return cls()
