"""Shared Cinema 4D render + geometry foundations for Three Blocks tools.

`RenderSession` owns the one proven external-render pattern: a warm renderer
over a clone that is NOT in the document list, rendered with
`RENDERFLAGS_EXTERNAL | RENDERFLAGS_NODOCUMENTCLONE` (lifted from
utsubo_splat's bake, where it was verified against real production scenes).
The geometry helpers (`polygon_objects`, `world_bounds`, `surface_points`)
and `disable_grading_posts` moved here from `utsubo_splat/bake.py` so every
tool port imports one copy.
"""

from __future__ import annotations

import os
import random

import c4d
import c4d.documents
import c4d.utils

# The strategy Axis A flips once the range render is measured faster:
#   "per_view"  — one RenderDocument call per camera pose (today's proven path).
#   "animation" — keyframe every pose onto the session camera and render ONE
#                 RDATA_FRAMESEQUENCE_MANUAL range.
# A per-session override is the `strategy=` constructor kwarg.
DEFAULT_STRATEGY = "per_view"


# --- geometry helpers (moved from utsubo_splat/bake.py) -----------------------


def polygon_objects(doc):
    """Every renderable polygon object, generators included.

    Current State to Object gives the polygonised result of Cloners, Subdivision
    Surfaces, Cloth Surfaces and Scene Nodes — the geometry the renderer shows
    and the point seed needs.

    Returns `(keepalive, objects)`: the caller MUST hold `keepalive` for as long
    as it touches `objects`. SendModelingCommand hands back roots that live in
    no document, so dropping them frees their children mid-walk ("the object
    'c4d.PolygonObject' is not alive").
    """
    clone = doc.GetClone(c4d.COPYFLAGS_NONE)
    clone.ExecutePasses(None, True, True, True, c4d.BUILDFLAGS_NONE)
    roots = []
    node = clone.GetFirstObject()
    while node:
        roots.append(node)
        node = node.GetNext()
    try:
        result = c4d.utils.SendModelingCommand(
            command=c4d.MCOMMAND_CURRENTSTATETOOBJECT,
            list=roots,
            mode=c4d.MODELINGCOMMANDMODE_ALL,
            doc=clone,
        )
    except Exception:
        result = None
    source = result if isinstance(result, list) and result else roots

    found = []

    def walk(node):
        while node:
            if node.IsInstanceOf(c4d.Opolygon) and node.GetPointCount():
                found.append(node)
            walk(node.GetDown())
            node = node.GetNext()

    for root in source:
        walk(root)
    return (clone, source), found


def world_bounds(objects):
    """AABB over the polygon vertices, in Cinema 4D world space."""
    minimum = [float("inf")] * 3
    maximum = [float("-inf")] * 3
    triangles = 0
    for node in objects:
        matrix = node.GetMg()
        triangles += node.GetPolygonCount()
        for point in node.GetAllPoints():
            world = matrix * point
            for axis, value in enumerate((world.x, world.y, world.z)):
                minimum[axis] = min(minimum[axis], value)
                maximum[axis] = max(maximum[axis], value)
    if minimum[0] == float("inf"):
        raise RuntimeError("no polygon geometry to capture.")
    return tuple(minimum), tuple(maximum), triangles


def polygon_uv(uvw, index):
    """The polygon's four UV corners as `{'a': (u, v), …}`, or None."""
    if uvw is None:
        return None
    try:
        return {key: (vector.x, vector.y) for key, vector in uvw.GetSlow(index).items()}
    except Exception:
        return None


def surface_points(objects, target: int, seed: int = 1, color_for=None):
    """Area-weighted surface samples — the same low-discrepancy R2 sequence the
    Blender addon uses (plain random leaves clumps that survive training).

    `color_for(node)` returns either an `(r, g, b)` tuple — one flat colour for
    every one of that object's samples — or a `sampler(u, v) -> (r, g, b)`
    callable, which is handed each sample's UV so a textured material seeds the
    colours it actually paints. The default grey is the caller-agnostic
    fallback."""
    inv_g, inv_g2 = 0.7548776662466927, 0.5698402909980532
    faces = []
    total_area = 0.0
    for node in objects:
        matrix = node.GetMg()
        colour = color_for(node) if color_for else (200, 200, 200)
        uvw = node.GetTag(c4d.Tuvw) if callable(colour) else None
        if uvw is None and callable(colour):
            colour = colour(0.0, 0.0)  # no UV layer → one flat sample of it
        points = [matrix * point for point in node.GetAllPoints()]
        for index, polygon in enumerate(node.GetAllPolygons()):
            uv = polygon_uv(uvw, index)
            triangles = [((polygon.a, polygon.b, polygon.c), ("a", "b", "c"))]
            if polygon.c != polygon.d:  # quad → two triangles, UVs split the SAME way
                triangles.append(((polygon.a, polygon.c, polygon.d), ("a", "c", "d")))
            for triangle, keys in triangles:
                a, b, c = (points[corner] for corner in triangle)
                area = (b - a).Cross(c - a).GetLength() * 0.5
                if area <= 0.0:
                    continue
                faces.append((area, a, b, c, colour,
                              tuple(uv[key] for key in keys) if uv else None))
                total_area += area
    if not faces or total_area <= 0.0:
        return []

    generator = random.Random(seed)
    quotas = [(face[0] / total_area) * target for face in faces]
    counts = [int(quota) for quota in quotas]
    shortfall = target - sum(counts)
    if shortfall > 0:  # largest-remainder, so tiny faces still get their share
        order = sorted(range(len(faces)), key=lambda i: -(quotas[i] - counts[i]))
        for index in order[:shortfall]:
            counts[index] += 1

    samples = []
    for (_, a, b, c, colour, uv), count in zip(faces, counts):
        offset_x, offset_y = generator.random(), generator.random()
        for step in range(count):
            u = (offset_x + inv_g * (step + 1)) % 1.0
            v = (offset_y + inv_g2 * (step + 1)) % 1.0
            if u + v > 1.0:
                u, v = 1.0 - u, 1.0 - v
            point = a * (1.0 - u - v) + b * u + c * v
            rgb = colour
            if uv is not None:
                weight = 1.0 - u - v  # the SAME barycentric as the position
                rgb = colour(uv[0][0] * weight + uv[1][0] * u + uv[2][0] * v,
                             uv[0][1] * weight + uv[1][1] * u + uv[2][1] * v)
            samples.append(((point.x, point.y, point.z), rgb))
    return samples


def renderer_post(rdata):
    """The video post that IS the active renderer (Redshift, Standard, …)."""
    renderer = rdata[c4d.RDATA_RENDERENGINE]
    post = rdata.GetFirstVideoPost()
    while post:
        if post.GetType() == renderer:
            return post
        post = post.GetNext()
    return None


# Redshift's adaptive sampler, by parameter id from
# `Redshift/res/description/vprsrenderer.h` (RS 2026.7, shipped with C4D 2026.3).
RS_MIN_SAMPLES = 1101
RS_MAX_SAMPLES = 1102
RS_ERROR_THRESHOLD = 1103
RS_AUTOMATIC_SAMPLING = 1107


def cap_sampling(rdata, max_samples, threshold=0.05):
    """Cap the renderer's adaptive sampler — the ONE knob that makes a capture
    finish (see `docs/reviews/cinema4d-splat-timing.md`).

    A production scene ships an adaptive ceiling meant for one hero frame
    (`unified minmax: [16, 8192]` on the reference scene); a capture is dozens
    of frames of the same subject, and the trainer averages them, so paying for
    the last stop of noise per view is pure waste. Materials, lights and the
    authored look are untouched — only how long the sampler chases the noise
    threshold. Returns the post it capped, or None when the renderer has no
    such parameters (Standard, or a Redshift that renamed them).
    """
    post = renderer_post(rdata)
    if post is None or not max_samples:
        return None
    try:
        post[RS_AUTOMATIC_SAMPLING] = False  # else min/max below are ignored
        post[RS_MIN_SAMPLES] = max(1, min(16, int(max_samples)))
        post[RS_MAX_SAMPLES] = int(max_samples)
        post[RS_ERROR_THRESHOLD] = float(threshold)
    except Exception:
        return None
    return post


def disable_grading_posts(rdata) -> list:
    """Turn off every video post that is NOT the renderer itself.

    Colour grading (Magic Bullet Looks), bloom and glare are view-dependent
    looks; baked into a splat they become geometry-shaped artefacts that follow
    the camera. The Blender addon disables the compositor for the same reason.
    Returns the posts it touched so the caller can restore them.
    """
    renderer = rdata[c4d.RDATA_RENDERENGINE]
    disabled = []
    post = rdata.GetFirstVideoPost()
    while post:
        if post.GetType() != renderer and not post.GetBit(c4d.BIT_VPDISABLED):
            post.SetBit(c4d.BIT_VPDISABLED)
            disabled.append(post)
        post = post.GetNext()
    return disabled


# --- the render session -------------------------------------------------------


def pose_matrix(pose):
    """A `(position, right, up, forward)` tuple pose → a c4d.Matrix."""
    position, right, up, forward = pose
    matrix = c4d.Matrix()
    matrix.v1 = c4d.Vector(*right)
    matrix.v2 = c4d.Vector(*up)
    matrix.v3 = c4d.Vector(*forward)
    matrix.off = c4d.Vector(*position)
    return matrix


class RenderSession:
    """A warm renderer over one cloned document."""

    def __init__(self, doc, *, size, alpha=True, keep_grading=False,
                 focal_mm=50.0, sensor_mm=36.0, strategy=None,
                 max_samples=None, sample_threshold=0.05, thread=None):
        self._size = int(size)
        self._alpha = bool(alpha)
        self._strategy = strategy  # None → module DEFAULT_STRATEGY at call time
        self._thread = thread
        self._doc = doc.GetClone(c4d.COPYFLAGS_NONE)
        self._rdata = self._doc.GetActiveRenderData()
        if not keep_grading:
            disable_grading_posts(self._rdata)
        cap_sampling(self._rdata, max_samples, sample_threshold)
        self._rdata[c4d.RDATA_XRES] = self._size
        self._rdata[c4d.RDATA_YRES] = self._size
        self._rdata[c4d.RDATA_ALPHACHANNEL] = self._alpha
        # The dataset contract is STRAIGHT alpha (the per-view path gets it from
        # AddChannel(True, True)); the animation path saves via the renderer, so
        # the render data must say so too.
        self._rdata[c4d.RDATA_STRAIGHTALPHA] = self._alpha
        camera = c4d.BaseObject(c4d.Ocamera)
        camera.SetName("TB_RenderCam")
        camera[c4d.CAMERA_FOCUS] = float(focal_mm)
        camera[c4d.CAMERAOBJECT_APERTURE] = float(sensor_mm)
        self._doc.InsertObject(camera)
        draw = self._doc.GetRenderBaseDraw() or self._doc.GetActiveBaseDraw()
        if draw is None:
            raise RuntimeError("this document has no view to render from; open or clone a scene first.")
        draw.SetSceneCamera(camera)
        self._camera = camera

    @property
    def document(self):
        """The clone this session renders — safe to hide objects in, it is not
        the artist's document and dies with `close()`."""
        self._require_open()
        return self._doc

    def render_camera_path(self, cameras, out_dir, *, name_fmt="View.{index:03d}.png",
                           progress=None):
        """Render N `(position, right, up, forward)` poses into `out_dir`.

        Returns the saved basenames in camera order; `index` in `name_fmt` is
        1-based. `progress(index, total)` fires per finished view.
        """
        self._require_open()
        os.makedirs(out_dir, exist_ok=True)
        strategy = self._strategy or DEFAULT_STRATEGY
        if strategy == "animation":
            return self._render_animation(cameras, out_dir, name_fmt, progress)
        return self._render_per_view(cameras, out_dir, name_fmt, progress)

    def render_single(self, camera_matrix, out_path) -> None:
        """Render one view from a c4d.Matrix camera transform."""
        self._require_open()
        parent = os.path.dirname(out_path)
        if parent:
            os.makedirs(parent, exist_ok=True)
        self._camera.SetMg(camera_matrix)
        self._doc.ExecutePasses(None, True, True, True, c4d.BUILDFLAGS_NONE)
        self._render_to(out_path)

    def close(self) -> None:
        """Drop the cloned document — and with it the session camera."""
        self._doc = None
        self._rdata = None
        self._camera = None
        self._thread = None

    # --- internals ------------------------------------------------------------

    def _require_open(self):
        if self._doc is None:
            raise RuntimeError("RenderSession is closed.")

    def _render_to(self, out_path, index=1):
        bitmap = c4d.bitmaps.MultipassBitmap(self._size, self._size, c4d.COLORMODE_RGB)
        if self._alpha:
            # Trap 2: RDATA_SAVEALPHA does not exist — alpha is RDATA_ALPHACHANNEL
            # + AddChannel(True, True) + SAVEBIT_ALPHA, nothing else.
            bitmap.AddChannel(True, True)
        result = c4d.documents.RenderDocument(
            self._doc, self._rdata.GetDataInstance(), bitmap,
            c4d.RENDERFLAGS_EXTERNAL | c4d.RENDERFLAGS_NODOCUMENTCLONE,
            th=self._thread,
        )
        if result != c4d.RENDERRESULT_OK:
            raise RuntimeError(f"Cinema 4D could not render view {index} (code {result}).")
        bitmap.Save(out_path, c4d.FILTER_PNG, c4d.BaseContainer(),
                    c4d.SAVEBIT_ALPHA if self._alpha else 0)

    def _render_per_view(self, cameras, out_dir, name_fmt, progress):
        names = []
        for index, pose in enumerate(cameras, start=1):
            self._camera.SetMg(pose_matrix(pose))
            self._doc.ExecutePasses(None, True, True, True, c4d.BUILDFLAGS_NONE)
            name = name_fmt.format(index=index)
            self._render_to(os.path.join(out_dir, name), index)
            names.append(name)
            if progress:
                progress(index, len(cameras))
        return names

    def _render_animation(self, cameras, out_dir, name_fmt, progress):
        """Keyframe the poses onto the session camera, render ONE manual range."""
        fps = self._doc.GetFps()
        tracks = self._pose_tracks()
        for frame, pose in enumerate(cameras):
            matrix = pose_matrix(pose)
            hpb = c4d.utils.MatrixToHPB(matrix)
            values = (matrix.off.x, matrix.off.y, matrix.off.z, hpb.x, hpb.y, hpb.z)
            time = c4d.BaseTime(frame, fps)
            for track, value in zip(tracks, values):
                curve = track.GetCurve()
                key = curve.AddKey(time)["key"]
                key.SetValue(curve, value)
                key.SetInterpolation(curve, c4d.CINTERPOLATION_LINEAR)
        self._doc.ExecutePasses(None, True, True, True, c4d.BUILDFLAGS_NONE)

        self._rdata[c4d.RDATA_FRAMESEQUENCE] = c4d.RDATA_FRAMESEQUENCE_MANUAL
        self._rdata[c4d.RDATA_FRAMEFROM] = c4d.BaseTime(0, fps)
        self._rdata[c4d.RDATA_FRAMETO] = c4d.BaseTime(len(cameras) - 1, fps)
        self._rdata[c4d.RDATA_FRAMESTEP] = 1
        self._rdata[c4d.RDATA_SAVEIMAGE] = True
        self._rdata[c4d.RDATA_FORMAT] = c4d.FILTER_PNG
        self._rdata[c4d.RDATA_PATH] = os.path.join(out_dir, "TBFrame")

        before = set(os.listdir(out_dir))
        bitmap = c4d.bitmaps.MultipassBitmap(self._size, self._size, c4d.COLORMODE_RGB)
        if self._alpha:
            bitmap.AddChannel(True, True)
        result = c4d.documents.RenderDocument(
            self._doc, self._rdata.GetDataInstance(), bitmap,
            c4d.RENDERFLAGS_EXTERNAL | c4d.RENDERFLAGS_NODOCUMENTCLONE,
            th=self._thread,
        )
        if result != c4d.RENDERRESULT_OK:
            raise RuntimeError(f"Cinema 4D could not render the camera path (code {result}).")

        # C4D names the sequence itself; rename (in frame order — the prefix is
        # zero-padded, so lexical sort == frame order) onto the caller's format.
        written = sorted(set(os.listdir(out_dir)) - before)
        if len(written) != len(cameras):
            raise RuntimeError(
                f"Expected {len(cameras)} frames, the range render wrote {len(written)}."
            )
        names = []
        for index, source in enumerate(written, start=1):
            name = name_fmt.format(index=index)
            os.replace(os.path.join(out_dir, source), os.path.join(out_dir, name))
            names.append(name)
            if progress:
                progress(index, len(cameras))
        return names

    def _pose_tracks(self):
        """Six fresh CTracks: position X/Y/Z then rotation H/P/B (MatrixToHPB
        order). REL_* is parent-relative, but the session camera has no parent,
        so relative == global."""
        tracks = []
        for parameter in (c4d.ID_BASEOBJECT_REL_POSITION, c4d.ID_BASEOBJECT_REL_ROTATION):
            for component in (c4d.VECTOR_X, c4d.VECTOR_Y, c4d.VECTOR_Z):
                descid = c4d.DescID(
                    c4d.DescLevel(parameter, c4d.DTYPE_VECTOR, 0),
                    c4d.DescLevel(component, c4d.DTYPE_REAL, 0),
                )
                track = c4d.CTrack(self._camera, descid)
                self._camera.InsertTrackSorted(track)
                tracks.append(track)
        return tracks
