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

import datetime
import logging
import re
from copy import deepcopy
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.codegen.atocodegen import AtoCodeGen
from faebryk.libs.codegen.atocodeparse import AtoCodeParse
from faebryk.libs.codegen.pycodegen import sanitize_name
from faebryk.libs.kicad.fileformats_common import ChecksumMismatch, PropertyNotSet
from faebryk.libs.kicad.fileformats_latest import (
    C_kicad_footprint_file,
    C_kicad_model_file,
)
from faebryk.libs.kicad.fileformats_sch import C_kicad_sym_file
from faebryk.libs.picker.picker import PickedPart
from faebryk.libs.util import compare_dataclasses, starts_or_ends_replace

logger = logging.getLogger(__name__)


@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: C_kicad_footprint_file
    symbol: C_kicad_sym_file
    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"{self.fp.footprint.base_name}.kicad_mod"

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

    @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 __post_init__(self):
        self.fp = deepcopy(self.fp)
        self.fp.footprint.name = f"{self.identifier}:{self.fp.footprint.base_name}"

        self.symbol = deepcopy(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

    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_symbol_lib.symbols.values())

        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 = []
        for unit in symbol.symbols.values():
            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])

        defined_signals = 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:
                build.add_stmt(
                    AtoCodeGen.Connect(
                        left=AtoCodeGen.Connect.Connectable(
                            pin_name,
                            declare="signal"
                            if pin_name not in defined_signals
                            else None,
                        ),
                        right=AtoCodeGen.Connect.Connectable(pin_num, declare="pin"),
                    )
                )
                defined_signals.add(pin_name)

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

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

        # refresh checksums
        self.fp.footprint.set_checksum()
        symbol = first(self.symbol.kicad_symbol_lib.symbols.values())
        symbol.set_checksum()

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

        ato_builder = AtoCodeGen.ComponentFile(
            f"{self.identifier}_package", 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=symbol.propertys["Reference"].value
        )

        if self.datasheet:
            ato_builder.add_trait("has_datasheet_defined", 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_symbol_lib.symbols.values())

        # check fp & symbol
        for obj, t_name in ((fp, "Footprint"), (symbol, "Symbol")):
            try:
                obj.verify_checksum()
            except PropertyNotSet:
                raise _FileManuallyModified(
                    f"{t_name} has no checksum."
                    "But part is auto-generated. This is not allowed."
                )
            except ChecksumMismatch:
                raise _FileManuallyModified(
                    f"{t_name} has a checksum mismatch. "
                    "But part is auto-generated. This is not allowed. "
                )

        # 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_defined))
            else None
        )

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

        docstring = ato.parse_docstring()

        fp = C_kicad_footprint_file.loads(path / atomic_trait._footprint)
        symbol = C_kicad_sym_file.loads(path / atomic_trait._symbol)
        model = (
            C_kicad_model_file.loads(path / atomic_trait._model)
            if atomic_trait._model
            else None
        )
        mfn_pn = atomic_trait._manufacturer, atomic_trait._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
