# SPDX-License-Identifier: GPL-2.0-or-later

import bpy
import gpu
import bgl
import blf
import math
import enum
import random

from itertools import islice
from mathutils.bvhtree import BVHTree
from mathutils import Vector, Matrix, Euler
from gpu_extras.batch import batch_for_shader

from bpy_extras.view3d_utils import (
    region_2d_to_vector_3d,
    region_2d_to_origin_3d
)


# Modal Operator
################################################################

class ScatterObjects(bpy.types.Operator):
    bl_idname = "object.scatter"
    bl_label = "Scatter Objects"
    bl_options = {'REGISTER', 'UNDO'}

    @classmethod
    def poll(cls, context):
        return (
            currently_in_3d_view(context)
            and context.active_object is not None
            and context.active_object.mode == 'OBJECT')

    def invoke(self, context, event):
        self.target_object = context.active_object
        self.objects_to_scatter = get_selected_non_active_objects(context)

        if self.target_object is None or len(self.objects_to_scatter) == 0:
            self.report({'ERROR'}, "Select objects to scatter and a target object")
            return {'CANCELLED'}

        self.base_scale = get_max_object_side_length(self.objects_to_scatter)

        self.targets = []
        self.active_target = None
        self.target_cache = {}

        self.enable_draw_callback()
        context.window_manager.modal_handler_add(self)
        return {'RUNNING_MODAL'}

    def modal(self, context, event):
        context.area.tag_redraw()

        if not event_is_in_region(event, context.region) and self.active_target is None:
            return {'PASS_THROUGH'}

        if event.type == 'ESC':
            return self.finish('CANCELLED')

        if event.type == 'RET' and event.value == 'PRESS':
            self.create_scatter_object()
            return self.finish('FINISHED')

        event_used = self.handle_non_exit_event(event)
        if event_used:
            return {'RUNNING_MODAL'}
        else:
            return {'PASS_THROUGH'}

    def handle_non_exit_event(self, event):
        if self.active_target is None:
            if event.type == 'LEFTMOUSE' and event.value == 'PRESS':
                self.active_target = StrokeTarget()
                self.active_target.start_build(self.target_object)
                return True
        else:
            build_state = self.active_target.continue_build(event)
            if build_state == BuildState.FINISHED:
                self.targets.append(self.active_target)
                self.active_target = None
            self.remove_target_from_cache(self.active_target)
            return True

        return False

    def enable_draw_callback(self):
        self._draw_callback_view = bpy.types.SpaceView3D.draw_handler_add(self.draw_view, (), 'WINDOW', 'POST_VIEW')
        self._draw_callback_px = bpy.types.SpaceView3D.draw_handler_add(self.draw_px, (), 'WINDOW', 'POST_PIXEL')

    def disable_draw_callback(self):
        bpy.types.SpaceView3D.draw_handler_remove(self._draw_callback_view, 'WINDOW')
        bpy.types.SpaceView3D.draw_handler_remove(self._draw_callback_px, 'WINDOW')

    def draw_view(self):
        for target in self.iter_targets():
            target.draw()

        draw_matrices_batches(list(self.iter_matrix_batches()))

    def draw_px(self):
        draw_text((20, 20, 0), "Instances: " + str(len(self.get_all_matrices())))

    def finish(self, return_value):
        self.disable_draw_callback()
        bpy.context.area.tag_redraw()
        return {return_value}

    def create_scatter_object(self):
        matrix_chunks = make_random_chunks(
            self.get_all_matrices(), len(self.objects_to_scatter))

        collection = bpy.data.collections.new("Scatter")
        bpy.context.collection.children.link(collection)

        for obj, matrices in zip(self.objects_to_scatter, matrix_chunks):
            make_duplicator(collection, obj, matrices)

    def get_all_matrices(self):
        settings = self.get_current_settings()

        matrices = []
        for target in self.iter_targets():
            self.ensure_target_is_in_cache(target)
            matrices.extend(self.target_cache[target].get_matrices(settings))
        return matrices

    def iter_matrix_batches(self):
        settings = self.get_current_settings()
        for target in self.iter_targets():
            self.ensure_target_is_in_cache(target)
            yield self.target_cache[target].get_batch(settings)

    def iter_targets(self):
        yield from self.targets
        if self.active_target is not None:
            yield self.active_target

    def ensure_target_is_in_cache(self, target):
        if target not in self.target_cache:
            entry = TargetCacheEntry(target, self.base_scale)
            self.target_cache[target] = entry

    def remove_target_from_cache(self, target):
        self.target_cache.pop(self.active_target, None)

    def get_current_settings(self):
        return bpy.context.scene.scatter_properties.to_settings()

class TargetCacheEntry:
    def __init__(self, target, base_scale):
        self.target = target
        self.last_used_settings = None
        self.base_scale = base_scale
        self.settings_changed()

    def get_matrices(self, settings):
        self._handle_new_settings(settings)
        if self.matrices is None:
            self.matrices = self.target.get_matrices(settings)
        return self.matrices

    def get_batch(self, settings):
        self._handle_new_settings(settings)
        if self.gpu_batch is None:
            self.gpu_batch = create_batch_for_matrices(self.get_matrices(settings), self.base_scale)
        return self.gpu_batch

    def _handle_new_settings(self, settings):
        if settings != self.last_used_settings:
            self.settings_changed()
        self.last_used_settings = settings

    def settings_changed(self):
        self.matrices = None
        self.gpu_batch = None


# Duplicator Creation
######################################################

def make_duplicator(target_collection, source_object, matrices):
    triangle_scale = 0.1

    duplicator = triangle_object_from_matrices(source_object.name + " Duplicator", matrices, triangle_scale)
    duplicator.instance_type = 'FACES'
    duplicator.use_instance_faces_scale = True
    duplicator.show_instancer_for_viewport = True
    duplicator.show_instancer_for_render = False
    duplicator.instance_faces_scale = 1 / triangle_scale

    copy_obj = source_object.copy()
    copy_obj.name = source_object.name + " - copy"
    copy_obj.location = (0, 0, 0)
    copy_obj.parent = duplicator

    target_collection.objects.link(duplicator)
    target_collection.objects.link(copy_obj)

def triangle_object_from_matrices(name, matrices, triangle_scale):
    mesh = triangle_mesh_from_matrices(name, matrices, triangle_scale)
    return bpy.data.objects.new(name, mesh)

def triangle_mesh_from_matrices(name, matrices, triangle_scale):
    mesh = bpy.data.meshes.new(name)
    vertices, polygons = mesh_data_from_matrices(matrices, triangle_scale)
    mesh.from_pydata(vertices, [], polygons)
    mesh.update()
    mesh.validate()
    return mesh

unit_triangle_vertices = (
    Vector((-3**-0.25, -3**-0.75, 0)),
    Vector((3**-0.25, -3**-0.75, 0)),
    Vector((0, 2/3**0.75, 0)))

def mesh_data_from_matrices(matrices, triangle_scale):
    vertices = []
    polygons = []
    triangle_vertices = [triangle_scale * v for v in unit_triangle_vertices]

    for i, matrix in enumerate(matrices):
        vertices.extend((matrix @ v for v in triangle_vertices))
        polygons.append((i * 3 + 0, i * 3 + 1, i * 3 + 2))

    return vertices, polygons


# Target Provider
#################################################

class BuildState(enum.Enum):
    FINISHED = enum.auto()
    ONGOING = enum.auto()

class TargetProvider:
    def start_build(self, target_object):
        pass

    def continue_build(self, event):
        return BuildState.FINISHED

    def get_matrices(self, scatter_settings):
        return []

    def draw(self):
        pass

class StrokeTarget(TargetProvider):
    def start_build(self, target_object):
        self.points = []
        self.bvhtree = bvhtree_from_object(target_object)
        self.batch = None

    def continue_build(self, event):
        if event.type == 'LEFTMOUSE' and event.value == 'RELEASE':
            return BuildState.FINISHED

        mouse_pos = (event.mouse_region_x, event.mouse_region_y)
        location, *_ = shoot_region_2d_ray(self.bvhtree, mouse_pos)
        if location is not None:
            self.points.append(location)
            self.batch = None
        return BuildState.ONGOING

    def draw(self):
        if self.batch is None:
            self.batch = create_line_strip_batch(self.points)
        draw_line_strip_batch(self.batch, color=(1.0, 0.4, 0.1, 1.0), thickness=5)

    def get_matrices(self, scatter_settings):
        return scatter_around_stroke(self.points, self.bvhtree, scatter_settings)

def scatter_around_stroke(stroke_points, bvhtree, settings):
    scattered_matrices = []
    for point, local_seed in iter_points_on_stroke_with_seed(stroke_points, settings.density, settings.seed):
        matrix = scatter_from_source_point(bvhtree, point, local_seed, settings)
        scattered_matrices.append(matrix)
    return scattered_matrices

def iter_points_on_stroke_with_seed(stroke_points, density, seed):
    for i, (start, end) in enumerate(iter_pairwise(stroke_points)):
        segment_seed = sub_seed(seed, i)
        segment_vector = end - start

        segment_length = segment_vector.length
        amount = round_random(segment_length * density, segment_seed)

        for j in range(amount):
            t = random_uniform(sub_seed(segment_seed, j, 0))
            origin = start + t * segment_vector
            yield origin, sub_seed(segment_seed, j, 1)

def scatter_from_source_point(bvhtree, point, seed, settings):
    # Project displaced point on surface
    radius = random_uniform(sub_seed(seed, 0)) * settings.radius
    offset = random_vector(sub_seed(seed, 2)) * radius
    location, normal, *_ = bvhtree.find_nearest(point + offset)
    assert location is not None
    normal.normalize()

    up_direction = normal if settings.use_normal_rotation else Vector((0, 0, 1))

    # Scale
    min_scale = settings.scale * (1 - settings.random_scale)
    max_scale = settings.scale
    scale = random_uniform(sub_seed(seed, 1), min_scale, max_scale)

    # Location
    location += normal * settings.normal_offset * scale

    # Rotation
    z_rotation = Euler((0, 0, random_uniform(sub_seed(seed, 3), 0, 2 * math.pi))).to_matrix()
    up_rotation = up_direction.to_track_quat('Z', 'X').to_matrix()
    local_rotation = random_euler(sub_seed(seed, 3), settings.rotation).to_matrix()
    rotation = local_rotation @ up_rotation @ z_rotation

    return Matrix.Translation(location) @ rotation.to_4x4() @ scale_matrix(scale)


# Drawing
#################################################

box_vertices = (
    (-1, -1,  1), ( 1, -1,  1), ( 1,  1,  1), (-1,  1,  1),
    (-1, -1, -1), ( 1, -1, -1), ( 1,  1, -1), (-1,  1, -1))

box_indices = (
    (0, 1, 2), (2, 3, 0), (1, 5, 6), (6, 2, 1),
    (7, 6, 5), (5, 4, 7), (4, 0, 3), (3, 7, 4),
    (4, 5, 1), (1, 0, 4), (3, 2, 6), (6, 7, 3))

box_vertices = tuple(Vector(vertex) * 0.5 for vertex in box_vertices)

def draw_matrices_batches(batches):
    shader = get_uniform_color_shader()
    shader.bind()
    shader.uniform_float("color", (0.4, 0.4, 1.0, 0.3))

    bgl.glEnable(bgl.GL_BLEND)
    bgl.glDepthMask(bgl.GL_FALSE)

    for batch in batches:
        batch.draw(shader)

    bgl.glDisable(bgl.GL_BLEND)
    bgl.glDepthMask(bgl.GL_TRUE)

def create_batch_for_matrices(matrices, base_scale):
    coords = []
    indices = []

    scaled_box_vertices = [base_scale * vertex for vertex in box_vertices]

    for matrix in matrices:
        offset = len(coords)
        coords.extend((matrix @ vertex for vertex in scaled_box_vertices))
        indices.extend(tuple(index + offset for index in element) for element in box_indices)

    batch = batch_for_shader(get_uniform_color_shader(),
        'TRIS', {"pos" : coords}, indices = indices)
    return batch


def draw_line_strip_batch(batch, color, thickness=1):
    shader = get_uniform_color_shader()
    bgl.glLineWidth(thickness)
    shader.bind()
    shader.uniform_float("color", color)
    batch.draw(shader)

def create_line_strip_batch(coords):
    return batch_for_shader(get_uniform_color_shader(), 'LINE_STRIP', {"pos" : coords})


def draw_text(location, text, size=15, color=(1, 1, 1, 1)):
    font_id = 0
    ui_scale = bpy.context.preferences.system.ui_scale
    blf.position(font_id, *location)
    blf.size(font_id, round(size * ui_scale), 72)
    blf.draw(font_id, text)


# Utilities
########################################################

'''
Pythons random functions are designed to be used in cases
when a seed is set once and then many random numbers are
generated. To improve the user experience I want to have
full control over how random seeds propagate through the
functions. This is why I use custom random functions.

One benefit is that changing the object density does not
generate new random positions for all objects.
'''

def round_random(value, seed):
    probability = value % 1
    if probability < random_uniform(seed):
        return math.floor(value)
    else:
        return math.ceil(value)

def random_vector(x, min=-1, max=1):
    return Vector((
        random_uniform(sub_seed(x, 0), min, max),
        random_uniform(sub_seed(x, 1), min, max),
        random_uniform(sub_seed(x, 2), min, max)))

def random_euler(x, factor):
    return Euler(tuple(random_vector(x) * factor))

def random_uniform(x, min=0, max=1):
    return random_int(x) / 2147483648 * (max - min) + min

def random_int(x):
    x = (x<<13) ^ x
    return (x * (x * x * 15731 + 789221) + 1376312589) & 0x7fffffff

def sub_seed(seed, index, index2=0):
    return random_int(seed * 3243 + index * 5643 + index2 * 54243)


def currently_in_3d_view(context):
    return context.space_data.type == 'VIEW_3D'

def get_selected_non_active_objects(context):
    return set(context.selected_objects) - {context.active_object}

def make_random_chunks(sequence, chunk_amount):
    sequence = list(sequence)
    random.shuffle(sequence)
    return make_chunks(sequence, chunk_amount)

def make_chunks(sequence, chunk_amount):
    length = math.ceil(len(sequence) / chunk_amount)
    return [sequence[i:i+length] for i in range(0, len(sequence), length)]

def iter_pairwise(sequence):
    return zip(sequence, islice(sequence, 1, None))

def bvhtree_from_object(object):
    import bmesh
    bm = bmesh.new()

    depsgraph = bpy.context.evaluated_depsgraph_get()
    object_eval = object.evaluated_get(depsgraph)
    mesh = object_eval.to_mesh()
    bm.from_mesh(mesh)
    bm.transform(object.matrix_world)

    bvhtree = BVHTree.FromBMesh(bm)
    object_eval.to_mesh_clear()
    return bvhtree

def shoot_region_2d_ray(bvhtree, position_2d):
    region = bpy.context.region
    region_3d = bpy.context.space_data.region_3d

    origin = region_2d_to_origin_3d(region, region_3d, position_2d)
    direction = region_2d_to_vector_3d(region, region_3d, position_2d)

    location, normal, index, distance = bvhtree.ray_cast(origin, direction)
    return location, normal, index, distance

def scale_matrix(factor):
    m = Matrix.Identity(4)
    m[0][0] = factor
    m[1][1] = factor
    m[2][2] = factor
    return m

def event_is_in_region(event, region):
    return (region.x <= event.mouse_x <= region.x + region.width
        and region.y <= event.mouse_y <= region.y + region.height)

def get_max_object_side_length(objects):
    return max(
        max(obj.dimensions[0] for obj in objects),
        max(obj.dimensions[1] for obj in objects),
        max(obj.dimensions[2] for obj in objects)
    )

def get_uniform_color_shader():
    return gpu.shader.from_builtin('3D_UNIFORM_COLOR')


# Registration
###############################################

def register():
    bpy.utils.register_class(ScatterObjects)

def unregister():
    bpy.utils.unregister_class(ScatterObjects)
