#!/usr/bin/env python3
# $Id: ppkicad_layout 209 2026-10-12 12:07:39Z peter $
#
# ppkicad_layout --- the CLI entry point for Stage 3b of this
# project's own pipeline (Workflow.org): an existing KiCad schematic,
# or a netlist exported from one, in; a NEW, algorithmically laid-out
# KiCad schematic out -- split across a root sheet and child sheets
# when it is above the per-sheet limit -- with no build instructions
# in between at all.
#
# ADDED 2026-10-05, the second of the four deliberately deferred
# multi-sheet follow-ups (FutureResearch.org's own "Multi-sheet KiCad
# output" item, order agreed with Péter 2026-10-05: nested-hierarchy
# verification, THIS tool, functional grouping, sheet reuse). It
# closes two older gaps at once: Workflow.org's own Stage 3b had said
# "no direct CLI yet" since Péter's own, direct, repeated correction
# of 2026-09-16, and ppnetlist's own help text had pointed at a
# "ppkicad_layout(1)" that never existed (noted, and left, 2026-09-11).
#
# A thin wrapper, by design, in the same sense as ppresolve_components:
# resolve_components.py + auto_layout.py's classify() +
# kicad_layout.py's generate_schematic_auto(), always used together,
# in the same fixed order. Unlike ppresolve_components there is no
# parse step and no --sources file: a netlist exported from a real
# schematic already carries every component's real library and part
# and every pin's real number, so the only external knowledge it can
# still lack is a project-LOCAL symbol library -- --symbol-lib.

import os
import sys


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 _fail(message):
    print(f"ppkicad_layout: {message}", file=sys.stderr)
    raise SystemExit(1)


def _bounded(kind, lo, inclusive):
    """argparse `type=` factory: a real number of `kind` that is > lo
    (or >= lo when inclusive) -- every user-provided value is validated
    (DevelopmentStandard.org's own "Values Validation")."""
    import argparse

    def convert(text):
        try:
            value = kind(text)
        except ValueError:
            raise argparse.ArgumentTypeError(
                f"{text!r} is not a valid {kind.__name__}")
        if value < lo or (value == lo and not inclusive):
            raise argparse.ArgumentTypeError(
                f"must be {'>=' if inclusive else '>'} {lo}, got {value}")
        return value
    return convert


def _parse_symbol_lib(text):
    """[NICKNAME=]FILE -> (nickname, path). The nickname defaults to
    FILE's own base name without .kicad_sym, which is what KiCad's own
    sym-lib-table conventionally uses for a project-local library;
    spell it out when it differs. Raises ValueError, naming the real
    problem, for a path that is not a readable .kicad_sym file."""
    nickname, sep, path = text.partition("=")
    if not sep:
        nickname, path = None, text
    if not path.endswith(".kicad_sym"):
        raise ValueError(f"--symbol-lib {text!r}: {path!r} is not a "
                         f".kicad_sym file")
    if not os.path.isfile(path):
        raise ValueError(f"--symbol-lib {text!r}: {path}: no such file")
    if nickname is None:
        nickname = os.path.basename(path)[:-len(".kicad_sym")]
    if not nickname:
        raise ValueError(f"--symbol-lib {text!r}: empty library nickname")
    return nickname, path


def main(argv=None):
    """CLI entry point, brought up to DevelopmentStandard.org's own
    "Options and Arguments" bar from the start -- see
    ppkicad_layout.1, this program's own manual page."""
    import argparse
    import datetime
    import shutil
    import subprocess
    import tempfile

    from netlist import load_netlist, load_from_schematic
    from resolve_components import (resolve_components, UnresolvedComponentError,
                                    expand_multi_unit,
                                    embedded_symbol_libs)
    from auto_layout import (classify, high_degree_nets,
                             order_anchors_by_connectivity, TooManyComponentsError)
    from breadboard_config import DEFAULT_MAX_COMPONENTS
    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)

    parser = argparse.ArgumentParser(
        prog="ppkicad_layout",
        description="Read a real KiCad schematic (or a netlist exported "
                     "from one) and write a NEW, algorithmically laid-out "
                     "KiCad schematic of the same circuit -- split across "
                     "a root sheet and child sheets when it is above the "
                     "per-sheet component limit. Stage 3b of this "
                     "project's own pipeline (Workflow.org).")
    parser.add_argument("input_file", metavar="INPUT",
                         help="a .kicad_sch schematic (its netlist is "
                              "exported with kicad-cli, see --kicad-cli) "
                              "or a .net file already exported with "
                              "'kicad-cli sch export netlist --format "
                              "kicadsexpr' (the same file ppnetlist(1) "
                              "reads; kicad-cli is then not needed)")
    parser.add_argument("-o", "--output", metavar="FILE", required=True,
                         help="output path for the generated .kicad_sch "
                              "file; a split design also writes "
                              "FILE-sheetN.kicad_sch beside it (and "
                              "FILE-sheetN-sheetM.kicad_sch for a sheet "
                              "split further), plus FILE's .kicad_pro")
    parser.add_argument("--symbol-lib", metavar="[NICKNAME=]FILE",
                         action="append", default=[],
                         help="a symbol library (.kicad_sym) for the "
                              "netlist's components, taking precedence "
                              "over everything else. A .kicad_sch INPUT "
                              "needs none: its own embedded symbols (and "
                              "its sub-sheets') are used, before KiCad's "
                              "standard directory ($KICAD_SYM_DIR). A "
                              ".net INPUT carries no symbols, so a "
                              "project-local library must be given here. "
                              "NICKNAME is the library name the netlist "
                              "uses (default: FILE's base name without "
                              ".kicad_sym). May be repeated")
    parser.add_argument("--kicad-cli", metavar="CMD",
                         default=os.environ.get("KICAD_CLI", "kicad-cli"),
                         help="kicad-cli command, used only for a "
                              ".kicad_sch INPUT (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=_bounded(int, 1, True),
                         metavar="N",
                         help="the PER-SHEET component limit (default: "
                              "this project's own breadboard_config."
                              "DEFAULT_MAX_COMPONENTS, 150): a circuit "
                              "above it is written as a root sheet plus "
                              "child sheets, not refused")
    parser.add_argument("--flatten", action="store_true",
                         help="ignore the source design's own sheets and "
                              "lay the circuit out as one sheet (split "
                              "mechanically only above --max-components). "
                              "Default: a hierarchical source stays "
                              "hierarchical -- one sheet per source sheet, "
                              "named after it, however small the design")
    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("--cache-dir", metavar="DIR", default=None,
                         help="where resolved .kicad_sym files are "
                              "collected (default: a fresh temporary "
                              "directory, discarded on exit)")
    parser.add_argument("--title", metavar="TITLE", default=None,
                         help="schematic title-block title (default: the "
                              "input 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=_bounded(float, 0, False), default=48,
                         help="the MINIMUM horizontal spacing, in mm, between "
                              "anchor components (default: %(default)s); "
                              "widened automatically for a large anchor "
                              "so neighbours never overlap. "
                              "Increase it if generation fails with "
                              "'placed close enough to genuinely "
                              "overlap' at every paper size")
    parser.add_argument("--row-gap", metavar="MM",
                         type=_bounded(float, 0, True), default=15.0,
                         help="the MINIMUM vertical gap, in mm, between the "
                              "anchor row and the dedicated rows, and "
                              "between wrapped rows (default: %(default)s); "
                              "raised automatically where the parts' own "
                              "height and label stubs need more. Increase "
                              "it if generation fails with a "
                              "label-collision error naming a dedicated row")
    parser.add_argument("--row-spacing", metavar="MM",
                         type=_bounded(float, 0, False), default=20.0,
                         help="horizontal spacing, in mm, between "
                              "components within a dedicated row "
                              "(default: %(default)s)")
    parser.add_argument("--version", action="version",
                         version=f"%(prog)s {_version()}")
    parser.add_argument("--debug", action="store_true",
                         help="also print every retry tier and split "
                              "attempt generate_schematic_auto() makes, "
                              "to standard error; and show a Python "
                              "traceback for an unexpected error")
    parser.add_argument("--quiet", action="store_true",
                         help="suppress the \"wrote FILE\" messages")
    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:
        _fail(str(e))

    # ---- validate everything the user gave us, before any real work
    if os.path.abspath(args.output) == os.path.abspath(args.input_file):
        _fail(f"{args.output}: the output would overwrite the input")
    extra_libs = {}
    for text in args.symbol_lib:
        try:
            nickname, path = _parse_symbol_lib(text)
        except ValueError as e:
            _fail(str(e))
        if nickname in extra_libs and extra_libs[nickname] != path:
            _fail(f"--symbol-lib: library {nickname!r} given twice, with "
                  f"different files ({extra_libs[nickname]} and {path})")
        extra_libs[nickname] = path
    out_dir = os.path.dirname(os.path.abspath(args.output))
    if not os.path.isdir(out_dir):
        _fail(f"{args.output}: {out_dir}: no such directory")

    # ---- the netlist
    ext = os.path.splitext(args.input_file)[1].lower()
    try:
        if ext == ".net":
            with open(args.input_file) as f:
                nl = load_netlist(f.read())
        elif ext == ".kicad_sch":
            if not os.path.isfile(args.input_file):
                raise OSError(2, "No such file or directory")
            try:
                nl = load_from_schematic(args.input_file,
                                          kicad_cli=args.kicad_cli)
            except (FileNotFoundError, PermissionError) as e:
                # the INPUT was checked just above, so this is the
                # kicad-cli executable itself, not the schematic
                _fail(f"cannot run kicad-cli ({args.kicad_cli!r}): "
                      f"{e.strerror or e} -- set --kicad-cli or $KICAD_CLI, "
                      f"or export a .net file first and pass that instead")
        else:
            _fail(f"{args.input_file}: INPUT must be a .kicad_sch "
                  f"schematic or a .net netlist export "
                  f"(got {ext or 'no extension'!r})")
    except OSError as e:
        _fail(f"{args.input_file}: {e.strerror or e}")
    except ValueError as e:
        _fail(f"{args.input_file}: {e}")
    except subprocess.CalledProcessError as e:
        detail = (e.stderr or e.stdout or "").strip().splitlines()
        _fail(f"{args.input_file}: kicad-cli failed to export its netlist "
              f"({args.kicad_cli!r} exit {e.returncode})"
              + (f": {detail[-1]}" if detail else ""))
    if not nl.components:
        _fail(f"{args.input_file}: the netlist has no components")

    # ---- resolve, place, write
    cache_dir = args.cache_dir
    cleanup_cache = False
    if cache_dir is None:
        cache_dir = tempfile.mkdtemp(prefix="ppkicad_layout-")
        cleanup_cache = True
    try:
        # The input schematic's OWN embedded symbols (and its sub-sheets'),
        # for a .kicad_sch INPUT: precedence is an explicit --symbol-lib,
        # then these, then KiCad's installed libraries (Péter's own
        # decision, 2026-10-05). A .net carries no symbols at all.
        if ext == ".kicad_sch":
            embedded = embedded_symbol_libs(
                args.input_file, os.path.join(cache_dir, "embedded"))
            if args.debug:
                print("ppkicad_layout: symbols embedded in the schematic: "
                      + (", ".join(sorted(embedded)) or "none"),
                      file=sys.stderr)
            extra_libs = {**embedded, **extra_libs}
        try:
            pins, sym_dir, forced_anchors = resolve_components(
                nl, {}, cache_dir, extra_libs=extra_libs)
        except UnresolvedComponentError as e:
            _fail(str(e))

        # A symbol with several units (a dual op-amp) is laid out as one
        # virtual component per used unit: the units of a symbol are all
        # drawn at the same origin, so as one component their pins sit on top
        # of each other (2026-10-05, FutureResearch.org's dense-circuit item).
        nl, pins = expand_multi_unit(nl, pins, sym_dir)
        forced_anchors = {r for r in nl.components
                          if r.split("@u")[0] in forced_anchors}

        anchors, passives = classify(nl)
        anchors = set(anchors) | forced_anchors
        passives = [r for r in passives if r not in forced_anchors]
        # Connectivity order, not alphabetical: generate_schematic_auto()'s
        # own ordered_anchors parameter means exactly that, and an
        # alphabetical row puts unrelated anchors side by side -- confirmed
        # directly, 2026-10-05, on this project's own ppexample_circuit2
        # (11 parts): alphabetical order failed on every paper tier ("J1
        # and U1 ... genuinely overlap") and the split ladder then split a
        # circuit that fits one sheet.
        rails = high_degree_nets(nl)
        anchors = order_anchors_by_connectivity(nl, anchors, rails)

        title = args.title or os.path.splitext(
            os.path.basename(args.input_file))[0]
        date = args.date or datetime.date.today().isoformat()
        try:
            sch = generate_schematic_auto(
                nl, pins, sym_dir, anchors, passives, rails,
                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, group_by_source=not args.flatten)
        except (TooManyComponentsError, UnplaceableError) as e:
            _fail(str(e))
        except RuntimeError as e:
            # generate_schematic_auto()'s own "no paper size, however
            # generous, made it fit" -- a genuine "needs a human" signal
            if args.debug:
                raise
            _fail(str(e))
        except KeyError as e:
            if args.debug:
                raise
            _fail(f"a component's own pin could not be matched to its "
                  f"library symbol: {e.args[0] if e.args else e}")
    finally:
        if cleanup_cache:
            shutil.rmtree(cache_dir, ignore_errors=True)

    try:
        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])
    except OSError as e:
        _fail(f"{args.output}: {e.strerror or e}")
    try:
        convert_format(getattr(sch, "paths", None) or [args.output], args.kicad_cli, kicad_version)
    except RuntimeError as e:
        _fail(str(e))

    if not args.quiet:
        limit = args.max_components or DEFAULT_MAX_COMPONENTS
        if (len(getattr(sch, "paths", [])) > 1 and len(nl.components) <= limit
                and not getattr(sch, "names", None)):
            print(f"note: {len(nl.components)} components is within the "
                  f"per-sheet limit ({limit}), but the circuit fit no "
                  f"single sheet at any paper size, so it was split "
                  f"across several (--debug shows each attempt; "
                  f"--anchor-spacing or --paper may avoid it)",
                  file=sys.stderr)
        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()
