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

import datetime
import logging
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Self

from more_itertools import first
from natsort import natsorted

import faebryk.library._F as F
from atopile.config import config as Gcfg
from faebryk.libs.checksum import Checksum
from faebryk.libs.codegen.atocodegen import AtoCodeGen
from faebryk.libs.codegen.atocodeparse import AtoCodeParse
from faebryk.libs.codegen.pycodegen import sanitize_name
from faebryk.libs.kicad.fileformats import (
    C_kicad_model_file,
    Property,
    kicad,
)
from faebryk.libs.picker.picker import PickedPart
from faebryk.libs.util import ConfigFlag, compare_dataclasses, starts_or_ends_replace

logger = logging.getLogger(__name__)

FBRK_OVERRIDE_CHECKSUM_MISMATCH = ConfigFlag(
    "PART_OVERRIDE_CHECKSUM_MISMATCH",
    default=False,
)


@dataclass(kw_only=True)
class AtoPart:
    @dataclass(kw_only=True)
    class AutoGenerated:
        system: str = Path(__file__).stem
        date: datetime.datetime = field(
            default_factory=lambda: datetime.datetime.now(datetime.timezone.utc)
        )
        source: str | None
        checksum: str | None = None

    identifier: str
    path: Path
    mfn: tuple[str, str]
    fp: kicad.footprint.FootprintFile
    symbol: kicad.symbol.SymbolFile
    model: C_kicad_model_file | None
    auto_generated: AutoGenerated | None
    datasheet: str | None = None
    docstring: str = ""
    pick_part: PickedPart | None = None

    @property
    def fp_path(self) -> Path:
        return self.path / f"{kicad.fp_get_base_name(self.fp.footprint)}.kicad_mod"

    @property
    def sym_path(self) -> Path:
        return self.path / f"{first(self.symbol.kicad_sym.symbols).name}.kicad_sym"

    @property
    def module_name(self) -> str:
        return f"{self.identifier}_package"

    @property
    def ato_path(self) -> Path:
        return self.path / (self.path.name + ".ato")

    @property
    def model_path(self) -> Path:
        if not self.model:
            raise ValueError("Model is not set")
        return self.path / self.model.filename

    def generate_import_statement(self, src_path: Path) -> str:
        import_path = self.ato_path.relative_to(src_path)
        return f'from "{import_path}" import {self.module_name}'

    def __post_init__(self):
        self.fp = kicad.copy(self.fp)
        self.fp.footprint.name = (
            f"{self.identifier}:{kicad.fp_get_base_name(self.fp.footprint)}"
        )

        self.symbol = kicad.copy(self.symbol)

        if self.model:
            rel_path = Gcfg.project.get_relative_to_kicad_project(self.model_path)

            if len(self.fp.footprint.models) != 1:
                raise NotImplementedError(
                    "Multiple models are not supported yet. "
                    "Please report this issue to the maintainers."
                )
            self.fp.footprint.models[0].path = rel_path.as_posix()

    def compare(self, other: Self) -> dict:
        return compare_dataclasses(
            self,
            other,
            skip_keys=(
                "path",
                "uuid",
                "date",
                "checksum",
            ),
        )

    def _dump_pins(self, build: AtoCodeGen.ComponentFile):
        symbol = first(self.symbol.kicad_sym.symbols)

        build.add_comments("pins", use_spacer=True)

        def _sanitize(pin_name: str) -> str | None:
            # number only == pin_number, not name
            if re.match(r"^[0-9]+$", pin_name):
                return None

            # handle negation
            pin_name = starts_or_ends_replace(pin_name, ("~", "#"), prefix="n")
            # handle diff pair
            pin_name = starts_or_ends_replace(pin_name, ("+",), suffix="pos")
            pin_name = starts_or_ends_replace(pin_name, ("-", "–"), suffix="neg")

            return sanitize_name(pin_name, warn_prefix=f"{self.identifier}")

        # Collect and sort pins first
        unsorted_pins: list[tuple[str | None, str]] = []
        for unit in symbol.symbols:
            for pin in unit.pins:
                pin_num = pin.number.number
                pin_name = pin.name.name
                if pin_name.upper() == "NC":
                    continue
                pin_name = _sanitize(pin_name)
                unsorted_pins.append((pin_name, pin_num))

        # Sort pins by name using natsort
        sorted_pins = natsorted(unsorted_pins, key=lambda x: x[0])

        all_pin_nums = {pin_num for _, pin_num in sorted_pins}

        defined_signals: set[str] = set()

        # Process sorted pins
        for pin_name, pin_num in sorted_pins:
            if pin_name is None:
                build.add_stmt(AtoCodeGen.PinDeclaration(pin_num))
            else:
                # Add prefix if pin_name conflicts with any pin_num
                signal_name = (
                    f"SIG_{pin_name}" if pin_name in all_pin_nums else pin_name
                )

                build.add_stmt(
                    AtoCodeGen.Connect(
                        left=AtoCodeGen.Connect.Connectable(
                            signal_name,
                            declare="signal"
                            if signal_name not in defined_signals
                            else None,
                        ),
                        right=AtoCodeGen.Connect.Connectable(pin_num, declare="pin"),
                    )
                )
                defined_signals.add(signal_name)

    def dump(self):
        assert self.auto_generated is not None

        self.path.mkdir(parents=True, exist_ok=True)

        symbol = first(self.symbol.kicad_sym.symbols)
        # refresh checksums
        if False:
            # TODO see verify_checksum
            Property.checksum.set_checksum(self.fp.footprint, kicad.pcb.Property)
            Property.checksum.set_checksum(symbol, kicad.schematic.Property)

        kicad.dumps(self.fp, self.fp_path)
        kicad.dumps(self.symbol, self.sym_path)
        if self.model:
            self.model.dumps(self.model_path)

        ato_builder = AtoCodeGen.ComponentFile(
            self.module_name, docstring=self.docstring
        )
        ato_builder.add_comments(
            "This trait marks this file as auto-generated",
            "If you want to manually change it, remove the trait",
        )
        ato_builder.add_trait(
            "is_auto_generated",
            system=self.auto_generated.system,
            source=self.auto_generated.source,
            date=self.auto_generated.date.isoformat(),
            checksum=F.is_auto_generated.CHECKSUM_PLACEHOLDER,
        )

        ato_builder.add_stmt(AtoCodeGen.Spacer())

        ato_builder.add_trait(
            "is_atomic_part",
            manufacturer=self.mfn[0],
            partnumber=self.mfn[1],
            footprint=self.fp_path.name,
            symbol=self.sym_path.name,
            model=self.model_path.name if self.model else None,
        )

        if self.pick_part:
            ato_builder.add_trait(
                "has_part_picked",
                "by_supplier",
                supplier_id=self.pick_part.supplier.supplier_id,
                supplier_partno=self.pick_part.supplier_partno,
                manufacturer=self.pick_part.manufacturer,
                partno=self.pick_part.partno,
            )

        ato_builder.add_trait(
            "has_designator_prefix",
            prefix=Property.get_property(symbol.propertys, "Reference"),
        )

        if self.datasheet:
            # TODO: Fix me
            ato_builder.add_trait("has_datasheet", datasheet=self.datasheet)

        self._dump_pins(ato_builder)

        ato = ato_builder.dump()

        ato = F.is_auto_generated.set_checksum(ato)

        self.ato_path.write_text(ato, encoding="utf-8")

    def verify_checksum(self, ato_file_contents: str):
        from faebryk.library.is_auto_generated import _FileManuallyModified

        if not self.auto_generated:
            return
        assert self.auto_generated.checksum is not None

        # check ato
        F.is_auto_generated.verify(self.auto_generated.checksum, ato_file_contents)

        fp = self.fp.footprint
        symbol = first(self.symbol.kicad_sym.symbols)

        # TODO re-enable
        # This makes sure the user did not forget to remove the is_auto_generated trait
        return
        # check fp & symbol
        for obj, t_name in ((fp, "Footprint"), (symbol, "Symbol")):
            try:
                Property.checksum.verify_checksum(obj)
            except Property.PropertyNotSet:
                raise _FileManuallyModified(
                    f"{t_name} has no checksum for auto-generated part"
                )

            except Checksum.Mismatch:
                if FBRK_OVERRIDE_CHECKSUM_MISMATCH:
                    # must now write the new value to handle updating the checksum
                    # mechanism
                    Property.checksum.set_checksum(
                        obj,
                        kicad.pcb.Property
                        if isinstance(obj, kicad.pcb.Footprint)
                        else kicad.schematic.Property,  # type:ignore
                    )
                    return
                raise _FileManuallyModified(
                    f"{t_name} has a checksum mismatch for auto-generated part"
                )

        # TODO verify model

    @classmethod
    def load(cls, path: Path) -> Self:
        """
        Looks for ato manifest in the given directory.
        If found, loads the manifest and returns an AtoPart object.
        """

        # TODO consider using Bob
        ato = AtoCodeParse.ComponentFile(path / (path.name + ".ato"))

        atomic_trait = ato.get_trait(F.is_atomic_part)
        auto_generated_trait = ato.try_get_trait(F.is_auto_generated)
        datasheet = (
            t.get_datasheet() if (t := ato.try_get_trait(F.has_datasheet)) else None
        )

        pick_part = (
            t.get_part()
            if (t := ato.try_get_trait(F.Pickable.has_part_picked))
            else None
        )

        docstring = ato.parse_docstring()

        fp = kicad.loads(
            kicad.footprint.FootprintFile, path / atomic_trait.get_footprint()
        )
        symbol = kicad.loads(kicad.symbol.SymbolFile, path / atomic_trait.get_symbol())
        model = (
            C_kicad_model_file.loads(path / model_name)
            if (model_name := atomic_trait.get_model())
            else None
        )
        mfn_pn = atomic_trait.get_manufacturer(), atomic_trait.get_partnumber()

        auto_generated = None
        if auto_generated_trait is not None:
            assert auto_generated_trait.system is not None
            assert auto_generated_trait.date is not None

            auto_generated = AtoPart.AutoGenerated(
                system=auto_generated_trait.system,
                date=datetime.datetime.fromisoformat(auto_generated_trait.date),
                source=auto_generated_trait.source,
                checksum=auto_generated_trait.checksum,
            )

        out = cls(
            identifier=path.name,
            path=path,
            mfn=mfn_pn,
            fp=fp,
            symbol=symbol,
            model=model,
            auto_generated=auto_generated,
            datasheet=datasheet,
            docstring=docstring,
            pick_part=pick_part,
        )

        out.verify_checksum(ato.ato)

        return out
