"""Part search/install helpers (JLCPCB/LCSC via parts API)."""

from __future__ import annotations

import ast
import os
import re
from collections.abc import Mapping
from pathlib import Path
from typing import TYPE_CHECKING, Iterable

if TYPE_CHECKING:
    from faebryk.libs.kicad.fileformats import kicad

import faebryk.core.faebrykpy as fbrk
import faebryk.core.graph as graph
import faebryk.library._F as F
from atopile import errors
from atopile.config import config
from atopile.server.domains.parts import serialize_component
from atopile.server.domains.parts_search_jlc import search_jlc_parts
from faebryk.libs.ato_part import AtoPart
from faebryk.libs.part_lifecycle import PartIsNotAutoGenerated, PartLifecycle
from faebryk.libs.picker.api.api import ApiHTTPError, get_api_client
from faebryk.libs.picker.api.models import Component
from faebryk.libs.picker.lcsc import download_easyeda_info
from faebryk.libs.util import robustly_rm_dir

_LCSC_RE = re.compile(r"^C?\d+$", re.IGNORECASE)


def _normalize_lcsc_id(raw: str) -> int | None:
    candidate = raw.strip().upper()
    if not _LCSC_RE.match(candidate):
        return None
    return int(candidate[1:] if candidate.startswith("C") else candidate)


def _serialize_part(component: Component) -> dict:
    """Serialize a Component with pretty-printed attributes."""
    return serialize_component(component, attributes_formatter=_pretty_attributes)


def _pretty_attributes(attributes: dict[str, object] | None) -> dict[str, str]:
    if not attributes:
        return {}

    g = graph.GraphView.create()
    tg = fbrk.TypeGraph.create(g=g)

    # Create unit instances for decode_symbol_runtime during deserialization
    from faebryk.library.Units import Ampere, Farad, Henry, Ohm, Volt, Watt

    for unit_type in [Ohm, Volt, Watt, Farad, Ampere, Henry]:
        unit_type.bind_typegraph(tg=tg).create_instance(g=g)

    pretty: dict[str, str] = {}
    for name, value in attributes.items():
        if value is None:
            continue

        literal_data = None
        if isinstance(value, Mapping):
            literal_data = dict(value)
        elif isinstance(value, str):
            raw = value.strip()
            if raw.startswith("{") and raw.endswith("}"):
                try:
                    parsed = ast.literal_eval(raw)
                    if isinstance(parsed, dict):
                        literal_data = parsed
                except Exception:
                    literal_data = None

        if isinstance(literal_data, dict) and literal_data.get("type"):
            try:
                literal = F.Literals.is_literal.deserialize(literal_data, g=g, tg=tg)
                pretty[name] = literal.pretty_str()
                continue
            except Exception:
                pass
        pretty[name] = str(value)

    return pretty


def _sort_key(component: Component) -> tuple:
    # In-stock, preferred, basic, highest stock, lowest unit price.
    return (
        0 if component.stock > 0 else 1,
        0 if component.is_preferred else 1,
        0 if component.is_basic else 1,
        -component.stock,
        component.get_price(1),
    )


def _safe_fetch_parts(
    components: Iterable[Component],
    limit: int | None = None,
) -> list[dict]:
    sorted_components = sorted(components, key=_sort_key)
    if limit is not None:
        sorted_components = sorted_components[:limit]
    return [_serialize_part(component) for component in sorted_components]


def _search_atopile_backend(
    query: str,
    *,
    limit: int = 50,
) -> tuple[list[dict], str | None]:
    query = query.strip()
    if not query:
        return [], "Missing query"

    client = get_api_client()
    lcsc_id = _normalize_lcsc_id(query)
    try:
        if lcsc_id is not None:
            components = client.fetch_part_by_lcsc(lcsc_id)
            return _safe_fetch_parts(components, limit=limit), None

        if ":" in query:
            manufacturer, part_number = [part.strip() for part in query.split(":", 1)]
            if not manufacturer or not part_number:
                return [], "Expected Manufacturer:PartNumber"
            components = client.fetch_part_by_mfr(manufacturer, part_number)
            return _safe_fetch_parts(components, limit=limit), None

        return [], "Search currently supports LCSC IDs or Manufacturer:PartNumber"
    except ApiHTTPError as exc:
        if exc.response.status_code == 404:
            return [], None
        raise


def handle_search_parts(
    query: str, *, limit: int = 50
) -> tuple[list[dict], str | None]:
    provider = os.getenv("ATOPILE_PARTS_SEARCH_PROVIDER", "jlc_public").lower()
    if provider in {"jlc", "jlc_public", "jlcpcb"}:
        return search_jlc_parts(query, limit=limit)
    return _search_atopile_backend(query, limit=limit)


def handle_get_part_details(lcsc_id: str) -> dict | None:
    lcsc_numeric = _normalize_lcsc_id(lcsc_id)
    if lcsc_numeric is None:
        raise ValueError(f"Invalid LCSC part number: {lcsc_id}")

    client = get_api_client()
    try:
        components = client.fetch_part_by_lcsc(lcsc_numeric)
    except ApiHTTPError as exc:
        if exc.response.status_code == 404:
            return None
        raise

    if not components:
        return None

    component = components[0]
    detail = _serialize_part(component)
    detail["footprint"] = component.package
    detail["image_url"] = _fetch_jlc_image(lcsc_id)
    return detail


def _fetch_jlc_image(lcsc_id: str) -> str | None:
    try:
        results, _error = search_jlc_parts(lcsc_id, limit=3)
    except Exception:
        return None

    if not results:
        return None

    lcsc_upper = lcsc_id.strip().upper()
    for part in results:
        if str(part.get("lcsc", "")).upper() == lcsc_upper:
            return part.get("image_url")

    return results[0].get("image_url")


def handle_install_part(lcsc_id: str, project_root: str) -> dict:
    config.apply_options(None, working_dir=Path(project_root))

    try:
        epart = download_easyeda_info(lcsc_id, get_model=True)
        apart = PartLifecycle.singleton().library.ingest_part_from_easyeda(epart)
    except PartIsNotAutoGenerated as exc:
        raise errors.UserException(
            f"Part `{exc.part.path}` already exists and is manually modified."
        ) from exc
    except Exception as exc:
        raise errors.UserException(str(exc)) from exc

    return {
        "identifier": apart.identifier,
        "path": str(apart.path),
    }


def handle_list_installed_parts(project_root: str) -> list[dict]:
    config.apply_options(None, working_dir=Path(project_root))
    parts_path = config.project.paths.parts
    if not parts_path.exists():
        return []

    parts: list[dict] = []
    for part_dir in sorted(parts_path.iterdir()):
        if not part_dir.is_dir():
            continue
        ato_file = part_dir / f"{part_dir.name}.ato"
        if not ato_file.exists():
            continue
        try:
            part = AtoPart.load(part_dir)
        except Exception:
            continue

        lcsc_id = None
        if part.pick_part and part.pick_part.supplier.supplier_id.lower() == "lcsc":
            lcsc_id = part.pick_part.supplier_partno

        # Return basic info immediately - frontend enriches with stock/price async
        parts.append(
            {
                "identifier": part.identifier,
                "manufacturer": part.mfn[0],
                "mpn": part.mfn[1],
                "lcsc": lcsc_id,
                "datasheet_url": part.datasheet,
                "description": part.docstring,
                "path": str(part.path),
            }
        )

    return parts


def _lib_fp_to_pcb_fp(
    lib_fp: "kicad.footprint.Footprint",
) -> "kicad.pcb.Footprint":
    """Convert a library footprint to a PCB footprint for embedding in a kicad_pcb."""
    from faebryk.libs.kicad.fileformats import kicad

    # Get common fields between footprint and pcb.Footprint
    common_fields = {
        field: getattr(lib_fp, field)
        for field in set(kicad.footprint.Footprint.__field_names__())
        & set(kicad.pcb.Footprint.__field_names__())
    }

    # Convert pads (they share the same structure)
    common_fields["pads"] = [
        kicad.pcb.Pad(
            **{f: getattr(p, f) for f in kicad.pcb.Pad.__field_names__()},
        )
        for p in lib_fp.pads
    ]

    # Generate a UUID for the footprint
    common_fields["uuid"] = kicad.gen_uuid()

    # Create PCB footprint with position at origin
    pcb_fp = kicad.pcb.Footprint(
        at=kicad.pcb.Xyr(x=0, y=0, r=0),
        **common_fields,
    )

    # Remove all text elements entirely (REF**, VALUE, etc.)
    # Hiding them isn't enough - KiCanvas still calculates bounding box including them
    pcb_fp.propertys.clear()
    pcb_fp.fp_texts.clear()

    return pcb_fp


def handle_get_part_footprint(lcsc_id: str) -> bytes | None:
    """Fetch footprint data wrapped in a kicad_pcb file for viewing in kicanvas."""
    from easyeda2kicad.easyeda.easyeda_api import EasyedaApi
    from easyeda2kicad.easyeda.easyeda_importer import EasyedaFootprintImporter
    from easyeda2kicad.kicad.export_kicad_footprint import ExporterFootprintKicad

    from faebryk.libs.kicad.fileformats import kicad
    from faebryk.libs.test.fileformats import PCBFILE
    from faebryk.libs.util import call_with_file_capture

    lcsc_numeric = _normalize_lcsc_id(lcsc_id)
    if lcsc_numeric is None:
        raise ValueError(f"Invalid LCSC part number: {lcsc_id}")

    lcsc_str = f"C{lcsc_numeric}"

    try:
        # Fetch from EasyEDA API directly
        api = EasyedaApi()
        cad_data = api.get_cad_data_of_component(lcsc_id=lcsc_str)
        if not cad_data:
            return None

        # Import footprint
        easyeda_footprint = EasyedaFootprintImporter(
            easyeda_cp_cad_data=cad_data
        ).get_footprint()

        # Export to KiCad format
        exporter = ExporterFootprintKicad(easyeda_footprint)
        fp_raw = call_with_file_capture(lambda path: exporter.export(str(path), None))[
            1
        ]

        # Convert to latest KiCad format
        fp_file = kicad.loads(kicad.footprint_v5.FootprintFile, fp_raw.decode("utf-8"))
        fp_file = kicad.convert(fp_file)

        # Load template PCB and wrap the footprint in it (kicanvas needs kicad_pcb)
        template_pcb = kicad.loads(kicad.pcb.PcbFile, PCBFILE)
        pcb = template_pcb.kicad_pcb

        # Convert library footprint to PCB footprint and replace template's footprints
        pcb_fp = _lib_fp_to_pcb_fp(fp_file.footprint)
        pcb.footprints.clear()
        pcb.footprints.append(pcb_fp)

        # Clear nets and other elements we don't need for viewing
        pcb.nets.clear()
        pcb.zones.clear()
        pcb.segments.clear()
        pcb.vias.clear()
        pcb.gr_texts.clear()
        pcb.gr_text_boxes.clear()

        return kicad.dumps(template_pcb).encode("utf-8")
    except Exception as e:
        import logging

        logging.getLogger(__name__).warning(
            f"Failed to get footprint for {lcsc_id}: {e}"
        )
        return None


def handle_get_part_model(lcsc_id: str) -> tuple[bytes, str] | None:
    """Fetch the STEP 3D model data for a part."""
    from easyeda2kicad.easyeda.easyeda_api import EasyedaApi
    from easyeda2kicad.easyeda.easyeda_importer import EasyedaFootprintImporter

    lcsc_numeric = _normalize_lcsc_id(lcsc_id)
    if lcsc_numeric is None:
        raise ValueError(f"Invalid LCSC part number: {lcsc_id}")

    lcsc_str = f"C{lcsc_numeric}"

    try:
        # Fetch from EasyEDA API directly
        api = EasyedaApi()
        cad_data = api.get_cad_data_of_component(lcsc_id=lcsc_str)
        if not cad_data:
            return None

        # Import footprint to get model info
        easyeda_footprint = EasyedaFootprintImporter(
            easyeda_cp_cad_data=cad_data
        ).get_footprint()

        if easyeda_footprint.model_3d is None:
            return None

        # Fetch the 3D model
        model_data = api.get_step_3d_model(uuid=easyeda_footprint.model_3d.uuid)
        if model_data is None:
            return None

        return model_data, easyeda_footprint.model_3d.name
    except Exception as e:
        import logging

        logging.getLogger(__name__).warning(
            f"Failed to get 3D model for {lcsc_id}: {e}"
        )
        return None


def handle_uninstall_part(lcsc_id: str, project_root: str) -> dict:
    config.apply_options(None, working_dir=Path(project_root))
    parts_path = config.project.paths.parts
    if not parts_path.exists():
        raise errors.UserException("No parts installed in this project.")

    target = lcsc_id.strip().upper()
    for part_dir in sorted(parts_path.iterdir()):
        if not part_dir.is_dir():
            continue
        ato_file = part_dir / f"{part_dir.name}.ato"
        if not ato_file.exists():
            continue
        try:
            part = AtoPart.load(part_dir)
        except Exception:
            continue

        supplier_partno = None
        if part.pick_part and part.pick_part.supplier.supplier_id.lower() == "lcsc":
            supplier_partno = part.pick_part.supplier_partno

        if not supplier_partno or supplier_partno.strip().upper() != target:
            continue

        if not part.auto_generated:
            raise errors.UserException(
                f"Part `{part.path}` is manually modified; "
                "remove is_auto_generated to delete."
            )

        robustly_rm_dir(part.path)
        return {
            "identifier": part.identifier,
            "path": str(part.path),
        }

    raise errors.UserException(f"Part {lcsc_id} not found in this project.")


def test_selftest_pretty_attributes() -> None:
    """
    Lightweight sanity check for attribute decoding.
    Not executed automatically; call manually if needed.
    """
    from faebryk.library.Units import Ohm

    value_dict = {
        "type": "Quantity_Interval_Disjoint",
        "data": {
            "intervals": {
                "type": "Numeric_Interval_Disjoint",
                "data": {
                    "intervals": [
                        {
                            "type": "Numeric_Interval",
                            "data": {"min": 9.9e3, "max": 1.01e4},
                        }
                    ]
                },
            },
            "unit": "ohm",
        },
    }

    # Create graph with unit instances for computing expected value
    g = graph.GraphView.create()
    tg = fbrk.TypeGraph.create(g=g)
    Ohm.bind_typegraph(tg=tg).create_instance(g=g)
    expected = F.Literals.is_literal.deserialize(value_dict, g=g, tg=tg).pretty_str()

    pretty_from_str = _pretty_attributes({"resistance": str(value_dict)})
    assert pretty_from_str["resistance"] == expected

    pretty_from_dict = _pretty_attributes({"resistance": value_dict})
    assert pretty_from_dict["resistance"] == expected
