#!/usr/bin/env python3
"""
Convert Packrift public fixture CSVs into packingsolver box input folders.

This converter is a public integration reference for the Packrift fixture pack.
It reads the Packrift carton and scenario-order CSV files and writes one
packingsolver-style folder per scenario:

  <order_id>/bins.csv
  <order_id>/items.csv
  <order_id>/parameters.csv

The scenario groupings are generated benchmark inputs, not real customer
orders, and this script does not claim known optimal packing solutions.
"""

from __future__ import annotations

import argparse
import csv
import json
import sys
from collections import defaultdict
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from pathlib import Path


BIN_FIELDS = ["ID", "X", "Y", "Z", "COST", "COPIES"]
ITEM_FIELDS = [
    "ID",
    "X",
    "Y",
    "Z",
    "COPIES",
    "PROFIT",
    "ROTATION_XYZ",
    "ROTATION_YXZ",
    "ROTATION_ZYX",
    "ROTATION_YZX",
    "ROTATION_XZY",
    "ROTATION_ZXY",
]
PARAMETER_FIELDS = ["NAME", "VALUE"]
REQUIRED_CARTON_FIELDS = [
    "carton_id",
    "length_in",
    "width_in",
    "height_in",
    "volume_cuin",
]
REQUIRED_ORDER_FIELDS = [
    "order_id",
    "item_length_in",
    "item_width_in",
    "item_height_in",
    "item_count",
]


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Convert Packrift fixture CSVs into packingsolver box input files."
    )
    parser.add_argument(
        "input_dir",
        type=Path,
        help="Directory containing fixture_cartons_v2026.05.31.csv and fixture_orders_v2026.05.31.csv.",
    )
    parser.add_argument(
        "output_dir",
        type=Path,
        help="Directory where per-scenario packingsolver folders will be written.",
    )
    parser.add_argument(
        "--cartons",
        default="fixture_cartons_v2026.05.31.csv",
        help="Carton candidate CSV filename relative to input_dir.",
    )
    parser.add_argument(
        "--orders",
        default="fixture_orders_v2026.05.31.csv",
        help="Scenario order-line CSV filename relative to input_dir.",
    )
    parser.add_argument(
        "--scenario",
        action="append",
        dest="scenarios",
        help="Optional scenario order_id to convert. Repeat for multiple scenarios. Defaults to all scenarios.",
    )
    parser.add_argument(
        "--overwrite",
        action="store_true",
        help="Overwrite existing bins/items/parameters files if they already exist.",
    )
    return parser.parse_args()


def require_fields(path: Path, fieldnames: list[str] | None, required: list[str]) -> None:
    missing = [field for field in required if field not in (fieldnames or [])]
    if missing:
        raise ValueError(f"{path} is missing required columns: {', '.join(missing)}")


def decimal_value(value: str, field: str, row_id: str) -> Decimal:
    try:
        result = Decimal(str(value).strip())
    except (InvalidOperation, ValueError) as exc:
        raise ValueError(f"{row_id}: {field} must be numeric, got {value!r}") from exc
    if result <= 0:
        raise ValueError(f"{row_id}: {field} must be positive, got {value!r}")
    return result


def scale_inches(value: str, field: str, row_id: str) -> int:
    return int(
        (decimal_value(value, field, row_id) * Decimal("1000")).quantize(
            Decimal("1"), rounding=ROUND_HALF_UP
        )
    )


def scaled_cost(volume_cuin: str, row_id: str) -> int:
    return int(
        (decimal_value(volume_cuin, "volume_cuin", row_id) * Decimal("1000")).quantize(
            Decimal("1"), rounding=ROUND_HALF_UP
        )
    )


def positive_int(value: str, field: str, row_id: str) -> int:
    try:
        result = int(Decimal(str(value).strip()))
    except (InvalidOperation, ValueError) as exc:
        raise ValueError(f"{row_id}: {field} must be an integer, got {value!r}") from exc
    if result <= 0:
        raise ValueError(f"{row_id}: {field} must be positive, got {value!r}")
    return result


def read_cartons(path: Path) -> list[dict[str, int]]:
    with path.open(newline="", encoding="utf-8") as handle:
        reader = csv.DictReader(handle)
        require_fields(path, reader.fieldnames, REQUIRED_CARTON_FIELDS)
        cartons = []
        for index, row in enumerate(reader):
            row_id = row.get("carton_id") or f"carton-row-{index + 1}"
            x = scale_inches(row["length_in"], "length_in", row_id)
            y = scale_inches(row["width_in"], "width_in", row_id)
            z = scale_inches(row["height_in"], "height_in", row_id)
            cartons.append(
                {
                    "ID": index,
                    "X": x,
                    "Y": y,
                    "Z": z,
                    "COST": scaled_cost(row["volume_cuin"], row_id),
                    "COPIES": 1,
                }
            )
    if not cartons:
        raise ValueError(f"{path} contains no carton rows")
    return cartons


def read_orders(path: Path) -> dict[str, list[dict[str, int]]]:
    grouped: dict[str, list[dict[str, int]]] = defaultdict(list)
    with path.open(newline="", encoding="utf-8") as handle:
        reader = csv.DictReader(handle)
        require_fields(path, reader.fieldnames, REQUIRED_ORDER_FIELDS)
        for row_number, row in enumerate(reader, start=2):
            order_id = (row.get("order_id") or "").strip()
            if not order_id:
                raise ValueError(f"{path}:{row_number}: order_id is required")
            row_id = f"{order_id} line {len(grouped[order_id]) + 1}"
            x = scale_inches(row["item_length_in"], "item_length_in", row_id)
            y = scale_inches(row["item_width_in"], "item_width_in", row_id)
            z = scale_inches(row["item_height_in"], "item_height_in", row_id)
            grouped[order_id].append(
                {
                    "ID": len(grouped[order_id]),
                    "X": x,
                    "Y": y,
                    "Z": z,
                    "COPIES": positive_int(row["item_count"], "item_count", row_id),
                    "PROFIT": x * y * z,
                    "ROTATION_XYZ": 1,
                    "ROTATION_YXZ": 1,
                    "ROTATION_ZYX": 1,
                    "ROTATION_YZX": 1,
                    "ROTATION_XZY": 1,
                    "ROTATION_ZXY": 1,
                }
            )
    if not grouped:
        raise ValueError(f"{path} contains no scenario order rows")
    return dict(grouped)


def write_csv(path: Path, fieldnames: list[str], rows: list[dict[str, int | str]], overwrite: bool) -> None:
    if path.exists() and not overwrite:
        raise FileExistsError(f"{path} already exists; pass --overwrite to replace it")
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", newline="", encoding="utf-8") as handle:
        writer = csv.DictWriter(handle, fieldnames=fieldnames, lineterminator="\n")
        writer.writeheader()
        writer.writerows(rows)


def convert(input_dir: Path, output_dir: Path, cartons_name: str, orders_name: str, scenarios: list[str] | None, overwrite: bool) -> dict[str, int | list[str]]:
    cartons_path = input_dir / cartons_name
    orders_path = input_dir / orders_name
    cartons = read_cartons(cartons_path)
    orders = read_orders(orders_path)

    selected = scenarios or sorted(orders)
    unknown = [scenario for scenario in selected if scenario not in orders]
    if unknown:
        raise ValueError(f"Unknown scenario order_id(s): {', '.join(unknown)}")

    files_written = 0
    item_rows = 0
    for order_id in selected:
        scenario_dir = output_dir / order_id
        write_csv(scenario_dir / "bins.csv", BIN_FIELDS, cartons, overwrite)
        write_csv(scenario_dir / "items.csv", ITEM_FIELDS, orders[order_id], overwrite)
        write_csv(
            scenario_dir / "parameters.csv",
            PARAMETER_FIELDS,
            [{"NAME": "objective", "VALUE": "variable-sized-bin-packing"}],
            overwrite,
        )
        files_written += 3
        item_rows += len(orders[order_id])

    return {
        "carton_candidates": len(cartons),
        "scenarios_written": len(selected),
        "item_rows_written": item_rows,
        "files_written": files_written,
        "output_dir": str(output_dir),
        "boundaries": [
            "Generated scenarios are not real customer orders.",
            "No known optimal or quasi-optimal packing solution is claimed.",
            "Target carton fields in source rows are scenario anchors, not solver answers.",
        ],
    }


def main() -> int:
    args = parse_args()
    try:
        report = convert(
            args.input_dir,
            args.output_dir,
            args.cartons,
            args.orders,
            args.scenarios,
            args.overwrite,
        )
    except Exception as exc:
        print(f"convert_box_packrift.py: error: {exc}", file=sys.stderr)
        return 1
    print(json.dumps(report, indent=2, sort_keys=True))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
