# -*- coding: utf-8 -*-
"""gui_api Project Doctor: check a project before it goes to a vendor or a tech pack, and fix it.

CLO has no single place that tells you a project is "ready": duplicate or meaningless pattern names,
pieces nobody sewed, fabrics without an item number, unused library entries that bloat the file, 8K
textures, default names. People find these by clicking through every piece. This dock panel does it in
one pass:

- Check: scans patterns, fabrics, graphics and colorways and lists every finding with a severity and a
  health score. Selecting a finding selects that pattern in CLO.
- Fix: one click for the safe fixes (unique names, remove unused fabrics / graphics).
- Rename: bulk rename with a preview, numbering the pieces left to right as laid out in the 2D window,
  find / replace, or prefix / suffix.
- Report: writes the findings as CSV and Markdown next to the project.
"""
import csv
import datetime
import json
import os
import re
import struct

import fabric_api
import gui_api
import pattern_api
import utility_api

if not gui_api.IsGuiAvailable():
    raise SystemExit("gui_api needs the CLO GUI (not available in headless mode)")

MAX_TEXTURE_PX = 4096        # larger textures bloat the project and the GPU memory, rarely visible
MAX_PATTERN_FACES = 20000    # a very dense piece slows simulation; usually a too small particle distance
SEVERITY_ORDER = {"Error": 0, "Warning": 1, "Info": 2}
SEVERITY_WEIGHT = {"Error": 10, "Warning": 4, "Info": 1}

gui_api.CloseAllWindows()  # a re-run must not leave the previous panel open

ui = {}
state = {"findings": [], "rename_preview": []}


# ------------------------------------------------------------------ small helpers
_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t", "\r")


def csv_cell(value):
    """Neutralise spreadsheet formula injection (CWE-1236): names come from the project file."""
    text = str(value)
    try:
        float(text)
        return text
    except ValueError:
        pass
    return "'" + text if text.startswith(_FORMULA_PREFIXES) else text


def image_size(path):
    """(width, height) of a PNG / JPEG without loading it, or None."""
    try:
        with open(path, "rb") as f:
            head = f.read(26)
            if head[:8] == b"\x89PNG\r\n\x1a\n":
                return struct.unpack(">II", head[16:24])
            if head[:2] != b"\xff\xd8":
                return None
            f.seek(2)
            while True:
                marker = f.read(2)
                if len(marker) < 2 or marker[0] != 0xFF:
                    return None
                if marker[1] in (0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF):
                    f.read(3)
                    h, w = struct.unpack(">HH", f.read(4))
                    return w, h
                length = struct.unpack(">H", f.read(2))[0]
                f.seek(length - 2, 1)
    except (OSError, struct.error):
        return None


def is_placeholder_name(name):
    """Names that say nothing about the piece: empty, digits only, or CLO's default 'Pattern'."""
    text = (name or "").strip()
    return text == "" or text.isdigit() or re.fullmatch(r"(?i)pattern[\s_]*\d*", text) is not None


def graphic_styles():
    """[(index, name)] of every graphic style, read from GetGraphicStyleListWithIndex."""
    try:
        return [(int(item["Index"]), item.get("Name", "")) for item in json.loads(utility_api.GetGraphicStyleListWithIndex())]
    except (ValueError, TypeError, KeyError):
        return [(g, utility_api.GetGraphicStyleName(g)) for g in range(utility_api.GetGraphicStyleCount())]


def pattern_bbox(index):
    try:
        info = json.loads(pattern_api.GetPatternLineInfo(index))
        return info.get("boundingBox", {})
    except (ValueError, TypeError):
        return {}


# ------------------------------------------------------------------ the checks
def finding(severity, category, target, message, fix=None, pattern=-1):
    return {"severity": severity, "category": category, "target": target, "message": message,
            "fix": fix, "pattern": pattern}


def check_patterns(out, names):
    seen = {}
    for i, name in enumerate(names):
        seen.setdefault(name.strip().lower(), []).append(i)
    for key, indices in seen.items():
        if len(indices) > 1 and key:
            for i in indices:
                out.append(finding("Error", "Pattern", names[i], "name used by %d pieces (BOM / tech pack rows merge)" % len(indices),
                                   fix="unique_name", pattern=i))
    for i, name in enumerate(names):
        if is_placeholder_name(name):
            out.append(finding("Warning", "Pattern", name or "(empty)", "name does not describe the piece (use Rename below)", pattern=i))
        if len(pattern_api.GetSeamlinePairGroupListInPattern(i)) == 0:
            out.append(finding("Warning", "Pattern", name, "not sewn to anything (falls off in simulation unless intended)", pattern=i))
        if (pattern_api.GetPatternPieceClassification(i) or "None") == "None":
            out.append(finding("Info", "Pattern", name, "no classification (tech pack groups it under 'None')", pattern=i))
        try:
            faces = int(dict(pattern_api.GetMeshCountByType(i)).get("Face Count", 0))
        except (TypeError, ValueError):
            faces = 0
        if faces > MAX_PATTERN_FACES:
            out.append(finding("Warning", "Pattern", name, "%d mesh faces: particle distance too small, simulation slows down" % faces, pattern=i))
        if pattern_api.GetPatternPieceFabricIndex(i) == 0 and fabric_api.GetFabricName(0).upper().startswith("FABRIC"):
            out.append(finding("Info", "Pattern", name, "still uses the default fabric '%s'" % fabric_api.GetFabricName(0), pattern=i))


def check_fabrics(out, pattern_count):
    used = {pattern_api.GetPatternPieceFabricIndex(i) for i in range(pattern_count)}
    for f in range(fabric_api.GetFabricCount()):
        name = fabric_api.GetFabricName(f) or "Fabric %d" % f
        if f not in used and f != 0:
            out.append(finding("Warning", "Fabric", name, "not used by any pattern (bloats the project file)", fix="delete_fabric"))
        item = (fabric_api.GetFabricItemNo(f) or "None").strip()
        if f in used and item in ("", "None"):
            out.append(finding("Info", "Fabric", name, "no item number (BOM cannot be matched to the supplier)"))
        if fabric_api.GetFabricWidth(f) <= 0 and f in used:
            out.append(finding("Error", "Fabric", name, "fabric width is 0 (consumption cannot be estimated)"))
        for label, getter in (("base color", fabric_api.GetBaseTextureMapImageFilePath), ("normal", fabric_api.GetNormalMapImageFilePath),
                              ("opacity", fabric_api.GetOpacityMapImageFilePath), ("roughness", fabric_api.GetRoughnessMapImageFilePath)):
            try:
                path = getter(f)
            except Exception:
                continue
            if not path or path == "NONE":
                continue
            size = image_size(path)
            if size and max(size) > MAX_TEXTURE_PX:
                out.append(finding("Warning", "Fabric", name, "%s map is %dx%d px (over %d: heavy file, no visible gain)" % (label, size[0], size[1], MAX_TEXTURE_PX)))


def check_graphics(out):
    for g, name in graphic_styles():
        if not list(utility_api.GetGraphicStylePatternPieceIndices(g)):
            out.append(finding("Info", "Graphic", name or "Graphic %d" % g,
                               "in the library but not placed on any pattern", fix="remove_graphic"))


def check_project(out):
    for c in range(utility_api.GetColorwayCount()):
        name = utility_api.GetColorwayName(c)
        if re.fullmatch(r"(?i)(default colorway|colorway\s*\d*)", name or ""):
            out.append(finding("Info", "Colorway", name, "default colorway name (shows up in renders and tech packs)"))
    if (utility_api.GetProjectName() or "").lower() in ("", "untitled"):
        out.append(finding("Info", "Project", "(untitled)", "project was never saved"))


def run_checks():
    count = pattern_api.GetPatternCount()
    names = [pattern_api.GetPatternPieceName(i) or "" for i in range(count)]
    out = []
    check_patterns(out, names)
    check_fabrics(out, count)
    check_graphics(out)
    check_project(out)
    out.sort(key=lambda f: (SEVERITY_ORDER[f["severity"]], f["category"], f["target"]))
    state["findings"] = out
    score = max(0, 100 - sum(SEVERITY_WEIGHT[f["severity"]] for f in out))
    counts = {s: sum(1 for f in out if f["severity"] == s) for s in SEVERITY_ORDER}
    gui_api.SetTableRows(ui["findings"], [[f["severity"], f["category"], f["target"], "yes" if f["fix"] else "", f["message"]] for f in out])
    gui_api.SetWidgetValue(ui["score"], str(score))
    gui_api.SetWidgetText(ui["summary"], "%s  |  health %d / 100  |  %d error(s), %d warning(s), %d info  |  %d patterns, %d fabrics"
                          % (utility_api.GetProjectName() or "(untitled)", score, counts["Error"], counts["Warning"], counts["Info"],
                             count, fabric_api.GetFabricCount()))
    gui_api.SetWidgetText(ui["detail"], "select a finding to see it in CLO" if out else "no findings: ready to hand off")
    if "rename_table" in ui:
        preview_rename()


# ------------------------------------------------------------------ fixes
def unique_names():
    count = pattern_api.GetPatternCount()
    taken = {}
    renamed = 0
    for i in range(count):
        name = pattern_api.GetPatternPieceName(i) or "Pattern"
        key = name.strip().lower()
        if key in taken:
            n = taken[key] + 1
            while ("%s_%d" % (name, n)).lower() in taken:
                n += 1
            taken[key] = n
            new = "%s_%d" % (name, n)
            pattern_api.SetPatternPieceName(i, new)
            taken[new.lower()] = 1
            renamed += 1
        else:
            taken[key] = 1
    return "%d piece(s) renamed" % renamed


def delete_unused_fabrics():
    used = {pattern_api.GetPatternPieceFabricIndex(i) for i in range(pattern_api.GetPatternCount())}
    unused = [f for f in range(fabric_api.GetFabricCount()) if f not in used and f != 0]
    for f in sorted(unused, reverse=True):  # indices shift after a delete
        fabric_api.DeleteFabric(f)
    return "%d unused fabric(s) removed" % len(unused)


def remove_unused_graphics():
    unused = [g for g, _ in graphic_styles() if not list(utility_api.GetGraphicStylePatternPieceIndices(g))]
    removed, refused = 0, []
    for g in sorted(unused, reverse=True):
        try:
            reply = json.loads(utility_api.RemoveGraphicStyle(g) or "{}")
        except ValueError:
            reply = {}
        if str(reply.get("status", "")).lower() == "success":
            removed += 1
        else:
            refused.append(reply.get("message", "no reply"))
    text = "%d unused graphic(s) removed" % removed
    if refused:
        text += ", %d refused by CLO (%s): remove them in the Object Browser" % (len(refused), refused[0])
    return text


FIXES = {"unique_name": ("make pattern names unique", unique_names),
         "delete_fabric": ("remove unused fabrics", delete_unused_fabrics),
         "remove_graphic": ("remove unused graphics", remove_unused_graphics)}


def fix_selected():
    row = gui_api.GetWidgetSelectedIndex(ui["findings"])
    if row < 0 or row >= len(state["findings"]):
        gui_api.SetWidgetText(ui["detail"], "select a finding first")
        return
    kind = state["findings"][row]["fix"]
    if not kind:
        gui_api.SetWidgetText(ui["detail"], "this one needs a decision: no automatic fix")
        return
    label, fn = FIXES[kind]
    if kind != "unique_name" and not gui_api.AskConfirm("Project Doctor", "%s?" % label.capitalize(), "Fix", "Cancel"):
        return
    gui_api.SetWidgetText(ui["detail"], fn())
    run_checks()


def fix_all():
    kinds = sorted({f["fix"] for f in state["findings"] if f["fix"]})
    if not kinds:
        gui_api.SetWidgetText(ui["detail"], "nothing to fix automatically")
        return
    if not gui_api.AskConfirm("Project Doctor", "Apply these fixes?\n- " + "\n- ".join(FIXES[k][0] for k in kinds), "Fix all", "Cancel"):
        return
    results = [FIXES[k][1]() for k in kinds]
    run_checks()
    gui_api.SetWidgetText(ui["detail"], "; ".join(results))


def on_finding_selected(row):
    if row < 0 or row >= len(state["findings"]):
        return
    f = state["findings"][row]
    if f["pattern"] >= 0:
        pattern_api.SelectPatternViaIndex(f["pattern"], False)
    hint = " (fix available)" if f["fix"] else ""
    gui_api.SetWidgetText(ui["detail"], "%s %s '%s': %s%s" % (f["severity"], f["category"].lower(), f["target"], f["message"], hint))


# ------------------------------------------------------------------ rename
RENAME_MODES = ["Prefix + number, left to right in 2D", "Find / replace", "Add prefix / suffix"]


def rename_plan():
    count = pattern_api.GetPatternCount()
    names = [pattern_api.GetPatternPieceName(i) or "" for i in range(count)]
    mode = gui_api.GetWidgetSelectedIndex(ui["rename_mode"])
    a = gui_api.GetWidgetValue(ui["rename_a"])
    b = gui_api.GetWidgetValue(ui["rename_b"])
    plan = []
    if mode == 0:
        prefix = a or "Piece"
        start = int(b) if b.strip().isdigit() else 1
        order = sorted(range(count), key=lambda i: (round(float(pattern_bbox(i).get("minX", 0)) / 10.0), -float(pattern_bbox(i).get("maxY", 0))))
        for n, i in enumerate(order):
            plan.append((i, names[i], "%s_%02d" % (prefix, start + n)))
    elif mode == 1:
        if a:
            plan = [(i, n, n.replace(a, b)) for i, n in enumerate(names) if a in n]
    else:
        plan = [(i, n, "%s%s%s" % (a, n, b)) for i, n in enumerate(names)]
    return [p for p in plan if p[1] != p[2]]


def preview_rename(*_):
    state["rename_preview"] = rename_plan()
    gui_api.SetTableRows(ui["rename_table"], [[old, new] for _, old, new in state["rename_preview"]])
    gui_api.SetWidgetText(ui["rename_status"], "%d piece(s) would change" % len(state["rename_preview"]))


def apply_rename():
    plan = rename_plan()
    if not plan:
        gui_api.SetWidgetText(ui["rename_status"], "nothing to rename")
        return
    if not gui_api.AskConfirm("Project Doctor", "Rename %d pattern piece(s)?" % len(plan), "Rename", "Cancel"):
        return
    for i, _, new in plan:
        pattern_api.SetPatternPieceName(i, new)
    gui_api.SetWidgetText(ui["rename_status"], "%d piece(s) renamed" % len(plan))
    preview_rename()
    run_checks()


def on_rename_mode(index):
    hints = [("prefix, e.g. Front", "first number"), ("find", "replace with"), ("prefix", "suffix")]
    gui_api.SetWidgetText(ui["rename_hint"], "A = %s, B = %s" % hints[index])
    preview_rename()


# ------------------------------------------------------------------ report
def export_report():
    if not state["findings"]:
        run_checks()
    project_path = utility_api.GetProjectFilePath() or ""
    folder = os.path.dirname(project_path) if os.path.isdir(os.path.dirname(project_path)) and "Preset" not in project_path \
        else os.path.join(os.path.expanduser("~"), "Documents", "CLO", "ProjectDoctor")
    os.makedirs(folder, exist_ok=True)
    base = os.path.join(folder, "%s_doctor" % (utility_api.GetProjectName() or "untitled"))
    with open(base + ".csv", "w", newline="", encoding="utf-8-sig") as f:
        writer = csv.writer(f)
        writer.writerow(["Severity", "Category", "Target", "Finding"])
        for item in state["findings"]:
            writer.writerow([csv_cell(item[k]) for k in ("severity", "category", "target", "message")])
    with open(base + ".md", "w", encoding="utf-8") as f:
        f.write("# Project Doctor: %s\n\n%s\n\n" % (utility_api.GetProjectName(), datetime.datetime.now().strftime("%Y-%m-%d %H:%M")))
        f.write("Health %s / 100\n\n| Severity | Category | Target | Finding |\n|---|---|---|---|\n" % gui_api.GetWidgetValue(ui["score"]))
        for item in state["findings"]:
            f.write("| %s | %s | %s | %s |\n" % tuple(str(item[k]).replace("|", "/") for k in ("severity", "category", "target", "message")))
    gui_api.SetWidgetText(ui["detail"], "report written: %s.csv / .md" % base)


# ------------------------------------------------------------------ layout
panel = gui_api.CreateDockPanel("Project Doctor", "left", "project_doctor_left")

health = gui_api.AddGroup(panel, "Health check")
ui["summary"] = gui_api.AddLabel(health, "press Check")
ui["score"] = gui_api.AddProgressBar(health, 0)
row = gui_api.AddRow(health)
gui_api.AddButton(row, "Check", run_checks)
gui_api.AddButton(row, "Fix selected", fix_selected)
gui_api.AddButton(row, "Fix all safe", fix_all)
gui_api.AddButton(row, "Export report", export_report)
ui["findings"] = gui_api.AddTable(health, ["Severity", "Category", "Target", "Auto fix", "Finding"], [], on_finding_selected)
ui["detail"] = gui_api.AddLabel(health, "")

rename = gui_api.AddGroup(panel, "Rename pattern pieces")
ui["rename_mode"] = gui_api.AddDropdown(rename, RENAME_MODES, 0, on_rename_mode)
ui["rename_hint"] = gui_api.AddLabel(rename, "A = prefix, e.g. Front, B = first number")
inputs = gui_api.AddRow(rename)
ui["rename_a"] = gui_api.AddTextInput(inputs, "Piece", "A", preview_rename)
ui["rename_b"] = gui_api.AddTextInput(inputs, "1", "B", preview_rename)
ui["rename_table"] = gui_api.AddTable(rename, ["Current", "New"], [])
actions = gui_api.AddRow(rename)
gui_api.AddButton(actions, "Preview", preview_rename)
gui_api.AddButton(actions, "Apply", apply_rename)
ui["rename_status"] = gui_api.AddLabel(rename, "")

gui_api.ShowWindow(panel)
run_checks()
# The script returns here. The panel stays docked and all buttons keep working.
