# -*- coding: utf-8 -*-
# Copyright (c) 2023 Salvador E. Tropea
# Copyright (c) 2023 Instituto Nacional de Tecnología Industrial
# License: GPL-3.0
# Project: KiBot (formerly KiPlot)
"""
Dependencies:
  - from: Blender
    role: mandatory
    version: 3.4.0
  - from: ImageMagick
    role: Automatically crop images
"""
import json
import os
from tempfile import NamedTemporaryFile, TemporaryDirectory
from .error import KiPlotConfigurationError
from .kiplot import get_output_targets, run_output, run_command, register_xmp_import, config_output, configure_and_run
from .gs import GS
from .optionable import Optionable, BaseOptions
from .out_base_3d import Base3D, Base3DOptionsWithHL
from .registrable import RegOutput
from .macros import macros, document, output_class  # noqa: F401
from . import log

logger = log.get_logger()
bb = None


def get_board_size():
    global bb
    if bb is None:
        bb = GS.board.ComputeBoundingBox(True)
    width = GS.to_mm(bb.GetWidth())/1000.0
    height = GS.to_mm(bb.GetHeight())/1000.0
    size = max(width, height)
    return width, height, size


class PCB2BlenderOptions(Optionable):
    """ How the PCB3D is imported """
    def __init__(self):
        super().__init__()
        with document:
            self.components = True
            """ Import the components """
            self.cut_boards = True
            """ Separate the sub-PCBs in separated 3D models """
            self.texture_dpi = 1016.0
            """ [508-2032] Texture density in dots per inch """
            self.center = True
            """ Center the PCB at the coordinates origin """
            self.enhance_materials = True
            """ Create good looking materials """
            self.merge_materials = True
            """ Reuse materials """
            self.solder_joints = "SMART"
            """ [NONE,SMART,ALL] The plug-in can add nice looking solder joints.
                This option controls if we add it for none, all or only for THT/SMD pads with solder paste """
            self.stack_boards = True
            """ Move the sub-PCBs to their relative position """
        self._unkown_is_error = True


class BlenderOutputOptions(Optionable):
    """ What is generated """
    def __init__(self):
        super().__init__()
        with document:
            self.type = 'render'
            """ *[fbx,obj,x3d,gltf,stl,ply,blender,render] The format for the output.
                The `render` type will generate a PNG image of the render result.
                `fbx` is Kaydara's Filmbox, 'obj' is the Wavefront, 'x3d' is the new ISO/IEC standard
                that replaced VRML, `gltf` is the standardized GL format, `stl` is the 3D printing
                format, 'ply' is Polygon File Format (Stanford).
                Note that some formats includes the light and camera and others are just the 3D model
                (i.e. STL and PLY) """
            self.output = GS.def_global_output
            """ Name for the generated file (%i='3D_blender_$VIEW' %x=VARIABLE).
                The extension is selected from the type """
        self._unkown_is_error = True


class BlenderLightOptions(Optionable):
    """ A light in the scene. Currently also for the camera """
    def __init__(self):
        super().__init__()
        with document:
            self.name = ""
            """ Name for the light """
            self.pos_x = 0
            """ [number|string] X position [m]. You can use `width`, `height` and `size` for PCB dimensions """
            self.pos_y = 0
            """ [number|string] Y position [m]. You can use `width`, `height` and `size` for PCB dimensions """
            self.pos_z = 0
            """ [number|string] Z position [m]. You can use `width`, `height` and `size` for PCB dimensions """
        self._unkown_is_error = True

    def solve(self, member):
        val = getattr(self, member)
        if not isinstance(val, str):
            return float(val)
        try:
            res = eval(val, {}, {'width': self._width, 'height': self._width, 'size': self._size})
        except Exception as e:
            raise KiPlotConfigurationError('wrong `{}`: `{}`\nPython says: `{}`'.format(member, val, str(e)))
        return res

    def config(self, parent):
        super().config(parent)
        self._width, self._height, self._size = get_board_size()
        self.pos_x = self.solve('pos_x')
        self.pos_y = self.solve('pos_y')
        self.pos_z = self.solve('pos_z')


class BlenderCameraOptions(BlenderLightOptions):
    CAM_TYPE = {'perspective': 'PERSP', 'orthographic': 'ORTHO', 'panoramic': 'PANO'}

    def __init__(self):
        super().__init__()
        with document:
            self.type = "perspective"
            """ [perspective,orthographic,panoramic] Type of camera """

    def config(self, parent):
        super().config(parent)
        self._type = self.CAM_TYPE[self.type]


class BlenderRenderOptions(Optionable):
    """ Render parameters """
    def __init__(self):
        super().__init__()
        with document:
            self.samples = 10
            """ *How many samples we create. Each sample is a raytracing render.
                Use 1 for a raw preview, 10 for a draft and 100 or more for the final render """
            self.resolution_x = 1280
            """ Width of the image """
            self.width = None
            """ {resolution_x} """
            self.resolution_y = 720
            """ Height of the image """
            self.height = None
            """ {resolution_y} """
            self.transparent_background = False
            """ *Make the background transparent """
            self.background1 = "#66667F"
            """ First color for the background gradient """
            self.background2 = "#CCCCE5"
            """ Second color for the background gradient """
            self.auto_crop = False
            """ When enabled the image will be post-processed to remove the empty space around the image.
                In this mode the `background2` is changed to be the same as `background1` """
        self._unkown_is_error = True


class BlenderPointOfViewOptions(Optionable):
    """ Point of View parameters """
    _views = {'top': 'z', 'bottom': 'Z', 'front': 'y', 'rear': 'Y', 'right': 'x', 'left': 'X'}
    _rviews = {v: k for k, v in _views.items()}

    def __init__(self):
        super().__init__()
        with document:
            self.rotate_x = 0
            """ Angle to rotate the board in the X axis, positive is clockwise [degrees] """
            self.rotate_y = 0
            """ Angle to rotate the board in the Y axis, positive is clockwise [degrees] """
            self.rotate_z = 0
            """ Angle to rotate the board in the Z axis, positive is clockwise [degrees] """
            self.view = 'top'
            """ *[top,bottom,front,rear,right,left,z,Z,y,Y,x,X] Point of view.
                Compatible with `render_3d` """
            self.file_id = ''
            """ String to diferentiate the name of this view.
                When empty we use the `view` """
        self._unkown_is_error = True
        self._file_id = ''

    def config(self, parent):
        super().config(parent)
        # View point
        view = self._views.get(self.view, None)
        if view is not None:
            self.view = view
        self._file_id = self.file_id
        if not self._file_id:
            self._file_id = '_'+self._rviews.get(self.view)


class PCB3DExportOptions(Base3DOptionsWithHL):
    """ Options to generate the PCB3D file """
    def __init__(self):
        super().__init__()
        with document:
            self.output = GS.def_global_output
            """ Name for the generated PCB3D file (%i='blender_export' %x='pcb3d') """
            self.version = '2.1'
            """ [2.1,2.1_haschtl] Variant of the format used """
            self.solder_paste_for_populated = True
            """ Add solder paste only for the populated components.
                Populated components are the ones listed in `show_components` """
        self._expand_id = 'blender_export'
        self._expand_ext = 'pcb3d'
        self._unkown_is_error = True

    def get_output_name(self, out_dir):
        p = self._parent
        cur_id = p._expand_id
        cur_ext = p._expand_ext
        p._expand_id = self._expand_id
        p._expand_ext = self._expand_ext
        out_name = p._parent.expand_filename(out_dir, self.output)
        p._expand_id = cur_id
        p._expand_ext = cur_ext
        return out_name

    def setup_renderer(self, components, active_components, bottom, name):
        super().setup_renderer(components, active_components)
        self._pov.view = 'Z' if bottom else 'z'
        # Expand the name using .PNG
        cur_ext = self._expand_ext
        self._expand_ext = 'png'
        o_name = self.expand_filename_both(name, is_sch=False)
        self._expand_ext = cur_ext
        self._out.output = o_name
        return o_name

    def save_renderer_options(self):
        """ Save the current renderer settings """
        p = self._parent
        # We are an option inside another option
        self._parent = self._parent._parent
        super().save_renderer_options()
        self._parent = p
        self.old_show_all_components = self._show_all_components
        self.old_view = self._pov.view
        self.old_output = self._out.output

    def restore_renderer_options(self):
        """ Restore the renderer settings """
        p = self._parent
        self._parent = self._parent._parent
        super().restore_renderer_options()
        self._parent = p
        self._show_all_components = self.old_show_all_components
        self._pov.view = self.old_view
        self._out.output = self.old_output


class Blender_ExportOptions(BaseOptions):
    def __init__(self):
        with document:
            self.pcb3d = PCB3DExportOptions
            """ *[string|dict] Options to export the PCB to Blender.
                You can also specify the name of the output that generates the PCB3D file.
                See the `PCB2Blender_2_1` and  `PCB2Blender_2_1_haschtl` templates """
            self.pcb_import = PCB2BlenderOptions
            """ Options to configure how Blender imports the PCB.
                The default values are good for most cases """
            self.outputs = BlenderOutputOptions
            """ [dict|list(dict)] Outputs to generate in the same run """
            self.light = BlenderLightOptions
            """ [dict|list(dict)] Options for the light/s """
            self.add_default_light = True
            """ Add a default light when none specified.
                The default light is located at (-size*3.33, size*3.33, size*5) where size is max(width, height) of the PCB """
            self.camera = BlenderCameraOptions
            """ [dict] Options for the camera.
                If none specified KiBot will create a suitable camera.
                If no position is specified for the camera KiBot will look for a suitable position """
            self.render_options = BlenderRenderOptions
            """ *[dict] Controls how the render is done for the `render` output type """
            self.point_of_view = BlenderPointOfViewOptions
            """ *[dict|list(dict)] How the object is viewed by the camera """
        super().__init__()
        self._expand_id = '3D_blender'
        self._unkown_is_error = True

    def config(self, parent):
        super().config(parent)
        # Check we at least have a name for the source output
        if isinstance(self.pcb3d, str) and not self.pcb3d:
            raise KiPlotConfigurationError('You must specify the name of the output that'
                                           ' generates the PCB3D file or its options')
        if isinstance(self.pcb3d, type):
            self.pcb3d = PCB3DExportOptions()
            self.pcb3d.config(self)
        # Do we have outputs?
        if isinstance(self.outputs, type):
            self.outputs = []
        elif isinstance(self.outputs, BlenderOutputOptions):
            # One, make a list
            self.outputs = [self.outputs]
        # Ensure we have import options
        if isinstance(self.pcb_import, type):
            self.pcb_import = PCB2BlenderOptions()
        # Ensure we have a light
        if isinstance(self.light, type):
            # None
            if self.add_default_light:
                # Create one
                light = BlenderLightOptions()
                light.name = 'kibot_light'
                _, _, size = get_board_size()
                light.pos_x = -size*3.33
                light.pos_y = size*3.33
                light.pos_z = size*5.0
                self.light = [light]
            else:
                # The dark ...
                self.light = []
        elif isinstance(self.light, BlenderLightOptions):
            # Ensure a list
            self.light = [self.light]
        # Check light names
        light_names = set()
        for li in self.light:
            name = li.name if li.name else 'kibot_light'
            if name in light_names:
                id = 2
                while name+'_'+str(id) in light_names:
                    id += 1
                name = name+'_'+str(id)
            li.name = name
        # If no camera let the script create one
        if isinstance(self.camera, type):
            self.camera = None
        elif not self.camera.name:
            self.camera.name = 'kibot_camera'
        # Ensure we have some render options
        if isinstance(self.render_options, type):
            self.render_options = BlenderRenderOptions()
        # Point of View, make sure we have a list and at least 1 element
        if isinstance(self.point_of_view, type):
            self.point_of_view = [BlenderPointOfViewOptions()]
        elif isinstance(self.point_of_view, BlenderPointOfViewOptions):
            self.point_of_view = [self.point_of_view]

    def get_output_filename(self, o, output_dir, pov):
        if o.type == 'render':
            self._expand_ext = 'png'
        elif o.type == 'blender':
            self._expand_ext = 'blend'
        else:
            self._expand_ext = o.type
        cur_id = self._expand_id
        self._expand_id += pov._file_id
        name = self._parent.expand_filename(output_dir, o.output)
        self._expand_id = cur_id
        return name

    def get_targets(self, out_dir):
        files = []
        if isinstance(self.pcb3d, PCB3DExportOptions):
            files.append(self.pcb3d.get_output_name(out_dir))
        for pov in self.point_of_view:
            for o in self.outputs:
                files.append(self.get_output_filename(o, out_dir, pov))
        return files

    def create_vrml(self, dest_dir):
        tree = {'name': '_temporal_vrml_for_pcb3d',
                'type': 'vrml',
                'comment': 'Internally created for the PCB3D',
                'dir': dest_dir,
                'options': {'output': 'pcb.wrl',
                            'dir_models': 'components',
                            'use_pcb_center_as_ref': False,
                            'model_units': 'meters'}}
        out = RegOutput.get_class_for('vrml')()
        out.set_tree(tree)
        config_output(out)
        out.options.copy_options(self.pcb3d)
        logger.debug(' - Creating VRML ...')
        out.options.run(os.path.join(dest_dir, 'pcb.wrl'))

    def create_layers(self, dest_dir):
        out_dir = os.path.join(dest_dir, 'layers')
        tree = {'name': '_temporal_svgs_layers',
                'type': 'svg',
                'comment': 'Internally created for the PCB3D',
                'dir': out_dir,
                'options': {'output': '%i.%x',
                            'margin': 1,
                            'limit_viewbox': True,
                            'svg_precision': 6,
                            'drill_marks': 'none'},
                'layers': ['F.Cu', 'B.Cu', 'F.Paste', 'B.Paste', 'F.Mask', 'B.Mask',
                           {'layer': 'F.SilkS', 'suffix': 'F_SilkS'},
                           {'layer': 'B.SilkS', 'suffix': 'B_SilkS'}]}
        configure_and_run(tree, out_dir, ' - Creating SVG for layers ...')

    def create_pads(self, dest_dir):
        tree = {'name': '_temporal_pcb3d_tools',
                'type': 'pcb2blender_tools',
                'comment': 'Internally created for the PCB3D',
                'dir': dest_dir,
                'options': {'stackup_create': self.pcb3d.version == '2.1_haschtl'}}
        if self.pcb3d.solder_paste_for_populated:
            sc = 'all'
            if not self.pcb3d._show_all_components:
                sc = 'none' if not self.pcb3d.show_components else self.pcb3d._show_components_raw
            tree['options']['show_components'] = sc
        configure_and_run(tree, dest_dir, ' - Creating Pads and boundary ...')

    def create_pcb3d(self, data_dir):
        out_dir = self._parent.output_dir
        # Compute the name for the PCB3D
        out_name = self.pcb3d.get_output_name(out_dir)
        tree = {'name': '_temporal_compress_pcb3d',
                'type': 'compress',
                'comment': 'Internally created for the PCB3D',
                'dir': out_dir,
                'options': {'output': out_name,
                            'format': 'ZIP',
                            'files': [{'source': os.path.join(data_dir, 'boards'),
                                       'dest': '/'},
                                      {'source': os.path.join(data_dir, 'boards/*'),
                                       'dest': 'boards'},
                                      {'source': os.path.join(data_dir, 'components'),
                                       'dest': '/'},
                                      {'source': os.path.join(data_dir, 'components/*'),
                                       'dest': 'components'},
                                      {'source': os.path.join(data_dir, 'layers'),
                                       'dest': '/'},
                                      {'source': os.path.join(data_dir, 'layers/*'),
                                       'dest': 'layers'},
                                      {'source': os.path.join(data_dir, 'pads'),
                                       'dest': '/'},
                                      {'source': os.path.join(data_dir, 'pads/*'),
                                       'dest': 'pads'},
                                      {'source': os.path.join(data_dir, 'pcb.wrl'),
                                       'dest': '/'},
                                      ]}}
        configure_and_run(tree, out_dir, ' - Creating the PCB3D ...')
        return out_name

    def solve_pcb3d(self):
        if isinstance(self.pcb3d, str):
            # An output creates it
            pcb3d_targets, _, pcb3d_out = get_output_targets(self.pcb3d, self._parent)
            pcb3d_file = pcb3d_targets[0]
            logger.debug('- From file '+pcb3d_file)
            if not pcb3d_out._done:
                logger.debug('-  Running '+self.pcb3d)
                run_output(pcb3d_out)
            self._pcb3d = PCB3DExportOptions()
            self._pcb3d.output = pcb3d_file
            # Needed by ensure tool
            self._pcb3d._parent = self._parent
        else:
            # We create it
            with TemporaryDirectory() as tmp_dir:
                # VRML
                self.create_vrml(tmp_dir)
                # SVG layers
                self.create_layers(tmp_dir)
                # Pads and bounds
                self.create_pads(tmp_dir)
                # Compress the files
                pcb3d_file = self.create_pcb3d(tmp_dir)
            self._pcb3d = self.pcb3d
            # Needed by ensure tool
            self.type = self._parent.type
        if not os.path.isfile(pcb3d_file):
            raise KiPlotConfigurationError('Missing '+pcb3d_file)
        return pcb3d_file

    def run(self, output):
        pcb3d_file = self.solve_pcb3d()
        # If no outputs specified just finish
        # Can be used to export the PCB to Blender
        if not self.outputs:
            return
        # Make sure Blender is available
        command = self._pcb3d.ensure_tool('Blender')
        if self.render_options.auto_crop:
            # Avoid a gradient
            self.render_options.background2 = self.render_options.background1
            convert_command = self.ensure_tool('ImageMagick')
        # Create a JSON with the scene information
        with NamedTemporaryFile(mode='w', suffix='.json') as f:
            scene = {}
            if self.light:
                lights = [{'name': light.name, 'position': (light.pos_x, light.pos_y, light.pos_z)} for light in self.light]
                scene['lights'] = lights
            if self.camera:
                ca = self.camera
                scene['camera'] = {'name': ca.name,
                                   'type': ca._type}
                if (hasattr(ca, '_pos_x_user_defined') or hasattr(ca, '_pos_y_user_defined') or
                   hasattr(ca, '_pos_z_user_defined')):
                    scene['camera']['position'] = (ca.pos_x, ca.pos_y, ca.pos_z)
            ro = self.render_options
            scene['render'] = {'samples': ro.samples,
                               'resolution_x': ro.resolution_x,
                               'resolution_y': ro.resolution_y,
                               'transparent_background': ro.transparent_background,
                               'background1': ro.background1,
                               'background2': ro.background2}
            povs = []
            for pov in self.point_of_view:
                povs.append({'rotate_x': -pov.rotate_x,
                             'rotate_y': -pov.rotate_y,
                             'rotate_z': -pov.rotate_z,
                             'view': pov.view})
            scene['point_of_view'] = povs
            text = json.dumps(scene, sort_keys=True, indent=2)
            logger.debug('Scene:\n'+text)
            f.write(text)
            f.flush()
            # Create the command line
            script = os.path.join(os.path.dirname(__file__), 'blender_scripts', 'blender_export.py')
            cmd = [command, '-b', '--factory-startup', '-P', script, '--']
            pi = self.pcb_import
            if not pi.components:
                cmd.append('--no_components')
            if not pi.cut_boards:
                cmd.append('--dont_cut_boards')
            if pi.texture_dpi != 1016.0:
                cmd.extend(['--texture_dpi', str(pi.texture_dpi)])
            if not pi.center:
                cmd.append('--dont_center')
            if not pi.enhance_materials:
                cmd.append('--dont_enhance_materials')
            if not pi.merge_materials:
                cmd.append('--dont_merge_materials')
            if pi.solder_joints != "SMART":
                cmd.extend(['--solder_joints', pi.solder_joints])
            if not pi.stack_boards:
                cmd.append('--dont_stack_boards')
            cmd.append('--format')
            for _ in self.point_of_view:
                for o in self.outputs:
                    cmd.append(o.type)
            cmd.append('--output')
            names = set()
            for pov in self.point_of_view:
                for o in self.outputs:
                    name = self.get_output_filename(o, self._parent.output_dir, pov)
                    if name in names:
                        raise KiPlotConfigurationError('Repeated name (use `file_id`): '+name)
                    cmd.append(name)
                    names.add(name)
            cmd.extend(['--scene', f.name])
            cmd.append(pcb3d_file)
            # Execute the command
            run_command(cmd)
        if self.render_options.auto_crop:
            for pov in self.point_of_view:
                for o in self.outputs:
                    if o.type != 'render':
                        continue
                    name = self.get_output_filename(o, self._parent.output_dir, pov)
                    run_command([convert_command, name, '-trim', '+repage', '-trim', '+repage', name])


@output_class
class Blender_Export(Base3D):
    """ Blender Export **Experimental**
        Exports the PCB in various 3D file formats.
        Also renders the PCB with high-quality.
        This output is complex to setup and needs very big dependencies.
        Please be patient when using it.
        You need Blender with the pcb2blender plug-in installed.
        Visit: [pcb2blender](https://github.com/30350n/pcb2blender).
        You can just generate the exported PCB if no output is specified.
        You can also export the PCB and render it at the same time """
    def __init__(self):
        super().__init__()
        with document:
            self.options = Blender_ExportOptions
            """ *[dict] Options for the `blender_export` output """
        self._category = 'PCB/3D'

    def get_dependencies(self):
        files = BaseOutput.get_dependencies(self)  # noqa: F821
        if isinstance(self.options.pcb3d, str):
            files.append(self.options.pcb3d)
        else:
            files.extend(self.options.pcb3d.list_models())
        return files

    def get_renderer_options(self):
        """ Where are the options for this output when used as a 'renderer' """
        ops = self.options
        out = next(filter(lambda x: x.type == 'render', ops.outputs), None)
        res = None
        if out is not None:
            if isinstance(ops.pcb3d, str):
                # We can't configure it
                out = None
            else:
                res = ops.pcb3d
                res._pov = ops.point_of_view[0]
                res._out = out
        return res if out is not None else None

    def get_extension(self):
        # Used when we are a renderer
        return 'png'

    @staticmethod
    def get_conf_examples(name, layers, templates):
        if not GS.check_tool(name, 'Blender'):
            return None
        has_top = False
        has_bottom = False
        for la in layers:
            if la.is_top() or la.layer.startswith('F.'):
                has_top = True
            elif la.is_bottom() or la.layer.startswith('B.'):
                has_bottom = True
        if not has_top and not has_bottom:
            return None
        register_xmp_import('PCB2Blender_2_1')
        povs = []
        if has_top:
            povs.append({'view': 'top'})
            povs.append({'rotate_x': 30, 'rotate_z': -20, 'file_id': '_30deg'})
        if has_bottom:
            povs.append({'view': 'bottom'})
        gb = {}
        gb['name'] = 'basic_{}'.format(name)
        gb['comment'] = '3D view from top/30 deg/bottom (Blender)'
        gb['type'] = name
        gb['dir'] = '3D'
        gb['options'] = {'pcb3d': '_PCB2Blender_2_1',
                         'outputs': [{'type': 'render'}, {'type': 'blender'}],
                         'point_of_view': povs}
        return [gb]
