#!/usr/bin/env python3
# $Id: ppresolve_components 209 2026-10-12 12:07:39Z peter $
#
# ppresolve_components --- the CLI entry point for the
# instructions -> KiCad direction of this project's own complete
# loop: parse_instructions.py + resolve_components.py +
# kicad_layout.py, wrapped together into one real, atomic tool
# rather than three separate ones, since Péter's own, direct
# altimeter-roundtrip work (2026-09-11) confirmed these three are
# ALWAYS used together, in the same fixed order, never independently
# -- the same real reasoning ppbuild_instructions(1) itself already
# applies to auto_layout.py + render.py + build_instructions.py on
# the other half of the loop. See ppparse_instructions(1) for the
# one, real, narrower piece (parsing alone, no resolution against
# real component data) that DOES stand on its own.
#
# ADDED 2026-09-11, closing the same real gap ppparse_instructions(1)
# closes -- see that program's own header comment for the fuller
# account of why this was missing until now.

import os
import sys
import json


def _version():
    """This project's own current Dewey-decimal version -- CLI-
    specific, so it lives here, not in kicad_layout.py's own library
    code. See ppnetlist's own identical helper for the fuller
    reasoning."""
    here = os.path.dirname(os.path.abspath(__file__))
    try:
        with open(os.path.join(here, "VERSION")) as f:
            for line in f:
                line = line.strip()
                if line and not line.startswith("#"):
                    return line
    except OSError:
        pass
    return "unknown"


def _load_sources_file(path):
    """The real, JSON "sources" file this program's own --sources
    option requires -- the one place external, real knowledge about
    each component (which real KiCad library it's from, or which
    real Eagle-format schematic to pull its own pin geometry from)
    has to be supplied, since the instructions text alone only ever
    carries a component's own human-readable value, never its real
    library/part identity. See ppresolve_components.1's own FILES
    section for the full, real schema and a worked example.

    Two real, top-level keys, both optional (an empty {} is valid,
    though useless: every component would fail to resolve at all):
    "sources" (per-ref: kind + kind-specific fields, passed straight
    through to resolve_components.py's own real sources dict, UNCHANGED)
    and "pin_map" (per-ref: {net_name: real_pin_id}, passed straight
    through to resolve_pin_identities()'s own real pin_map).

    Raises ValueError, naming the real file and the real problem, on
    invalid JSON or a top-level shape that isn't a real, plain
    object -- resolve_components.py's own, later, real per-ref
    errors (UnresolvedComponentError, UnresolvedPinError) still
    apply for anything wrong INSIDE a well-formed file."""
    with open(path) as f:
        try:
            data = json.load(f)
        except json.JSONDecodeError as e:
            raise ValueError(f"{path}: invalid JSON: {e}")
    if not isinstance(data, dict):
        raise ValueError(f"{path}: top level must be a real JSON object, "
                          f"not {type(data).__name__}")
    sources = data.get("sources", {})
    pin_map = data.get("pin_map", {})
    if not isinstance(sources, dict):
        raise ValueError(f"{path}: \"sources\" must be a real JSON object")
    if not isinstance(pin_map, dict):
        raise ValueError(f"{path}: \"pin_map\" must be a real JSON object")
    return sources, pin_map


def main(argv=None):
    """CLI entry point, brought up to DevelopmentStandard.org's own
    "Options and Arguments" bar from the start -- see
    ppresolve_components.1, this program's own manual page."""
    import argparse
    from parse_instructions import (parse_instructions, reconstruct, ParseError,
                                    board_config_for)
    from resolve_components import (resolve_components, resolve_pin_identities,
                                    expand_multi_unit, merge_nets_sharing_pins,
                                     resolve_implicit_anchor_connections,
                                     apply_lead_roles,
                                     UnresolvedComponentError, UnresolvedPinError)
    from auto_layout import classify, high_degree_nets, TooManyComponentsError
    from kicad_layout import generate_schematic_auto, UnplaceableError, PAPER_LADDER
    from kicad_gen import (write_project_file, DEFAULT_KICAD_VERSION, NATIVE_KICAD_VERSION, KicadVersionError, parse_kicad_version, check_kicad_version, convert_format)
    from breadboard_config import resolve_config_path, load_config

    parser = argparse.ArgumentParser(
        prog="ppresolve_components",
        description="Read a real build-instruction text file (as produced "
                     "by ppbuild_instructions(1)), resolve every "
                     "component against real component data, and write a "
                     "real, working KiCad schematic -- the "
                     "instructions -> KiCad half of this project's own "
                     "complete loop.")
    parser.add_argument("instructions_file", metavar="INSTRUCTIONS-FILE",
                         help="a real build-instruction text file, any of "
                              "this project's own four real formats "
                              "(plain, markdown, orgmode) -- html is NOT "
                              "supported (see parse_instructions.py's own "
                              "docstring for why)")
    parser.add_argument("--sources", metavar="FILE", required=True,
                         help="a real, JSON file mapping each component's "
                              "own ref to the real component data needed "
                              "to resolve it (which real KiCad library, "
                              "or which real Eagle-format schematic to "
                              "pull its own pin geometry from), plus, "
                              "optionally, real anchor pin identities -- "
                              "see ppresolve_components(1)'s own FILES "
                              "section for the exact, real schema")
    parser.add_argument("--config", metavar="FILE", default=None,
                         help="a real ppbreadboard_config config file "
                              "(same resolution as ppbuild_instructions, "
                              "checked next to INSTRUCTIONS-FILE instead "
                              "of a schematic -- see ppbreadboard_config(1)) "
                              "-- CORRECTED 2026-09-21, closing "
                              "FutureResearch.org's own item 5 follow-up: "
                              "reconstruct()'s own real hole decoding "
                              "needs to know the SAME real rows_per_half "
                              "the instructions were generated with, or "
                              "it silently, genuinely misreads hole names "
                              "whenever a non-default board config was "
                              "used at all -- this project's own, "
                              "established --config convention, not a "
                              "narrower, one-off flag, kept consistent "
                              "with ppbuild_instructions(1)/pprender(1), "
                              "even though reconstruct() itself only "
                              "ever actually uses this real config's own "
                              "rows_per_half field")
    parser.add_argument("-o", "--output", metavar="FILE", required=True,
                         help="output path for the generated "
                              ".kicad_sch file")
    parser.add_argument("--kicad-cli", metavar="CMD",
                         default=os.environ.get("KICAD_CLI", "kicad-cli"),
                         help="kicad-cli command, used for any --sources "
                              "entry of kind \"lcsc\" (default: $KICAD_CLI "
                              "if set, else 'kicad-cli' on PATH)")
    parser.add_argument("--kicad-version", metavar="N", default=os.environ.get("PACKPAY_KICAD_VERSION", str(DEFAULT_KICAD_VERSION)),
                         help="the KiCad major version of the files written: %d (written by this tool itself, no kicad-cli needed) or the major version of the "
                              "installed kicad-cli, to which the files are then converted, one 'kicad-cli sch upgrade' call per file (default: $PACKPAY_KICAD_VERSION if set, else %d)"
                              % (NATIVE_KICAD_VERSION, DEFAULT_KICAD_VERSION))
    parser.add_argument("--max-components", type=int, metavar="N",
                         help="override this project's own real, default "
                              "component-count limit (breadboard_config."
                              "DEFAULT_MAX_COMPONENTS) -- see that "
                              "constant's own docstring for the real, "
                              "direct rationale (2026-09-15). Here it is "
                              "the PER-SHEET limit: a circuit above it is "
                              "written as a root sheet plus child sheets "
                              "(OUTPUT-sheetN.kicad_sch), not refused "
                              "(2026-09-28)")
    parser.add_argument("--cache-dir", metavar="DIR", default=None,
                         help="where resolved/generated .kicad_sym files "
                              "are written (default: a real, fresh "
                              "temporary directory, discarded on exit)")
    parser.add_argument("--title", metavar="TITLE", default=None,
                         help="schematic title-block title (default: the "
                              "instructions file's own base name)")
    parser.add_argument("--date", metavar="DATE", default=None,
                         help="schematic title-block date (default: "
                              "today, YYYY-MM-DD)")
    parser.add_argument("--rev", metavar="REV", default="",
                         help="schematic title-block revision "
                              "(default: empty)")
    parser.add_argument("--company", metavar="NAME", default="",
                         help="schematic title-block company name "
                              "(default: empty)")
    parser.add_argument("--anchor-spacing", metavar="MM", type=float,
                         default=48,
                         help="horizontal spacing, in mm, between anchor "
                              "components (default: %(default)s -- see "
                              "kicad_layout.py's own generate_schematic() "
                              "docstring)")
    parser.add_argument("--row-gap", metavar="MM", type=float,
                         default=15.0,
                         help="the MINIMUM vertical gap, in mm, between the "
                              "anchor row and the dedicated row rail-only "
                              "passives (e.g. decoupling capacitors) are "
                              "placed in, and between wrapped rows "
                              "(default: %(default)s); raised automatically "
                              "where the parts' own height and label stubs "
                              "need more. Increase this if "
                              "generation fails with a label-collision "
                              "error naming the dedicated row.")
    parser.add_argument("--row-spacing", metavar="MM", type=float,
                         default=20.0,
                         help="horizontal spacing, in mm, between "
                              "components within the dedicated row "
                              "(default: %(default)s)")
    parser.add_argument("--paper", choices=PAPER_LADDER, default=None,
                         help="the paper size to try first; each larger size is still tried, in order, if the design does not fit "
                              "(default: start at A4)")
    parser.add_argument("--version", action="version",
                         version=f"%(prog)s {_version()}")
    parser.add_argument("--debug", action="store_true",
                         help="also print every retry tier "
                              "generate_schematic_auto() attempts, to "
                              "standard error")
    parser.add_argument("--quiet", action="store_true",
                         help="suppress the \"wrote FILE\" message")
    args = parser.parse_args(argv)
    try:
        kicad_version = parse_kicad_version(args.kicad_version)
        check_kicad_version(args.kicad_cli, kicad_version)
    except KicadVersionError as e:
        print(f"ppresolve_components: {e}", file=sys.stderr)
        raise SystemExit(1)

    try:
        with open(args.instructions_file) as f:
            text = f.read()
    except OSError as e:
        print(f"ppresolve_components: {args.instructions_file}: "
              f"{e.strerror}", file=sys.stderr)
        raise SystemExit(1)

    try:
        sources, pin_map = _load_sources_file(args.sources)
    except OSError as e:
        print(f"ppresolve_components: {args.sources}: {e.strerror}",
              file=sys.stderr)
        raise SystemExit(1)
    except ValueError as e:
        print(f"ppresolve_components: {e}", file=sys.stderr)
        raise SystemExit(1)

    try:
        steps = parse_instructions(text)
        config_path = resolve_config_path(explicit_path=args.config,
                                           schematic_path=args.instructions_file)
        board_config = load_config(config_path) if config_path else None
        board_config = board_config_for(text, board_config)
        nl, p = reconstruct(steps, board_config=board_config)
    except ParseError as e:
        print(f"ppresolve_components: {args.instructions_file}: {e}",
              file=sys.stderr)
        raise SystemExit(1)

    # rail_direct_power_source, ADDED 2026-09-16, EARLY refusal
    # closed and removed entirely, 2026-09-23 -- see resolve_
    # pin_identities()'s own docstring (resolve_components.py) for
    # the full, real, now-built KiCad-side equivalent this refusal
    # used to name as not-yet-built.

    cache_dir = args.cache_dir
    cleanup_cache = False
    if cache_dir is None:
        import tempfile
        cache_dir = tempfile.mkdtemp(prefix="ppresolve_components-")
        cleanup_cache = True

    try:
        try:
            pins, sym_dir, forced_anchors = resolve_components(
                nl, sources, cache_dir)
        except UnresolvedComponentError as e:
            print(f"ppresolve_components: {e}", file=sys.stderr)
            raise SystemExit(1)

        # POLARITY, stage 2: the leads whose role the instructions name (the anode, the cathode, ...) land on the symbol's own pins
        apply_lead_roles(nl, sym_dir)

        anchors, passives = classify(nl)
        anchors = sorted(set(anchors) | forced_anchors)
        passives = [r for r in passives if r not in forced_anchors]

        # the abstract power rails, recognised by the parser's synthetic "_rail_+/-"
        # pin -- BEFORE resolve_pin_identities() rewrites every pin (see
        # resolve_components.merge_nets_sharing_pins(), rail_names)
        rail_net_names = frozenset(net.name for net in nl.nets
                                   for _r, pin in net.nodes if str(pin).startswith("_rail_"))
        try:
            resolve_pin_identities(nl, pins, set(anchors), pin_map,
                                    rail_direct_sources=p.rail_direct_power_source,
                                    sym_dir=sym_dir)
        except UnresolvedPinError as e:
            print(f"ppresolve_components: {e}", file=sys.stderr)
            raise SystemExit(1)

        import datetime
        title = args.title or os.path.splitext(
            os.path.basename(args.instructions_file))[0]
        date = args.date or datetime.date.today().isoformat()

        def generate():
            """One generate_schematic_auto() on the netlist AS IT IS NOW. A part
            whose symbol has several units is laid out one virtual component per
            used unit (resolve_components.expand_multi_unit()), expanded HERE, at
            the last moment, from the real netlist each time: resolve_pin_
            identities() above and resolve_implicit_anchor_connections() below
            rewrite the netlist, and the latter pairs refs with the parsed
            instructions' own anchor columns, keyed by the REAL reference -- a
            virtual key ("U1@u2") must never reach them. With no multi-unit part
            expand_multi_unit() returns the very same objects, and this is exactly
            the call that was always made. ADDED 2026-10-06."""
            # two nets sharing a (ref, pin) are ONE net (see merge_nets_sharing_
            # pins()): a rail pin and a stated pin on the same real pin, 2026-10-06
            nl_m = merge_nets_sharing_pins(nl, rail_net_names)
            nl_g, pins_g = expand_multi_unit(nl_m, pins, sym_dir)
            if nl_g is nl_m:
                anchors_g, passives_g = anchors, passives
            else:
                a_g, passives_g = classify(nl_g)
                forced_g = {r for r in nl_g.components
                            if r.split("@u")[0] in forced_anchors}
                anchors_g = sorted(set(a_g) | forced_g)
                passives_g = [r for r in passives_g if r not in forced_g]
            return generate_schematic_auto(
                nl_g, pins_g, sym_dir, anchors_g, passives_g, high_degree_nets(nl_g),
                title=title, date=date, rev=args.rev, company=args.company,
                comments=[], anchor_spacing=args.anchor_spacing,
                verbose=args.debug, max_components=args.max_components,
                row_gap=args.row_gap, row_spacing=args.row_spacing, paper=args.paper,
            )

        try:
            sch = generate()
        except TooManyComponentsError as e:
            print(f"ppresolve_components: {e}", file=sys.stderr)
            raise SystemExit(1)
        except UnplaceableError as e:
            resolved = resolve_implicit_anchor_connections(
                nl, pins, p, set(anchors),
                frozenset(r.split("@u")[0] for r in e.unplaced_refs),
                rails=high_degree_nets(nl))
            if not resolved:
                print(f"ppresolve_components: {e}", file=sys.stderr)
                raise SystemExit(1)
            try:
                sch = generate()
            except (TooManyComponentsError, UnplaceableError) as e2:
                print(f"ppresolve_components: {e2}", file=sys.stderr)
                raise SystemExit(1)
        except RuntimeError as e:
            # any other refusal (two labels on one point, the geometric short
            # check): a clear message and exit status 1, not a traceback --
            # which is what a multi-unit part used to produce here
            print(f"ppresolve_components: {e}", file=sys.stderr)
            raise SystemExit(1)
    finally:
        if cleanup_cache:
            import shutil
            shutil.rmtree(cache_dir, ignore_errors=True)

    root_uuid = sch.write(args.output)
    pro_path = os.path.splitext(args.output)[0] + ".kicad_pro"
    write_project_file(pro_path, root_uuid,
                        os.path.splitext(os.path.basename(args.output))[0])
    try:
        convert_format(getattr(sch, "paths", None) or [args.output], args.kicad_cli, kicad_version)
    except RuntimeError as e:
        print(f"ppresolve_components: {e}", file=sys.stderr)
        raise SystemExit(1)

    if not args.quiet:
        print(f"wrote {args.output}", file=sys.stderr)
        for extra in getattr(sch, "paths", [])[1:]:
            print(f"wrote {extra}", file=sys.stderr)
        print(f"wrote {pro_path}", file=sys.stderr)


if __name__ == "__main__":
    main()
