"""Score a C-shaped mounting-bracket STEP model with minimal partial credit."""

from __future__ import annotations

import argparse
import itertools
import json
import tempfile
from pathlib import Path
from typing import Any

import numpy as np
import score_submission as strict_scorer
from OCP.Bnd import Bnd_Box, Bnd_OBB
from OCP.BRepBndLib import BRepBndLib

# Strict geometric congruence is the only passing result.  These diagnostic
# milestones distinguish how close an incorrect model was, but never let an
# incorrect answer receive more than 3% credit.
VALID_SOLID_SCORE = 0.005
GLOBAL_DIMENSIONS_SCORE = 0.010
GLOBAL_INVARIANTS_SCORE = 0.015
MASS_PROPERTIES_SCORE = 0.020
COARSE_OVERLAP_SCORE = 0.024
STRONG_OVERLAP_SCORE = 0.027
NEAR_CONGRUENT_SCORE = 0.030
EXACT_MATCH_SCORE = 1.0

GLOBAL_DIMENSIONS_RELATIVE_TOLERANCE = 0.05
INVARIANT_DIMENSIONS_RELATIVE_TOLERANCE = 0.02
INVARIANT_VOLUME_RELATIVE_TOLERANCE = 0.05
INVARIANT_AREA_RELATIVE_TOLERANCE = 0.05
INVARIANT_MOMENTS_RELATIVE_TOLERANCE = 0.10
MASS_PROPERTIES_DIMENSIONS_RELATIVE_TOLERANCE = 0.01
MASS_PROPERTIES_VOLUME_RELATIVE_TOLERANCE = 0.02
MASS_PROPERTIES_AREA_RELATIVE_TOLERANCE = 0.02
MASS_PROPERTIES_MOMENTS_RELATIVE_TOLERANCE = 0.05
MASS_PROPERTIES_CENTRE_TOLERANCE_MM = 0.50
COARSE_OVERLAP_CENTRE_TOLERANCE_MM = 2.0
COARSE_OVERLAP_RESIDUAL_RELATIVE_TOLERANCE = 0.10
STRONG_OVERLAP_CENTRE_TOLERANCE_MM = 0.50
STRONG_OVERLAP_RESIDUAL_RELATIVE_TOLERANCE = 0.03
NEAR_CENTRE_TOLERANCE_MM = 0.10
NEAR_DIMENSIONS_RELATIVE_TOLERANCE = 0.005
NEAR_VOLUME_RELATIVE_TOLERANCE = 0.01
NEAR_AREA_RELATIVE_TOLERANCE = 0.01
NEAR_MOMENTS_RELATIVE_TOLERANCE = 0.03
NEAR_RESIDUAL_RELATIVE_TOLERANCE = 0.005
OBB_DIMENSION_MAPPING_MARGIN = 0.01
INERTIA_MOMENT_MAPPING_MARGIN = 0.01
MAX_BOOLEAN_ALIGNMENT_CANDIDATES_PER_FRAME = 4


def maximum_relative_error(left: np.ndarray, right: np.ndarray) -> float:
    scale = np.maximum(np.maximum(np.abs(left), np.abs(right)), 1.0e-12)
    return float(np.max(np.abs(left - right) / scale))


def oriented_frame(comparator: Any, path: Path) -> dict[str, Any]:
    """Return an optimal OBB frame independent of the model's input pose."""

    shape = comparator.read_step(path)
    bounds = Bnd_OBB()
    BRepBndLib.AddOBB_s(shape, bounds, True, True, False)
    if bounds.IsVoid():
        raise RuntimeError(f"OpenCASCADE produced an empty oriented bounding box: {path}")
    dimensions = np.asarray(
        [2.0 * bounds.XHSize(), 2.0 * bounds.YHSize(), 2.0 * bounds.ZHSize()],
        dtype=float,
    )
    if not np.all(np.isfinite(dimensions)) or np.any(dimensions <= 0.0):
        raise RuntimeError(f"OpenCASCADE produced invalid oriented dimensions: {path}")
    position = bounds.Position()
    centre = bounds.Center()
    axes = np.column_stack(
        [
            np.asarray(position.XDirection().Coord(), dtype=float),
            np.asarray(position.YDirection().Coord(), dtype=float),
            np.asarray(position.Direction().Coord(), dtype=float),
        ]
    )
    return {
        "shape": shape,
        "centre": np.asarray(centre.Coord(), dtype=float),
        "axes": axes,
        "dimensions": dimensions,
    }


def axis_aligned_centre(shape: Any) -> np.ndarray:
    bounds = Bnd_Box()
    bounds.SetGap(0.0)
    BRepBndLib.AddOptimal_s(shape, bounds, True, False)
    if bounds.IsVoid():
        raise RuntimeError("OpenCASCADE produced an empty axis-aligned bounding box")
    if hasattr(bounds, "GetXMin"):
        values = np.asarray(
            [
                bounds.GetXMin(),
                bounds.GetYMin(),
                bounds.GetZMin(),
                bounds.GetXMax(),
                bounds.GetYMax(),
                bounds.GetZMax(),
            ],
            dtype=float,
        )
    else:
        values = np.asarray(bounds.Get(), dtype=float)
    return (values[:3] + values[3:]) / 2.0


def obb_alignment_candidates(
    reference_frame: dict[str, Any],
    candidate_frame: dict[str, Any],
    reference_centre_of_mass: np.ndarray,
    candidate_centre_of_mass: np.ndarray,
) -> list[dict[str, Any]]:
    """Generate proper rotations by mapping the candidate OBB axes to the reference."""

    candidates: list[dict[str, Any]] = []
    for permutation in itertools.permutations(range(3)):
        mapped_dimensions = candidate_frame["dimensions"][list(permutation)]
        dimension_error = maximum_relative_error(reference_frame["dimensions"], mapped_dimensions)

        for signs in itertools.product((-1.0, 1.0), repeat=3):
            local_mapping = np.zeros((3, 3), dtype=float)
            for reference_axis, candidate_axis in enumerate(permutation):
                local_mapping[reference_axis, candidate_axis] = signs[reference_axis]
            rotation = reference_frame["axes"] @ local_mapping @ candidate_frame["axes"].T
            if np.linalg.det(rotation) < 0.999999999:
                continue
            translation = reference_frame["centre"] - rotation @ candidate_frame["centre"]
            aligned_centre_of_mass = rotation @ candidate_centre_of_mass + translation
            candidates.append(
                {
                    "rotation": rotation,
                    "translation": translation,
                    "source": "oriented bounding-box axes",
                    "dimension_mapping_relative_error": dimension_error,
                    "centre_of_mass_offset_mm": float(
                        np.linalg.norm(reference_centre_of_mass - aligned_centre_of_mass)
                    ),
                }
            )

    minimum_dimension_error = min(
        candidate["dimension_mapping_relative_error"] for candidate in candidates
    )
    dimension_compatible = [
        candidate
        for candidate in candidates
        if candidate["dimension_mapping_relative_error"]
        <= minimum_dimension_error + OBB_DIMENSION_MAPPING_MARGIN
    ]
    dimension_compatible.sort(key=lambda candidate: candidate["centre_of_mass_offset_mm"])
    return dimension_compatible[:MAX_BOOLEAN_ALIGNMENT_CANDIDATES_PER_FRAME]


def inertia_alignment_candidates(
    reference_frame: dict[str, Any],
    candidate_frame: dict[str, Any],
    report: dict[str, Any],
) -> list[dict[str, Any]]:
    """Generate proper rotations from volume principal axes without centring on mass."""

    reference_axes = np.asarray(report["a"]["principal_axes"], dtype=float)
    candidate_axes = np.asarray(report["b"]["principal_axes"], dtype=float)
    reference_moments = np.asarray(report["a"]["principal_moments_mm5"], dtype=float)
    candidate_moments = np.asarray(report["b"]["principal_moments_mm5"], dtype=float)
    reference_centre_of_mass = np.asarray(report["a"]["centre_of_mass_mm"], dtype=float)
    candidate_centre_of_mass = np.asarray(report["b"]["centre_of_mass_mm"], dtype=float)

    candidates: list[dict[str, Any]] = []
    for permutation in itertools.permutations(range(3)):
        mapped_moments = candidate_moments[list(permutation)]
        moment_error = maximum_relative_error(reference_moments, mapped_moments)

        for signs in itertools.product((-1.0, 1.0), repeat=3):
            local_mapping = np.zeros((3, 3), dtype=float)
            for reference_axis, candidate_axis in enumerate(permutation):
                local_mapping[reference_axis, candidate_axis] = signs[reference_axis]
            rotation = reference_axes @ local_mapping @ candidate_axes.T
            if np.linalg.det(rotation) < 0.999999999:
                continue
            candidates.append(
                {
                    "rotation": rotation,
                    "source": "volume principal inertia axes",
                    "moment_mapping_relative_error": moment_error,
                    "reference_centre_of_mass": reference_centre_of_mass,
                    "candidate_centre_of_mass": candidate_centre_of_mass,
                }
            )

    minimum_moment_error = min(
        candidate["moment_mapping_relative_error"] for candidate in candidates
    )
    moment_compatible = [
        candidate
        for candidate in candidates
        if candidate["moment_mapping_relative_error"]
        <= minimum_moment_error + INERTIA_MOMENT_MAPPING_MARGIN
    ]
    return moment_compatible[:MAX_BOOLEAN_ALIGNMENT_CANDIDATES_PER_FRAME]


def best_partial_alignment(
    comparator: Any,
    reference_frame: dict[str, Any],
    candidate_frame: dict[str, Any],
    report: dict[str, Any],
) -> dict[str, Any] | None:
    """Find the coarse rigid pose with the smallest bidirectional Boolean residual."""

    reference_volume = float(report["a"]["volume_mm3"])
    candidate_volume = float(report["b"]["volume_mm3"])
    candidates = obb_alignment_candidates(
        reference_frame,
        candidate_frame,
        np.asarray(report["a"]["centre_of_mass_mm"], dtype=float),
        np.asarray(report["b"]["centre_of_mass_mm"], dtype=float),
    )
    candidates.extend(inertia_alignment_candidates(reference_frame, candidate_frame, report))
    candidates.append(
        {
            "rotation": np.eye(3, dtype=float),
            "source": "unchanged input axes",
            "reference_centre_of_mass": np.asarray(report["a"]["centre_of_mass_mm"], dtype=float),
            "candidate_centre_of_mass": np.asarray(report["b"]["centre_of_mass_mm"], dtype=float),
        }
    )
    reference_axis_aligned_centre = axis_aligned_centre(reference_frame["shape"])
    for candidate in candidates:
        if candidate["source"] != "oriented bounding-box axes":
            rotated_shape = comparator.apply_rigid_transform(
                candidate_frame["shape"],
                candidate["rotation"],
                np.zeros(3, dtype=float),
            )
            candidate["translation"] = reference_axis_aligned_centre - axis_aligned_centre(
                rotated_shape
            )
            aligned_centre_of_mass = (
                candidate["rotation"] @ candidate["candidate_centre_of_mass"]
                + candidate["translation"]
            )
            candidate["centre_of_mass_offset_mm"] = float(
                np.linalg.norm(candidate["reference_centre_of_mass"] - aligned_centre_of_mass)
            )

    candidates.sort(key=lambda candidate: candidate["centre_of_mass_offset_mm"])
    successful: list[dict[str, Any]] = []
    attempted_count = 0
    for candidate in candidates:
        attempted_count += 1
        try:
            aligned_shape = comparator.apply_rigid_transform(
                candidate_frame["shape"],
                candidate["rotation"],
                candidate["translation"],
            )
            common_shape = comparator.intersection(
                reference_frame["shape"],
                aligned_shape,
                report["tolerances"]["boolean_fuzzy_mm"],
                "partial-credit OBB alignment",
            )
            common_volume = comparator.volume(common_shape)
        except RuntimeError:
            continue

        reference_minus_candidate = max(0.0, reference_volume - common_volume)
        candidate_minus_reference = max(0.0, candidate_volume - common_volume)
        successful.append(
            {
                **candidate,
                "intersection_volume_mm3": common_volume,
                "reference_minus_candidate_volume_mm3": reference_minus_candidate,
                "candidate_minus_reference_volume_mm3": candidate_minus_reference,
                "reference_minus_candidate_relative": reference_minus_candidate / reference_volume,
                "candidate_minus_reference_relative": candidate_minus_reference / candidate_volume,
                "symmetric_difference_relative": (
                    reference_minus_candidate + candidate_minus_reference
                )
                / max(reference_volume, candidate_volume),
            }
        )
        if (
            max(
                successful[-1]["reference_minus_candidate_relative"],
                successful[-1]["candidate_minus_reference_relative"],
            )
            <= NEAR_RESIDUAL_RELATIVE_TOLERANCE
            and successful[-1]["centre_of_mass_offset_mm"] <= NEAR_CENTRE_TOLERANCE_MM
        ):
            break

    if not successful:
        return None
    successful.sort(
        key=lambda candidate: (
            max(
                candidate["reference_minus_candidate_relative"],
                candidate["candidate_minus_reference_relative"],
            ),
            candidate["symmetric_difference_relative"],
            candidate["centre_of_mass_offset_mm"],
        )
    )
    best = successful[0]
    return {
        "alignment_source": best["source"],
        "centre_of_mass_offset_mm": best["centre_of_mass_offset_mm"],
        "intersection_volume_mm3": best["intersection_volume_mm3"],
        "reference_minus_candidate_volume_mm3": best["reference_minus_candidate_volume_mm3"],
        "candidate_minus_reference_volume_mm3": best["candidate_minus_reference_volume_mm3"],
        "reference_minus_candidate_relative": best["reference_minus_candidate_relative"],
        "candidate_minus_reference_relative": best["candidate_minus_reference_relative"],
        "maximum_directional_residual_relative": max(
            best["reference_minus_candidate_relative"],
            best["candidate_minus_reference_relative"],
        ),
        "symmetric_difference_relative": best["symmetric_difference_relative"],
        "alignment_candidate_count": len(candidates),
        "boolean_evaluated_candidate_count": attempted_count,
        "successful_boolean_candidate_count": len(successful),
    }


def score(
    submission: Path,
    reference: Path,
    report_path: Path,
) -> dict[str, Any]:
    try:
        with tempfile.TemporaryDirectory(prefix="cshaped-mounting-bracket-partial-verify-") as temp_dir:
            snapshot = Path(temp_dir) / "submission.step"
            file_info = strict_scorer.snapshot_submission(submission, snapshot)
            comparator = strict_scorer.load_geometry_compare()
            report = comparator.compare(reference, snapshot)
            strict_scorer.write_diagnostic_report(report_path, report)

            if report["status"] == "SAME":
                return {
                    "score": EXACT_MATCH_SCORE,
                    "passed": True,
                    "status": "EXACT_MATCH",
                    "tier": "strict_geometric_congruence",
                    "file": file_info,
                    "message": "all strict geometry checks passed",
                }

            candidate = report["b"]
            valid_solid = bool(
                report["checks"]["b_is_valid_solid"]
                and candidate["maximum_topological_tolerance_mm"]
                <= report["tolerances"]["length_mm"]
            )
            awarded_score = 0.0
            tier = "no_credit"
            partial_metrics: dict[str, Any] = {}

            if valid_solid:
                awarded_score = VALID_SOLID_SCORE
                tier = "valid_self_contained_single_solid"

                reference_frame = oriented_frame(comparator, reference)
                candidate_frame = oriented_frame(comparator, snapshot)
                reference_dimensions = np.sort(reference_frame["dimensions"])
                candidate_dimensions = np.sort(candidate_frame["dimensions"])
                dimension_error = maximum_relative_error(reference_dimensions, candidate_dimensions)
                volume_error = maximum_relative_error(
                    np.asarray([report["a"]["volume_mm3"]]),
                    np.asarray([candidate["volume_mm3"]]),
                )
                area_error = maximum_relative_error(
                    np.asarray([report["a"]["surface_area_mm2"]]),
                    np.asarray([candidate["surface_area_mm2"]]),
                )
                moment_error = maximum_relative_error(
                    np.asarray(report["a"]["principal_moments_mm5"]),
                    np.asarray(candidate["principal_moments_mm5"]),
                )
                partial_metrics = {
                    "reference_oriented_dimensions_mm": reference_dimensions.tolist(),
                    "candidate_oriented_dimensions_mm": candidate_dimensions.tolist(),
                    "maximum_dimension_relative_error": dimension_error,
                    "volume_relative_error": volume_error,
                    "surface_area_relative_error": area_error,
                    "principal_moments_relative_error": moment_error,
                    "shell_counts_match": report["a"]["shell_count"] == candidate["shell_count"],
                }

                if dimension_error <= GLOBAL_DIMENSIONS_RELATIVE_TOLERANCE:
                    awarded_score = GLOBAL_DIMENSIONS_SCORE
                    tier = "global_dimensions_close"

                global_invariants_close = bool(
                    dimension_error <= INVARIANT_DIMENSIONS_RELATIVE_TOLERANCE
                    and volume_error <= INVARIANT_VOLUME_RELATIVE_TOLERANCE
                    and area_error <= INVARIANT_AREA_RELATIVE_TOLERANCE
                    and moment_error <= INVARIANT_MOMENTS_RELATIVE_TOLERANCE
                    and partial_metrics["shell_counts_match"]
                )
                if global_invariants_close:
                    awarded_score = GLOBAL_INVARIANTS_SCORE
                    tier = "global_dimensions_and_invariants_close"

                if (
                    dimension_error <= GLOBAL_DIMENSIONS_RELATIVE_TOLERANCE
                    and volume_error <= COARSE_OVERLAP_RESIDUAL_RELATIVE_TOLERANCE
                ):
                    alignment = best_partial_alignment(
                        comparator,
                        reference_frame,
                        candidate_frame,
                        report,
                    )
                    partial_metrics["coarse_alignment"] = alignment
                    if alignment is not None:
                        centre_offset = alignment["centre_of_mass_offset_mm"]
                        maximum_residual = alignment["maximum_directional_residual_relative"]

                        if (
                            dimension_error <= MASS_PROPERTIES_DIMENSIONS_RELATIVE_TOLERANCE
                            and volume_error <= MASS_PROPERTIES_VOLUME_RELATIVE_TOLERANCE
                            and area_error <= MASS_PROPERTIES_AREA_RELATIVE_TOLERANCE
                            and moment_error <= MASS_PROPERTIES_MOMENTS_RELATIVE_TOLERANCE
                            and centre_offset <= MASS_PROPERTIES_CENTRE_TOLERANCE_MM
                        ):
                            awarded_score = MASS_PROPERTIES_SCORE
                            tier = "mass_properties_and_centre_close"

                        if (
                            centre_offset <= COARSE_OVERLAP_CENTRE_TOLERANCE_MM
                            and maximum_residual <= COARSE_OVERLAP_RESIDUAL_RELATIVE_TOLERANCE
                        ):
                            awarded_score = COARSE_OVERLAP_SCORE
                            tier = "coarse_bidirectional_overlap"

                        if (
                            global_invariants_close
                            and centre_offset <= STRONG_OVERLAP_CENTRE_TOLERANCE_MM
                            and maximum_residual <= STRONG_OVERLAP_RESIDUAL_RELATIVE_TOLERANCE
                        ):
                            awarded_score = STRONG_OVERLAP_SCORE
                            tier = "strong_bidirectional_overlap"

                        if (
                            dimension_error <= NEAR_DIMENSIONS_RELATIVE_TOLERANCE
                            and volume_error <= NEAR_VOLUME_RELATIVE_TOLERANCE
                            and area_error <= NEAR_AREA_RELATIVE_TOLERANCE
                            and moment_error <= NEAR_MOMENTS_RELATIVE_TOLERANCE
                            and partial_metrics["shell_counts_match"]
                            and centre_offset <= NEAR_CENTRE_TOLERANCE_MM
                            and maximum_residual <= NEAR_RESIDUAL_RELATIVE_TOLERANCE
                        ):
                            awarded_score = NEAR_CONGRUENT_SCORE
                            tier = "near_congruent_occupied_geometry"

    except Exception as error:  # noqa: BLE001 - malformed submissions must score zero
        return {
            "score": 0.0,
            "passed": False,
            "status": "ERROR",
            "tier": "no_credit",
            "error": str(error),
        }

    return {
        "score": awarded_score,
        "passed": False,
        "status": "PARTIAL" if awarded_score > 0.0 else "NO_CREDIT",
        "tier": tier,
        "file": file_info,
        "partial_metrics": partial_metrics,
        "partial_tiers": {
            "valid_self_contained_single_solid": VALID_SOLID_SCORE,
            "global_dimensions_within_5_percent": GLOBAL_DIMENSIONS_SCORE,
            "dimensions_and_global_invariants_close": GLOBAL_INVARIANTS_SCORE,
            "mass_properties_and_centre_close": MASS_PROPERTIES_SCORE,
            "coarse_bidirectional_overlap": COARSE_OVERLAP_SCORE,
            "strong_bidirectional_overlap": STRONG_OVERLAP_SCORE,
            "near_congruent_occupied_geometry": NEAR_CONGRUENT_SCORE,
            "strict_geometric_congruence": EXACT_MATCH_SCORE,
        },
        "invariant_tolerances": {
            "oriented_dimensions_relative": INVARIANT_DIMENSIONS_RELATIVE_TOLERANCE,
            "volume_relative": INVARIANT_VOLUME_RELATIVE_TOLERANCE,
            "surface_area_relative": INVARIANT_AREA_RELATIVE_TOLERANCE,
            "principal_moments_relative": INVARIANT_MOMENTS_RELATIVE_TOLERANCE,
        },
        "alignment_tolerances": {
            "mass_properties_dimensions_relative": (MASS_PROPERTIES_DIMENSIONS_RELATIVE_TOLERANCE),
            "mass_properties_volume_relative": MASS_PROPERTIES_VOLUME_RELATIVE_TOLERANCE,
            "mass_properties_surface_area_relative": MASS_PROPERTIES_AREA_RELATIVE_TOLERANCE,
            "mass_properties_principal_moments_relative": (
                MASS_PROPERTIES_MOMENTS_RELATIVE_TOLERANCE
            ),
            "mass_properties_centre_mm": MASS_PROPERTIES_CENTRE_TOLERANCE_MM,
            "coarse_overlap_centre_mm": COARSE_OVERLAP_CENTRE_TOLERANCE_MM,
            "coarse_overlap_maximum_directional_residual_relative": (
                COARSE_OVERLAP_RESIDUAL_RELATIVE_TOLERANCE
            ),
            "strong_overlap_centre_mm": STRONG_OVERLAP_CENTRE_TOLERANCE_MM,
            "strong_overlap_maximum_directional_residual_relative": (
                STRONG_OVERLAP_RESIDUAL_RELATIVE_TOLERANCE
            ),
            "near_centre_mm": NEAR_CENTRE_TOLERANCE_MM,
            "near_dimensions_relative": NEAR_DIMENSIONS_RELATIVE_TOLERANCE,
            "near_volume_relative": NEAR_VOLUME_RELATIVE_TOLERANCE,
            "near_surface_area_relative": NEAR_AREA_RELATIVE_TOLERANCE,
            "near_principal_moments_relative": NEAR_MOMENTS_RELATIVE_TOLERANCE,
            "near_maximum_directional_residual_relative": (NEAR_RESIDUAL_RELATIVE_TOLERANCE),
        },
        "message": (
            "partial credit awarded; strict geometric congruence did not pass"
            if awarded_score > 0.0
            else "submission did not reach the valid-single-solid milestone"
        ),
    }


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--submission", type=Path, required=True)
    parser.add_argument("--reference", type=Path, required=True)
    parser.add_argument("--report", type=Path, required=True)
    parser.add_argument("--summary", type=Path, required=True)
    args = parser.parse_args()
    result = score(args.submission, args.reference, args.report)
    strict_scorer.write_diagnostic_report(args.summary, result)
    print(json.dumps(result, indent=2, sort_keys=True))
    return 0 if result["passed"] else 1


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