#!/usr/bin/env python3
"""Fill AIME production order template from Odoo MRP export.

Usage:
    python fill_production_order.py <source_xlsx>
    python fill_production_order.py               # uses newest matching source file

The script loads the specified Excel source file, extracts relevant fields,
fills the ODS template, and saves it as a new ODS named after cell B1:C2
of the resulting template (MODEL-FA without the prefixes).
"""

from __future__ import annotations

import argparse
import re
import sys
from pathlib import Path

import openpyxl
from odf import opendocument
from odf.table import Table, TableCell, TableRow
from odf.text import P

# ---------------------------------------------------------------------------
# Paths / globs
# ---------------------------------------------------------------------------
TEMPLATE_GLOB = "A4004_*.ods"
SOURCE_GLOB = "Production Order (mrp.production)*.xlsx"

# ---------------------------------------------------------------------------
# Mapping from source component category to template target rows.
# Template columns (0-based):
#   B = 1, C = 2, D = 3, E = 4
# ---------------------------------------------------------------------------
CATEGORY_TARGETS: dict[str, dict] = {
    "All / Components / Barebone": {
        "name_start": 10,
        "name_rows": 1,
        "serial_start": 10,
        "serial_rows": 1,
    },
    "All / Components / CPU": {
        "name_start": 11,
        "name_rows": 1,
        "serial_start": 11,
        "serial_rows": 1,
    },
    "All / Components / RAM": {
        "name_start": 12,
        "name_rows": 8,
        "serial_start": 12,
        "serial_rows": 8,
    },
    "All / Components / GPU": {
        "name_start": 20,
        "name_rows": 4,
        "serial_start": 20,
        "serial_rows": 4,
    },
    "All / Components / SSD": {
        "name_start": 24,
        "name_rows": 3,
        "serial_start": 24,
        "serial_rows": 4,
    },
    "All / Components / Network": {
        "name_start": 30,
        "name_rows": 1,
        "serial_start": 28,
        "serial_rows": 2,
    },
    "All / Components / RAID": {
        "name_start": 32,
        "name_rows": 1,
        "serial_start": 30,
        "serial_rows": 1,
    },
}

# Order in which categories are processed.  For SSD/RAID the source file may
# contain multiple distinct blocks for the same category; this order defines
# which block goes into which template area.
CATEGORY_ORDER = [
    "All / Components / Barebone",
    "All / Components / CPU",
    "All / Components / RAM",
    "All / Components / GPU",
    "All / Components / SSD",
    "All / Components / Network",
    "All / Components / RAID",
]

# Source column indices (0-based).
COL_CAT = 3   # D
COL_NAME = 4  # E
COL_SN = 6    # G

# ---------------------------------------------------------------------------
# ODS helpers
# ---------------------------------------------------------------------------
def _get_rows(table: Table) -> list[TableRow]:
    return list(table.getElementsByType(TableRow))


def _get_cells(row: TableRow) -> list[TableCell]:
    return list(row.getElementsByType(TableCell))


def _ensure_rows(table: Table, min_row_count: int) -> None:
    """Append empty rows until the table has at least ``min_row_count`` rows."""
    rows = _get_rows(table)
    if not rows:
        return
    first_row_cells = _get_cells(rows[0])
    while len(rows) < min_row_count:
        new_row = TableRow()
        for _ in first_row_cells:
            cell = TableCell()
            cell.addElement(P(text=""))
            new_row.addElement(cell)
        table.addElement(new_row)
        rows = _get_rows(table)


def _cell_style_name(cell: TableCell) -> str | None:
    return cell.getAttribute("stylename")


def _set_cell_value(table: Table, row: int, col: int, value: str) -> None:
    """Set the text value of a table cell, preserving style and spans."""
    _ensure_rows(table, row + 1)
    rows = _get_rows(table)
    target_row = rows[row]
    cells = _get_cells(target_row)

    if col >= len(cells):
        raise IndexError(
            f"Row {row + 1} only has {len(cells)} cells, cannot write to column {col + 1}"
        )

    old_cell = cells[col]
    style = _cell_style_name(old_cell)

    new_cell = TableCell()
    if style:
        new_cell.setAttribute("stylename", style)
    for attr in ("numbercolumnsspanned", "numberrowsspanned"):
        val = old_cell.getAttribute(attr)
        if val is not None:
            new_cell.setAttribute(attr, val)

    new_cell.addElement(P(text=str(value) if value is not None else ""))
    target_row.insertBefore(new_cell, old_cell)
    target_row.removeChild(old_cell)


def _find_template(directory: Path) -> Path:
    candidates = sorted(directory.glob(TEMPLATE_GLOB))
    if not candidates:
        raise FileNotFoundError(
            f"No ODS template matching '{TEMPLATE_GLOB}' found in {directory}"
        )
    return candidates[0]


# ---------------------------------------------------------------------------
# Source parsing
# ---------------------------------------------------------------------------
def _clean(value) -> str | None:
    if value is None:
        return None
    text = str(value).strip()
    return text if text else None


def _is_component_block_empty(ws, start_row: int) -> tuple[int, list[str]]:
    """Return how many extra rows with serial numbers belong to a component.

    Starting at ``start_row`` we walk downwards while the category (column D)
    and the component name (column E) are empty.  For every row that has a
    serial number in column G we increase the corresponding m* counter and
    collect the serial.  We stop as soon as a non-empty category or component
    name appears.
    """
    extra_serials: list[str] = []
    max_row = ws.max_row
    r = start_row + 1
    while r <= max_row:
        cat_val = _clean(ws.cell(row=r, column=COL_CAT + 1).value)
        name_val = _clean(ws.cell(row=r, column=COL_NAME + 1).value)
        if cat_val is not None or name_val is not None:
            break
        sn = _clean(ws.cell(row=r, column=COL_SN + 1).value)
        if sn:
            extra_serials.append(sn)
        r += 1
    return len(extra_serials), extra_serials


def _find_source_rows(ws) -> dict[str, list[tuple[int, str, list[str]]]]:
    """Find all component blocks in the source file.

    Returns a dict mapping source category -> list of blocks.  Each block is
    (row, name, serials_list).  A new block starts when a row contains a
    non-empty value in column E even if the category cell in column D is empty.
    """
    blocks: dict[str, list[tuple[int, str, list[str]]]] = {
        cat: [] for cat in CATEGORY_TARGETS
    }

    max_row = ws.max_row
    r = 1
    while r <= max_row:
        category = _clean(ws.cell(row=r, column=COL_CAT + 1).value)
        name = _clean(ws.cell(row=r, column=COL_NAME + 1).value)
        serial = _clean(ws.cell(row=r, column=COL_SN + 1).value)

        # A component block must either belong to a known category or start with
        # a component name directly below a known category (empty category cell
        # but non-empty name cell).
        if category not in CATEGORY_TARGETS:
            r += 1
            continue

        if name is None and serial is None:
            r += 1
            continue

        # Collect serials for this block.
        serials: list[str] = []
        if serial:
            serials.append(serial)
        _, extra_serials = _is_component_block_empty(ws, r)
        serials.extend(extra_serials)

        # Use the component name from this row; fall back to the name of the
        # first block of the same category if the source has split blocks.
        block_name = name if name else None

        blocks[category].append((r, block_name, serials))

        # Skip over the rows we already consumed.
        r += 1 + len(extra_serials)

    return blocks


def extract_source_data(source_path: Path) -> dict:
    """Read the source workbook and return extracted values."""
    wb = openpyxl.load_workbook(source_path, data_only=True)
    ws = wb.active

    fa: str | None = None
    model: str | None = None

    # B2 -> FA, C2 -> MODEL
    b2 = _clean(ws.cell(row=2, column=2).value)
    c2 = _clean(ws.cell(row=2, column=3).value)
    if isinstance(b2, str):
        m = re.search(r"FA-([A-Za-z0-9]+)", b2)
        if m:
            fa = m.group(1)
    if isinstance(c2, str):
        m = re.search(r"AIME-([A-Za-z0-9]+)", c2)
        if m:
            model = m.group(1)

    if fa is None:
        raise ValueError(f"Could not find 'FA-' serial number in {source_path}")
    if model is None:
        raise ValueError(f"Could not find 'AIME-' model in {source_path}")

    blocks = _find_source_rows(ws)
    wb.close()

    full_model = f"{model}-{fa}"

    counters = {
        "mGPU": 0,
        "mRAM": 0,
        "mSSD": 0,
        "mNIC": 0,
    }
    components: dict[str, dict] = {cat: {"name": None, "serials": []} for cat in CATEGORY_TARGETS}

    # Process each category according to the fixed template order.
    for cat in CATEGORY_ORDER:
        cat_blocks = blocks.get(cat, [])
        if not cat_blocks:
            continue

        # First block always fills the primary template area.
        _, name, serials = cat_blocks[0]
        components[cat]["name"] = name
        components[cat]["serials"] = serials

        # Counters count the *additional* serial rows beyond the first one.
        counter_key = {
            "All / Components / GPU": "mGPU",
            "All / Components / RAM": "mRAM",
            "All / Components / SSD": "mSSD",
            "All / Components / Network": "mNIC",
        }.get(cat)
        if counter_key:
            counters[counter_key] = max(0, len(serials) - 1)

        # Second block for SSD goes into the secondary NVME U.2 area (C27:E29).
        if cat == "All / Components / SSD" and len(cat_blocks) > 1:
            components["__SSD_SECOND__"] = {
                "name": cat_blocks[1][1],
                "serials": cat_blocks[1][2],
            }

    result = {
        "fa": fa,
        "model": model,
        "full_model": full_model,
        "components": components,
        **counters,
    }
    if "__SSD_SECOND__" in components:
        result["ssd_second"] = components.pop("__SSD_SECOND__")
    return result


# ---------------------------------------------------------------------------
# Template filling
# ---------------------------------------------------------------------------
def fill_template(source_path: Path, output_path: Path | None = None) -> Path:
    directory = source_path.parent
    data = extract_source_data(source_path)

    template_path = _find_template(directory)
    doc = opendocument.load(template_path)

    tables = list(doc.spreadsheet.getElementsByType(Table))
    if not tables:
        raise ValueError("No table found in the template ODS file")
    table = tables[0]

    print(f"Source: {source_path.name}")
    print(f"FA: {data['fa']}, MODEL: {data['model']}, Combined: {data['full_model']}")
    print(
        f"Counters -> mGPU={data['mGPU']}, mRAM={data['mRAM']}, "
        f"mSSD={data['mSSD']}, mNIC={data['mNIC']}"
    )

    # Write combined model-serial into B1 (merged B1:C2).
    _set_cell_value(table, 0, 1, data["full_model"])

    # Component names and serial numbers.
    serial_col = 4  # E
    name_col = 2    # C

    for category in CATEGORY_ORDER:
        comp = data["components"][category]
        targets = CATEGORY_TARGETS[category]
        name = comp.get("name")
        serials = comp.get("serials", [])

        if name:
            _set_cell_value(table, targets["name_start"] - 1, name_col, name)
            print(f"  {category} name -> C{targets['name_start']}: {name}")

        for i, sn in enumerate(serials[: targets["serial_rows"]]):
            target_row = targets["serial_start"] - 1 + i
            _set_cell_value(table, target_row, serial_col, sn)
            print(f"  {category} S/N {i + 1} -> E{target_row + 1}: {sn}")

        if len(serials) > targets["serial_rows"]:
            print(
                f"  Warning: {category} has {len(serials)} serials, "
                f"only {targets['serial_rows']} fit in the template",
                file=sys.stderr,
            )

    # Optional second SSD block into rows 27-29 (C27:D29 / E27:E29).
    ssd_second = data.get("ssd_second")
    if ssd_second:
        name = ssd_second.get("name")
        serials = ssd_second.get("serials", [])
        if name:
            _set_cell_value(table, 26, name_col, name)
            print(f"  Second SSD name -> C27: {name}")
        for i, sn in enumerate(serials[:3]):
            _set_cell_value(table, 26 + i, serial_col, sn)
            print(f"  Second SSD S/N {i + 1} -> E{27 + i}: {sn}")

    if output_path is None:
        output_path = directory / f"{data['full_model']}.ods"
    else:
        output_path = Path(output_path)

    doc.save(output_path)
    print(f"Saved: {output_path}")
    return output_path


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(
        description="Fill production order template from an Odoo export."
    )
    parser.add_argument(
        "source",
        nargs="?",
        help="Path to the source Excel file. If omitted, the alphabetically last "
        "source file matching the production order glob is used.",
    )
    parser.add_argument(
        "-o", "--output", help="Optional explicit output ODS file path"
    )
    args = parser.parse_args(argv)

    directory = Path(__file__).resolve().parent

    if args.source:
        source_path = Path(args.source).expanduser()
        if not source_path.is_absolute():
            source_path = directory / source_path
    else:
        sources = sorted(directory.glob(SOURCE_GLOB))
        if not sources:
            print(f"No source file matching '{SOURCE_GLOB}' found.", file=sys.stderr)
            return 1
        source_path = sources[-1]
        print(f"Using source file: {source_path.name}")

    if not source_path.exists():
        print(f"Source file not found: {source_path}", file=sys.stderr)
        return 1

    fill_template(source_path, args.output)
    return 0


if __name__ == "__main__":
    sys.exit(main())
