#Author-Peter Ludikar, Gary Singer
#Description-An Add-In for making dog-bone fillets.

# Peter completely revamped the dogbone add-in by Casey Rogers and Patrick Rainsberry and David Liu
# Some of the original utilities have remained, but a lot of the other functionality has changed.

# The original add-in was based on creating sketch points and extruding - Peter found using sketches and extrusion to be very heavy
# on processing resources, so this version has been designed to create dogbones directly by using a hole tool. So far the
# the performance of this approach is day and night compared to the original version.

# Select the face you want the dogbones to drop from. Specify a tool diameter and a radial offset.
# The add-in will then create a dogbone with diamater equal to the tool diameter plus
# twice the offset (as the offset is applied to the radius) at each selected edge.

import logging

from collections import defaultdict

import adsk.core, adsk.fusion
import math
import traceback
import os
import json

import time
from . import dbutils as dbUtils
from math import sqrt as sqrt

#constants - to keep attribute group and names consistent
DOGBONEGROUP = 'dogBoneGroup'
FACE_ID = 'faceID'
REV_ID = 'revId'
ID = 'id'
DEBUGLEVEL = logging.NOTSET

# Generate an edgeId or faceId from object
calcId = lambda x: str(x.tempId) + ':' + x.assemblyContext.name.split(':')[-1] if x.assemblyContext else str(x.tempId) + ':' + x.body.name
makeNative = lambda x: x.nativeObject if x.nativeObject else x
reValidateFace = lambda comp, x: comp.findBRepUsingPoint(x, adsk.fusion.BRepEntityTypes.BRepFaceEntityType,-1.0 ,False ).item(0)

class SelectedEdge:
    def __init__(self, edge, edgeId, activeEdgeName, tempId, selectedFace):
        self.edge = edge
        self.edgeId = edgeId
        self.activeEdgeName = activeEdgeName
        self.tempId = tempId
        self.selected = True
        self.selectedFace = selectedFace

    def select(self, selection = True):
        self.selected = selection

class SelectedFace:
    def __init__(self, dog, face, faceId, tempId, occurrenceName, refPoint, commandInputsEdgeSelect):
        self.dog = dog
        self.face = face # BrepFace
        self.faceId = faceId
        self.tempId = tempId
        self.occurrenceName = occurrenceName
        self.refPoint = refPoint
        self.commandInputsEdgeSelect = commandInputsEdgeSelect
        self.selected = True
        self.selectedEdges = {} # Keyed with edge
        self.brepEdges = [] # used for quick checking if an edge is already included (below)

        #==============================================================================
        #             this is where inside corner edges, dropping down from the face are processed
        #==============================================================================
        faceNormal = dbUtils.getFaceNormal(face)

        for edge in self.face.body.edges:
                if edge.isDegenerate:
                    continue
                if edge in self.brepEdges:
                    continue
                try:
                    if edge.geometry.curveType != adsk.core.Curve3DTypes.Line3DCurveType:
                        continue
                    vector = edge.startVertex.geometry.vectorTo(edge.endVertex.geometry)
                    if vector.isPerpendicularTo(faceNormal):
                        continue
                    if edge.faces.item(0).geometry.objectType != adsk.core.Plane.classType():
                        continue
                    if edge.faces.item(1).geometry.objectType != adsk.core.Plane.classType():
                        continue
                    if edge.startVertex not in face.vertices:
                        if edge.endVertex not in face.vertices:
                            continue
                        else:
                            vector = edge.endVertex.geometry.vectorTo(edge.startVertex.geometry)
                    if vector.dotProduct(faceNormal) >= 0:
                        continue
                    if dbUtils.getAngleBetweenFaces(edge) > math.pi:
                        continue

                    activeEdgeName = edge.assemblyContext.name.split(':')[-1] if edge.assemblyContext else edge.body.name
                    edgeId = str(edge.tempId)+':'+ activeEdgeName
                    self.selectedEdges[edgeId] = SelectedEdge(edge, edgeId, activeEdgeName, edge.tempId, self)
                    self.brepEdges.append(edge)
                    dog.addingEdges = True
                    self.commandInputsEdgeSelect.addSelection(edge)
                    dog.addingEdges = False

                    dog.selectedEdges[edgeId] = self.selectedEdges[edgeId] # can be used for reverse lookup of edge to face
                except:
                    dbUtils.messageBox('Failed at edge:\n{}'.format(traceback.format_exc()))

    def selectAll(self, selection = True):
        self.selected = selection
        dog.addingEdges = True
        for edgeId, selectedEdge in self.selectedEdges.items():
            selectedEdge.select(selection)
            if selection:
                #commandInputsEdgeSelect.addSelection(edge.edge) # Not working for re-adding.
                dog.ui.activeSelections.add(selectedEdge.edge)

            else:
                dog.ui.activeSelections.removeByEntity(selectedEdge.edge)
        dog.addingEdges = False

class DogboneCommand(object):
    COMMAND_ID = "dogboneBtn"

    faceAssociations = {}
    defaultData = {}

    def __init__(self):
        self.app = adsk.core.Application.get()
        self.ui = self.app.userInterface

        self.offStr = "0"
        self.offVal = None
        self.circStr = "0.25 in"
        self.circVal = None
        self.edges = []
        self.benchmark = False
        self.errorCount = 0
#        self.boneDirection = "top"
        self.dbType = 'Normal Dogbone'
        self.longside = True
        self.minimalPercent = 10.0
        self.faceSelections = adsk.core.ObjectCollection.create()
        self.fromTop = False

        self.addingEdges = 0
        self.parametric = True
        self.logging = 0
        self.loggingLevels = {'Notset':0,'Debug':10,'Info':20,'Warning':30,'Error':40}

        self.expandModeGroup = True
        self.expandSettingsGroup = False
#        self.loggingLevelsLookUp = {self.loggingLevels[k]:k for k in self.loggingLevels}
        self.levels = {}

        self.handlers = dbUtils.HandlerHelper()

        self.appPath = os.path.dirname(os.path.abspath(__file__))

    def writeDefaults(self):
        self.logger.info('config file write')

        self.defaultData['offStr'] = self.offStr
        self.defaultData['offVal'] = self.offVal
        self.defaultData['circStr'] = self.circStr
        self.defaultData['circVal'] = self.circVal
            #self.defaultData['!outputUnconstrainedGeometry:' = str(self.outputUnconstrainedGeometry))
        self.defaultData['benchmark'] = self.benchmark
#        self.defaultData['boneDirection'] = self.boneDirection
        self.defaultData['dbType'] = self.dbType
        self.defaultData['minimalPercent'] = self.minimalPercent
        self.defaultData['fromTop'] = self.fromTop
        self.defaultData['parametric'] = self.parametric
        self.defaultData['logging'] = self.logging
        self.defaultData['mortiseType'] = self.longside
        self.defaultData['expandModeGroup'] = self.expandModeGroup
        self.defaultData['expandSettingsGroup'] = self.expandSettingsGroup

        json_file = open(os.path.join(self.appPath, 'defaults.dat'), 'w', encoding='UTF-8')
        json.dump(self.defaultData, json_file, ensure_ascii=False)
        json_file.close()
            #file.write('!limitParticipation:' = str(self.limitParticipation))
            #file.write('!minimumAngle:' = str(self.minimumAngle))
            #file.write('!maximumAngle:' = str(self.maximumAngle))

    def readDefaults(self):
#        self.logger.info('config file read')
        if not os.path.isfile(os.path.join(self.appPath, 'defaults.dat')):
            return
        json_file = open(os.path.join(self.appPath, 'defaults.dat'), 'r', encoding='UTF-8')
        try:
            self.defaultData = json.load(json_file)
        except ValueError:
            self.logger.error('default.dat error')
            json_file.close()
            json_file = open(os.path.join(self.appPath, 'defaults.dat'), 'w', encoding='UTF-8')
            json.dump(self.defaultData, json_file, ensure_ascii=False)
            return

        json_file.close()
        try:
            self.offStr = self.defaultData['offStr']
            self.offVal = self.defaultData['offVal']
            self.circStr = self.defaultData['circStr']
            self.circVal = self.defaultData['circVal']
                #elif var == 'outputUnconstrainedGeometry': self.outputUnconstrainedGeometry = val == 'True'
            self.benchmark = self.defaultData['benchmark']
#            self.boneDirection = self.defaultData['boneDirection']
            self.dbType = self.defaultData['dbType']
            self.minimalPercent = self.defaultData['minimalPercent']
            self.fromTop = self.defaultData['fromTop']
            self.parametric = self.defaultData['parametric']
            self.logging = self.defaultData['logging']
            self.longside = self.defaultData['mortiseType']
            self.expandModeGroup = self.defaultData['expandModeGroup']
            self.expandSettingsGroup = self.defaultData['expandSettingsGroup']

        except KeyError:

#            self.logger.error('Key error on read config file')
        #if there's a keyError - means file is corrupted - so, rewrite it with known existing defaultData - it will result in a valid dict,
        # but contents may have extra, superfluous  data
            json_file = open(os.path.join(self.appPath, 'defaults.dat'), 'w', encoding='UTF-8')
            json.dump(self.defaultData, json_file, ensure_ascii=False)
            json_file.close()
            return

    def debugFace(self, face):
        if self.logger.level < logging.DEBUG:
            return
        for edge in face.edges:
            self.logger.debug('edge {}; startVertex: {}; endVertex: {}'.format(edge.tempId, edge.startVertex.geometry.asArray(), edge.endVertex.geometry.asArray()))

        return

    def addButton(self):
        # clean up any crashed instances of the button if existing
        try:
            self.removeButton()
        except:
            pass

        # add add-in to UI
        buttonDogbone = self.ui.commandDefinitions.addButtonDefinition(
            self.COMMAND_ID, 'Dogbone', 'Creates dogbones at all inside corners of a face', 'Resources')

        buttonDogbone.commandCreated.add(self.handlers.make_handler(adsk.core.CommandCreatedEventHandler,
                                                                    self.onCreate))

        createPanel = self.ui.allToolbarPanels.itemById('SolidCreatePanel')
        buttonControl = createPanel.controls.addCommand(buttonDogbone, 'dogboneBtn')

        # Make the button available in the panel.
        buttonControl.isPromotedByDefault = True
        buttonControl.isPromoted = True

    def removeButton(self):
        cmdDef = self.ui.commandDefinitions.itemById(self.COMMAND_ID)
        if cmdDef:
            cmdDef.deleteMe()
        createPanel = self.ui.allToolbarPanels.itemById('SolidCreatePanel')
        cntrl = createPanel.controls.itemById(self.COMMAND_ID)
        if cntrl:
            cntrl.deleteMe()

    def onCreate(self, args:adsk.core.CommandCreatedEventArgs):
        """
        important persistent variables:
        self.selectedOccurrences  - Lookup dictionary
        key: activeOccurrenceName
        value: list of selectedFaces
            provides a quick lookup relationship between each occurrence and in particular which faces have been selected.
            The 1st selected face in the list is always the primary face

        self.selectedFaces - Lookup dictionary
        key: faceId = str(face tempId:occurrenceNumber)
        value: [BrepFace, objectCollection of edges, reference point on nativeObject Face]
            provides fast method of getting Brep entities associated with a faceId

        self.selectedEdges - reverse lookup
        key: edgeId = str(edgeId:occurrenceNumber)
        value: str(face tempId:occurrenceNumber)
            provides fast method of finding face that owns an edge
        """
        inputs = adsk.core.CommandCreatedEventArgs.cast(args)
        self.faces = []
        self.errorCount = 0
        self.faceSelections.clear()

        self.selectedOccurrences = {}
        self.selectedFaces = {}
        self.selectedEdges = {}

        argsCmd = adsk.core.Command.cast(args)

        self.readDefaults()

        inputs = adsk.core.CommandInputs.cast(inputs.command.commandInputs)

        selInput0 = inputs.addSelectionInput(
            'select', 'Face',
            'Select a face to apply dogbones to all internal corner edges')
        selInput0.tooltip ='Select a face to apply dogbones to all internal corner edges\n*** Select faces by clicking on them. DO NOT DRAG SELECT! ***'
#        selInput0.addSelectionFilter('LinearEdges')
        selInput0.addSelectionFilter('PlanarFaces')
        selInput0.setSelectionLimits(1,0)

        selInput1 = inputs.addSelectionInput(
            'edgeSelect', 'DogBone Edges',
            'Select or de-select any internal edges dropping down from a selected face (to apply dogbones to')
#        selInput0.addSelectionFilter('LinearEdges')
        selInput1.tooltip ='Select or de-select any internal edges dropping down from a selected face (to apply dogbones to)'
        selInput1.addSelectionFilter('LinearEdges')
        selInput1.setSelectionLimits(1,0)
        selInput1.isVisible = False

        inp = inputs.addValueInput(
            'circDiameter', 'Tool Diameter               ', self.design.unitsManager.defaultLengthUnits,
            adsk.core.ValueInput.createByString(self.circStr))
        inp.tooltip = "Size of the tool with which you'll cut the dogbone."

        offsetInp = inputs.addValueInput(
            'offset', 'Tool diameter offset', self.design.unitsManager.defaultLengthUnits,
            adsk.core.ValueInput.createByString(self.offStr))
        offsetInp.tooltip = "Increases the tool diameter"
        offsetInp.tooltipDescription = "Use this to create an oversized dogbone.\n"\
                                        "Normally set to 0.  \n"\
                                        "A value of .010 would increase the dogbone diameter by .010 \n"\
                                        "Used when you want to keep the tool diameter and oversize value separate"

        modeGroup = adsk.core.GroupCommandInput.cast(inputs.addGroupCommandInput('modeGroup', 'Mode'))
        modeGroup.isExpanded = self.expandModeGroup
        modeGroupChildInputs = modeGroup.children

        modeRowInput = adsk.core.ButtonRowCommandInput.cast(modeGroupChildInputs.addButtonRowCommandInput('modeRow', 'Mode', False))
        modeRowInput.listItems.add('Static', not self.parametric, 'resources/staticMode' )
        modeRowInput.listItems.add('Parametric', self.parametric, 'resources/parametricMode' )
        modeRowInput.tooltipDescription = "Static dogbones do not move with the underlying component geometry. \n" \
                                "\nParametric dogbones will automatically adjust position with parametric changes to underlying geometry. " \
                                "Geometry changes must be made via the parametric dialog.\nFusion has more issues/bugs with these!"

        typeRowInput = adsk.core.ButtonRowCommandInput.cast(modeGroupChildInputs.addButtonRowCommandInput('dogboneType', 'Type', False))
        typeRowInput.listItems.add('Normal Dogbone', self.dbType == 'Normal Dogbone', 'resources/normal' )
        typeRowInput.listItems.add('Minimal Dogbone', self.dbType == 'Minimal Dogbone', 'resources/minimal' )
        typeRowInput.listItems.add('Mortise Dogbone', self.dbType == 'Mortise Dogbone', 'resources/hidden' )
        typeRowInput.tooltipDescription = "Minimal dogbones creates visually less prominent dogbones, but results in an interference fit " \
                                            "that, for example, will require a larger force to insert a tenon into a mortise.\n" \
                                            "\nMortise dogbones create dogbones on the shortest sides, or the longest sides.\n" \
                                            "A piece with a tenon can be used to hide them if they're not cut all the way through the workpiece."

        mortiseRowInput = adsk.core.ButtonRowCommandInput.cast(modeGroupChildInputs.addButtonRowCommandInput('mortiseType', 'Mortise Type', False))
        mortiseRowInput.listItems.add('On Long Side', self.longside, 'resources/hidden/longside' )
        mortiseRowInput.listItems.add('On Short Side', not self.longside, 'resources/hidden/shortside' )
        mortiseRowInput.tooltipDescription = "On Long Side will have the dogbones cut into the longer sides.\n" \
                                             "\nOn Short Side will have the dogbones cut into the shorter sides."
        mortiseRowInput.isVisible = self.dbType == 'Mortise Dogbone'

        minPercentInp = modeGroupChildInputs.addValueInput(
            'minimalPercent', 'Percentage Reduction', '',
            adsk.core.ValueInput.createByReal(self.minimalPercent))
        minPercentInp.tooltip = "Percentage of tool radius added to dogBone offset."
        minPercentInp.tooltipDescription = "This should typically be left at 10%, but if the fit is too tight, it should be reduced"
        minPercentInp.isVisible = self.dbType == 'Minimal Dogbone'

        depthRowInput = adsk.core.ButtonRowCommandInput.cast(modeGroupChildInputs.addButtonRowCommandInput('depthExtent', 'Depth Extent', False))
        depthRowInput.listItems.add('From Selected Face', not self.fromTop, 'resources/fromFace' )
        depthRowInput.listItems.add('From Top Face', self.fromTop, 'resources/fromTop' )
        depthRowInput.tooltipDescription = "When \"From Top Face\" is selected, all dogbones will be extended to the top most face\n"\
                                            "\nThis is typically chosen when you don't want to, or can't do, double sided machining."

        settingGroup = adsk.core.GroupCommandInput.cast(inputs.addGroupCommandInput('settingsGroup', 'Settings'))
        settingGroup.isExpanded = self.expandSettingsGroup
        settingGroupChildInputs = settingGroup.children

        benchMark = settingGroupChildInputs.addBoolValueInput("benchmark", "Benchmark time", True, "", self.benchmark)
        benchMark.tooltip = "Enables benchmarking"
        benchMark.tooltipDescription = "When enabled, shows overall time taken to process all selected dogbones."

        logDropDownInp = adsk.core.DropDownCommandInput.cast(settingGroupChildInputs.addDropDownCommandInput("logging", "Logging level", adsk.core.DropDownStyles.TextListDropDownStyle))
        logDropDownInp.tooltip = "Enables logging"
        logDropDownInp.tooltipDescription = "Creates a dogbone.log file. \n" \
                     "Location: " +  os.path.join(self.appPath, 'dogBone.log')

        logDropDownInp.listItems.add('Notset', self.logging == 0)
        logDropDownInp.listItems.add('Debug', self.logging == 10)
        logDropDownInp.listItems.add('Info', self.logging == 20)

        cmd = adsk.core.Command.cast(args.command)
        # Add handlers to this command.
        cmd.execute.add(self.handlers.make_handler(adsk.core.CommandEventHandler, self.onExecute))
        cmd.selectionEvent.add(self.handlers.make_handler(adsk.core.SelectionEventHandler, self.onFaceSelect))
        cmd.validateInputs.add(
            self.handlers.make_handler(adsk.core.ValidateInputsEventHandler, self.onValidate))
        cmd.inputChanged.add(
            self.handlers.make_handler(adsk.core.InputChangedEventHandler, self.onChange))

    #==============================================================================
    #  routine to process any changed selections
    #  this is where selection and deselection management takes place
    #  also where eligible edges are determined
    #==============================================================================
    def onChange(self, args:adsk.core.InputChangedEventArgs):

        changedInput = adsk.core.CommandInput.cast(args.input)
#        self.logger.debug('input changed- {}'.format(changedInput.id))

        if changedInput.id == 'dogboneType':
            changedInput.commandInputs.itemById('minimalPercent').isVisible = (changedInput.commandInputs.itemById('dogboneType').selectedItem.name == 'Minimal Dogbone')
            changedInput.commandInputs.itemById('mortiseType').isVisible = (changedInput.commandInputs.itemById('dogboneType').selectedItem.name == 'Mortise Dogbone')

        if changedInput.id != 'select' and changedInput.id != 'edgeSelect':
            return

#        self.logger.debug('input changed- {}'.format(changedInput.id))
        if changedInput.id == 'select':

            #==============================================================================
            #            processing changes to face selections
            #==============================================================================
            numSelectedFaces = sum(1 for face in self.selectedFaces.values() if face.selected)
            if numSelectedFaces > changedInput.selectionCount:
                # a face has been removed

                # If all faces are removed, just iterate through all
                if changedInput.selectionCount == 0:
                    for face in self.selectedFaces.values():
                        if face.selected:
                            face.selectAll(False)
                    changedInput.commandInputs.itemById('edgeSelect').clearSelection()
                    changedInput.commandInputs.itemById('select').hasFocus = True
                    changedInput.commandInputs.itemById('edgeSelect').isVisible = False
                    return

                # Else find the missing face in selection
                selectionList = [changedInput.selection(i).entity.tempId for i in range(changedInput.selectionCount)]
                missingFace = {k for k, v in self.selectedFaces.items() if v.selected and v.tempId not in selectionList}.pop()
                changedInput.commandInputs.itemById('edgeSelect').hasFocus = True
                self.selectedFaces[missingFace].selectAll(False)

                changedInput.commandInputs.itemById('select').hasFocus = True
                return

            #==============================================================================
            #             Face has been added - assume that the last selection entity is the one added
            #==============================================================================
            face = adsk.fusion.BRepFace.cast(changedInput.selection(changedInput.selectionCount -1).entity)
            changedInput.commandInputs.itemById('edgeSelect').isVisible = True

            changedEntity = face #changedInput.selection(changedInput.selectionCount-1).entity
            if changedEntity.assemblyContext:
                activeOccurrenceName = changedEntity.assemblyContext.name
            else:
                activeOccurrenceName = changedEntity.body.name

            if changedInput.selection(changedInput.selectionCount-1).entity.assemblyContext:
                changedEntityName = changedInput.selection(changedInput.selectionCount-1).entity.assemblyContext.name.split(':')[-1]
            else:
                changedEntityName = changedEntity.body.name

            faceId = str(changedEntity.tempId) + ":" + changedEntityName
            if faceId in self.selectedFaces :
                changedInput.commandInputs.itemById('edgeSelect').hasFocus = True
                self.selectedFaces[faceId].selectAll(True)
                changedInput.commandInputs.itemById('select').hasFocus = True
                return
            newSelectedFace = SelectedFace(
                                            self,
                                            face,
                                            faceId,
                                            changedEntity.tempId,
                                            changedEntityName,
                                            face.nativeObject.pointOnFace if face.assemblyContext else face.pointOnFace,
                                            changedInput.commandInputs.itemById('edgeSelect')
                                          )  # creates a collecton (of edges) associated with a faceId
            faces = []
            faces = self.selectedOccurrences.get(activeOccurrenceName, faces)
            faces.append(newSelectedFace)
            self.selectedOccurrences[activeOccurrenceName] = faces # adds a face to a list of faces associated with this occurrence
            self.selectedFaces[faceId] = newSelectedFace

                 #end of processing faces
        #==============================================================================
        #         Processing changed edge selection
        #==============================================================================
        if changedInput.id != 'edgeSelect':
            return

        if sum(1 for edge in self.selectedEdges.values() if edge.selected) > changedInput.selectionCount:
            #==============================================================================
            #             an edge has been removed
            #==============================================================================

            changedSelectionList = [changedInput.selection(i).entity for i in range(changedInput.selectionCount)]
            changedEdgeIdSet = set(map(calcId, changedSelectionList))  # converts list of edges to a list of their edgeIds
            missingEdges = (set(self.selectedEdges.keys()) - changedEdgeIdSet)
            for missingEdge in missingEdges:
                self.selectedEdges[missingEdge].select(False)
            # Note - let the user manually unselect the face if they want to choose a different face

            return
            # End of processing removed edge
        else:
            #==============================================================================
            #         Start of adding a selected edge
            #         Edge has been added - assume that the last selection entity is the one added
            #==============================================================================
            edge = adsk.fusion.BRepEdge.cast(changedInput.selection(changedInput.selectionCount - 1).entity)
            self.selectedEdges[calcId(edge)].select() # Get selectedFace then get selectedEdge, then call function

    def parseInputs(self, inputs):
        '''==============================================================================
           put the selections into variables that can be accessed by the main routine
           ==============================================================================
       '''
        inputs = {inp.id: inp for inp in inputs}

        self.logging = self.loggingLevels[inputs['logging'].selectedItem.name]
        self.logHandler.setLevel(self.logging)

        self.logger.debug('Parsing inputs')

        self.circStr = inputs['circDiameter'].expression
        self.circVal = inputs['circDiameter'].value
        self.offStr = inputs['offset'].expression
        self.offVal = inputs['offset'].value
        self.benchmark = inputs['benchmark'].value
        self.dbType = inputs['dogboneType'].selectedItem.name
        self.minimalPercent = inputs['minimalPercent'].value
        self.fromTop = (inputs['depthExtent'].selectedItem.name == 'From Top Face')
        self.parametric = (inputs['modeRow'].selectedItem.name == 'Parametric')
        self.longside = (inputs['mortiseType'].selectedItem.name == 'On Long Side')
        self.expandModeGroup = (inputs['modeGroup']).isExpanded
        self.expandSettingsGroup = (inputs['settingsGroup']).isExpanded

        self.logger.debug('self.fromTop = {}'.format(self.fromTop))
        self.logger.debug('self.dbType = {}'.format(self.dbType))
        self.logger.debug('self.parametric = {}'.format(self.parametric))
        self.logger.debug('self.circStr = {}'.format(self.circStr))
        self.logger.debug('self.circDiameter = {}'.format(self.circVal))
        self.logger.debug('self.offStr = {}'.format(self.offStr))
        self.logger.debug('self.offVal = {}'.format(self.offVal))
        self.logger.debug('self.benchmark = {}'.format(self.benchmark))
        self.logger.debug('self.mortiseType = {}'.format(self.longside))
        self.logger.debug('self.expandModeGroup = {}'.format(self.expandModeGroup))
        self.logger.debug('self.expandSettingsGroup = {}'.format(self.expandSettingsGroup))

        self.edges = []
        self.faces = []

        for i in range(inputs['edgeSelect'].selectionCount):
            entity = inputs['edgeSelect'].selection(i).entity
            if entity.objectType == adsk.fusion.BRepEdge.classType():
                self.edges.append(entity)
        for i in range(inputs['select'].selectionCount):
            entity = inputs['select'].selection(i).entity
            if entity.objectType == adsk.fusion.BRepFace.classType():
                self.faces.append(entity)

    def initLogger(self):
        self.logger = logging.getLogger(__name__)
        self.formatter = logging.Formatter('%(asctime)s ; %(name)s ; %(levelname)s ; %(lineno)d; %(message)s')
#        if not os.path.isfile(os.path.join(self.appPath, 'dogBone.log')):
#            return
        self.logHandler = logging.FileHandler(os.path.join(self.appPath, 'dogbone.log'), mode='w')
        self.logHandler.setFormatter(self.formatter)
        self.logHandler.flush()
        self.logger.addHandler(self.logHandler)

    def closeLogger(self):
        for handler in self.logger.handlers:
            handler.flush()
            handler.close()

    def onExecute(self, args):
        start = time.time()

        self.initLogger()
        self.logger.log(0, 'logging Level = %(levelname)')
        self.parseInputs(args.firingEvent.sender.commandInputs)
        self.logHandler.setLevel(self.logging)
        self.logger.setLevel(self.logging)

        self.writeDefaults()

        if self.parametric:
            userParams = adsk.fusion.UserParameters.cast(self.design.userParameters)

            #set up parameters, so that changes can be easily made after dogbones have been inserted
            if not userParams.itemByName('dbToolDia'):
                dValIn = adsk.core.ValueInput.createByString(self.circStr)
                dParameter = userParams.add('dbToolDia', dValIn, self.design.unitsManager.defaultLengthUnits, '')
                dParameter.isFavorite = True
            else:
                uParam = userParams.itemByName('dbToolDia')
                uParam.expression = self.circStr
                uParam.isFavorite = True

            if not userParams.itemByName('dbOffset'):
                rValIn = adsk.core.ValueInput.createByString(self.offStr)
                rParameter = userParams.add('dbOffset',rValIn, self.design.unitsManager.defaultLengthUnits, 'Do NOT change formula')
            else:
                uParam = userParams.itemByName('dbOffset')
                uParam.expression = self.offStr
                uParam.comment = 'Do NOT change formula'

            if not userParams.itemByName('dbRadius'):
                rValIn = adsk.core.ValueInput.createByString('(dbToolDia + dbOffset)/2')
                rParameter = userParams.add('dbRadius',rValIn, self.design.unitsManager.defaultLengthUnits, 'Do NOT change formula')
            else:
                uParam = userParams.itemByName('dbRadius')
                uParam.expression = '(dbToolDia + dbOffset)/2'
                uParam.comment = 'Do NOT change formula'

            if not userParams.itemByName('dbMinPercent'):
                rValIn = adsk.core.ValueInput.createByReal(self.minimalPercent)
                rParameter = userParams.add('dbMinPercent',rValIn, '', '')
                rParameter.isFavorite = True
            else:
                uParam = userParams.itemByName('dbMinPercent')
                uParam.value = self.minimalPercent
                uParam.comment = ''
                uParam.isFavorite = True

            if not userParams.itemByName('dbHoleOffset'):
                oValIn = adsk.core.ValueInput.createByString('dbRadius / sqrt(2)' + (' * (1 + dbMinPercent/100)') if self.dbType == 'Minimal Dogbone' else 'dbRadius' if self.dbType == 'Mortise Dogbone' else 'dbRadius / sqrt(2)')
                oParameter = userParams.add('dbHoleOffset', oValIn, self.design.unitsManager.defaultLengthUnits, 'Do NOT change formula')
            else:
                uParam = userParams.itemByName('dbHoleOffset')
                uParam.expression = 'dbRadius / sqrt(2)' + (' * (1 + dbMinPercent/100)') if self.dbType == 'Minimal Dogbone' else 'dbRadius' if self.dbType == 'Mortise Dogbone' else 'dbRadius / sqrt(2)'
                uParam.comment = 'Do NOT change formula'

            self.radius = userParams.itemByName('dbRadius').value
            self.offset = adsk.core.ValueInput.createByString('dbOffset')
            self.offset = adsk.core.ValueInput.createByReal(userParams.itemByName('dbHoleOffset').value)

            self.createParametricDogbones()

        else: #Static dogbones

            self.radius = (self.circVal + self.offVal) / 2
            self.offset = self.radius / sqrt(2)  * (1 + self.minimalPercent/100) if self.dbType == 'Minimal Dogbone' else self.radius if self.dbType == 'Mortise Dogbone' else self.radius / sqrt(2)

            self.createStaticDogbones()

        self.logger.info('all dogbones complete\n-------------------------------------------\n')

        self.closeLogger()

        if self.benchmark:
            dbUtils.messageBox("Benchmark: {:.02f} sec processing {} edges".format(
                time.time() - start, len(self.edges)))

    ################################################################################
    def onValidate(self, args):
        cmd = adsk.core.ValidateInputsEventArgs.cast(args)
        cmd = args.firingEvent.sender

        for input in cmd.commandInputs:
            if input.id == 'select':
                if input.selectionCount < 1:
                    args.areInputsValid = False
            elif input.id == 'circDiameter':
                if input.value <= 0:
                    args.areInputsValid = False
    def onFaceSelect(self, args):
        '''==============================================================================
            Routine gets called with every mouse movement, if a commandInput select is active
           ==============================================================================
       '''
        eventArgs = adsk.core.SelectionEventArgs.cast(args)
        # Check which selection input the event is firing for.
        activeIn = eventArgs.firingEvent.activeInput
        if activeIn.id != 'select' and activeIn.id != 'edgeSelect':
            return # jump out if not dealing with either of the two selection boxes

        if activeIn.id == 'select':
            #==============================================================================
            # processing activities when faces are being selected
            #        selection filter is limited to planar faces
            #        makes sure only valid occurrences and components are selectable
            #==============================================================================

            if not len( self.selectedOccurrences ): #get out if the face selection list is empty
                eventArgs.isSelectable = True
                return
            if not eventArgs.selection.entity.assemblyContext:
                # dealing with a root component body

                activeBodyName = eventArgs.selection.entity.body.name
                try:
                    faces = self.selectedOccurrences[activeBodyName]
                    for face in faces:
                        if face.selected:
                            primaryFace = face
                            break
                    else:
                        eventArgs.isSelectable = True
                        return
                except (KeyError, IndexError) as e:
                    return

                primaryFaceNormal = dbUtils.getFaceNormal(primaryFace.face)
                if primaryFaceNormal.isParallelTo(dbUtils.getFaceNormal(eventArgs.selection.entity)):
                    eventArgs.isSelectable = True
                    return
                eventArgs.isSelectable = False
                return
            # End of root component face processing

            #==============================================================================
            # Start of occurrence face processing
            #==============================================================================
            activeOccurrence = eventArgs.selection.entity.assemblyContext
            activeOccurrenceName = activeOccurrence.name
            activeComponent = activeOccurrence.component

            # we got here because the face is either not in root or is on the existing selected list
            # at this point only need to check for duplicate component selection - Only one component allowed, to save on conflict checking
            try:
                selectedComponentList = [x[0].face.assemblyContext.component for x in self.selectedOccurrences.values() if x[0].face.assemblyContext]
            except KeyError:
               eventArgs.isSelectable = True
               return

            if activeComponent not in selectedComponentList:
                    eventArgs.isSelectable = True
                    return

            if activeOccurrenceName not in self.selectedOccurrences:  #check if mouse is over a face that is not already selected
                eventArgs.isSelectable = False
                return

            try:
                faces = self.selectedOccurrences[activeOccurrenceName]
                for face in faces:
                    if face.selected:
                        primaryFace = face
                        break
                else:
                    eventArgs.isSelectable = True
                    return
            except KeyError:
                return
            primaryFaceNormal = dbUtils.getFaceNormal(primaryFace.face)
            if primaryFaceNormal.isParallelTo(dbUtils.getFaceNormal(eventArgs.selection.entity)):
                eventArgs.isSelectable = True
                return
            eventArgs.isSelectable = False
            return
            # end selecting faces

        else:
            #==============================================================================
            #             processing edges associated with face - edges selection has focus
            #==============================================================================
            if self.addingEdges:
                return
            selected = eventArgs.selection
            currentEdge = adsk.fusion.BRepEdge.cast(selected.entity)
            activeOccurrence = eventArgs.selection.entity.assemblyContext
            if eventArgs.selection.entity.assemblyContext:
                activeOccurrenceName = activeOccurrence.name
            else:
                activeOccurrenceName = eventArgs.selection.entity.body.name

            occurrenceNumber = activeOccurrenceName.split(':')[-1]
            edgeId = str(currentEdge.tempId) + ':' + occurrenceNumber
            if (edgeId in self.selectedEdges and self.selectedEdges[edgeId].selectedFace.selected):
                eventArgs.isSelectable = True
            else:
                eventArgs.isSelectable = False
            return

    @property
    def design(self):
        return self.app.activeProduct

    @property
    def rootComp(self):
        return self.design.rootComponent

    @property
    def originPlane(self):
        return self.rootComp.xZConstructionPlane if self.yUp else self.rootComp.xYConstructionPlane

    # The main algorithm for parametric dogbones
    def createParametricDogbones(self):
        self.logger.info('Creating parametric dogbones')
        self.errorCount = 0
        if not self.design:
            raise RuntimeError('No active Fusion design')
        holeInput = adsk.fusion.HoleFeatureInput.cast(None)
        offsetByStr = adsk.core.ValueInput.createByString('dbHoleOffset')
        centreDistance = self.radius*(1+self.minimalPercent/100 if self.dbType=='Minimal Dogbone' else  1)

        for occurrenceFace in self.selectedOccurrences.values():
            startTlMarker = self.design.timeline.markerPosition

            if occurrenceFace[0].face.assemblyContext:
                comp = occurrenceFace[0].face.assemblyContext.component
                occ = occurrenceFace[0].face.assemblyContext
                self.logger.debug('processing component  = {}'.format(comp.name))
                self.logger.debug('processing occurrence  = {}'.format(occ.name))
                #entityName = occ.name.split(':')[-1]
            else:
               comp = self.rootComp
               occ = None
               self.logger.debug('processing Rootcomponent')

            if self.fromTop:
                (topFace, topFaceRefPoint) = dbUtils.getTopFace(makeNative(occurrenceFace[0].face))
                self.logger.info('Processing holes from top face - {}'.format(topFace.body.name))

            for selectedFace in occurrenceFace:
                if len(selectedFace.selectedEdges.values()) <1:
                    self.logger.debug('Face has no edges')
                face = makeNative(selectedFace.face)

                comp = adsk.fusion.Component.cast(comp)

                if not face.isValid:
                    self.logger.debug('revalidating Face')
                    face = reValidateFace(comp, selectedFace.refPoint)
                self.logger.debug('Processing Face = {}'.format(face.tempId))

                #faceNormal = dbUtils.getFaceNormal(face.nativeObject)
                if self.fromTop:
                    self.logger.debug('topFace type {}'.format(type(topFace)))
                    if not topFace.isValid:
                       self.logger.debug('revalidating topFace')
                       topFace = reValidateFace(comp, topFaceRefPoint)

                    topFace = makeNative(topFace)

                    self.logger.debug('topFace isValid = {}'.format(topFace.isValid))
                    transformVector = dbUtils.getTranslateVectorBetweenFaces(face, topFace)
                    self.logger.debug('creating transformVector to topFace = ({},{},{}) length = {}'.format(transformVector.x, transformVector.y, transformVector.z, transformVector.length))

                for selectedEdge in selectedFace.selectedEdges.values():

                    self.logger.debug('Processing edge - {}'.format(selectedEdge.edge.tempId))

                    if not selectedEdge.selected:
                        self.logger.debug('  Not selected. Skipping...')
                        continue

                    if not face.isValid:
                        self.logger.debug('Revalidating face')
                        face = reValidateFace(comp, selectedFace.refPoint)

                    if not selectedEdge.edge.isValid:
                        continue # edges that have been processed already will not be valid any more - at the moment this is easier than removing the
    #                    affected edge from self.edges after having been processed
                    edge = makeNative(selectedEdge.edge)
                    try:
                        if not dbUtils.isEdgeAssociatedWithFace(face, edge):
                            continue  # skip if edge is not associated with the face currently being processed
                    except:
                        pass

                    startVertex = adsk.fusion.BRepVertex.cast(dbUtils.getVertexAtFace(face, edge))
                    extentToEntity = dbUtils.findExtent(face, edge)

                    extentToEntity = makeNative(extentToEntity)
                    self.logger.debug('extentToEntity - {}'.format(extentToEntity.isValid))
                    if not extentToEntity.isValid:
                        self.logger.debug('To face invalid')

                    try:
                        (edge1, edge2) = dbUtils.getCornerEdgesAtFace(face, edge)
                    except:
                        self.logger.exception('Failed at findAdjecentFaceEdges')
                        dbUtils.messageBox('Failed at findAdjecentFaceEdges:\n{}'.format(traceback.format_exc()))

                    centrePoint = makeNative(startVertex).geometry.copy()

                    selectedEdgeFaces = makeNative(selectedEdge.edge).faces

                    dirVect = adsk.core.Vector3D.cast(dbUtils.getFaceNormal(selectedEdgeFaces[0]).copy())
                    dirVect.add(dbUtils.getFaceNormal(selectedEdgeFaces[1]))
                    dirVect.normalize()
                    dirVect.scaleBy(centreDistance)  #ideally radius should be linked to parameters,

                    if self.dbType == 'Mortise Dogbone':
                        direction0 = dbUtils.correctedEdgeVector(edge1,startVertex)
                        direction1 = dbUtils.correctedEdgeVector(edge2,startVertex)

                        if self.longside:
                            if (edge1.length > edge2.length):
                                dirVect = direction0
                                edge1OffsetByStr = adsk.core.ValueInput.createByReal(0)
                                edge2OffsetByStr = offsetByStr
                            else:
                                dirVect = direction1
                                edge2OffsetByStr = adsk.core.ValueInput.createByReal(0)
                                edge1OffsetByStr = offsetByStr
                        else:
                            if (edge1.length > edge2.length):
                                dirVect = direction1
                                edge2OffsetByStr = adsk.core.ValueInput.createByReal(0)
                                edge1OffsetByStr = offsetByStr
                            else:
                                dirVect = direction0
                                edge1OffsetByStr = adsk.core.ValueInput.createByReal(0)
                                edge2OffsetByStr = offsetByStr
                    else:
                        dirVect = adsk.core.Vector3D.cast(dbUtils.getFaceNormal(makeNative(selectedEdgeFaces[0])).copy())
                        dirVect.add(dbUtils.getFaceNormal(makeNative(selectedEdgeFaces[1])))
                        edge1OffsetByStr = offsetByStr
                        edge2OffsetByStr = offsetByStr

                    centrePoint.translateBy(dirVect)
                    self.logger.debug('centrePoint = ({},{},{})'.format(centrePoint.x, centrePoint.y, centrePoint.z))

                    if self.fromTop:
                        centrePoint.translateBy(transformVector)
                        self.logger.debug('centrePoint at topFace = {}'.format(centrePoint.asArray()))
                        holePlane = topFace if self.fromTop else face
                        if not holePlane.isValid:
                            holePlane = reValidateFace(comp, topFaceRefPoint)
                    else:
                        holePlane = makeNative(face)

                    holes =  comp.features.holeFeatures
                    holeInput = holes.createSimpleInput(adsk.core.ValueInput.createByString('dbRadius*2'))
#                    holeInput.creationOccurrence = occ #This needs to be uncommented once AD fixes component copy issue!!
                    holeInput.isDefaultDirection = True
                    holeInput.tipAngle = adsk.core.ValueInput.createByString('180 deg')
#                    holeInput.participantBodies = [face.nativeObject.body if occ else face.body]  #Restore this once AD fixes occurrence bugs
                    holeInput.participantBodies = [makeNative(face.body)]

                    self.logger.debug('extentToEntity before setPositionByPlaneAndOffsets - {}'.format(extentToEntity.isValid))
                    holeInput.setPositionByPlaneAndOffsets(holePlane, centrePoint, edge1, edge1OffsetByStr, edge2, edge2OffsetByStr)
                    self.logger.debug('extentToEntity after setPositionByPlaneAndOffsets - {}'.format(extentToEntity.isValid))
                    holeInput.setOneSideToExtent(extentToEntity, False)
                    self.logger.info('hole added to list - {}'.format(centrePoint.asArray()))

                    holes.add(holeInput)

            endTlMarker = self.design.timeline.markerPosition-1
            if endTlMarker - startTlMarker >0:
                timelineGroup = self.design.timeline.timelineGroups.add(startTlMarker,endTlMarker)
                timelineGroup.name = 'dogbone'
#            self.logger.debug('doEvents - allowing display to refresh')
#            adsk.doEvents()

        if self.errorCount >0:
            dbUtils.messageBox('Reported errors:{}\nYou may not need to do anything, \nbut check holes have been created'.format(self.errorCount))

    def createStaticDogbones(self):
        self.logger.info('Creating static dogbones')
        self.errorCount = 0
        if not self.design:
            raise RuntimeError('No active Fusion design')
        holeInput = adsk.fusion.HoleFeatureInput.cast(None)
        centreDistance = self.radius*(1+self.minimalPercent/100 if self.dbType == 'Minimal Dogbone' else  1)

        for occurrenceFace in self.selectedOccurrences.values():
            startTlMarker = self.design.timeline.markerPosition

            if occurrenceFace[0].face.assemblyContext:
                comp = occurrenceFace[0].face.assemblyContext.component
                occ = occurrenceFace[0].face.assemblyContext
                self.logger.info('processing component  = {}'.format(comp.name))
                self.logger.info('processing occurrence  = {}'.format(occ.name))
                #entityName = occ.name.split(':')[-1]
            else:
               comp = self.rootComp
               occ = None
               self.logger.info('processing Rootcomponent')

            if self.fromTop:
                (topFace, topFaceRefPoint) = dbUtils.getTopFace(makeNative(occurrenceFace[0].face))
                self.logger.debug('topFace ref point: {}'.format(topFaceRefPoint.asArray()))
                self.logger.info('Processing holes from top face - {}'.format(topFace.tempId))
                self.debugFace(topFace)

                sketch = adsk.fusion.Sketch.cast(comp.sketches.add(topFace))  #used for fault finding
                sketch.name = 'dogbone'
                sketch.isComputeDeferred = True
                self.logger.debug('Added topFace sketch - {}'.format(sketch.name))

            for selectedFace in occurrenceFace:
                if len(selectedFace.selectedEdges.values()) <1:
                    self.logger.debug('Face has no edges')
                    continue
                face = makeNative(selectedFace.face)

                if not face.isValid:
                    self.logger.debug('Revalidating face')
                    face = reValidateFace(comp, selectedFace.refPoint)
                    self.logger.info('Processing Face = {}'.format(face.tempId))
                    self.debugFace(face)

                self.logger.info('processing face - {}'.format(face.tempId))
                self.debugFace(face)
                holeList = []

                comp = adsk.fusion.Component.cast(comp)

                if self.fromTop:
                    self.logger.debug('topFace type {}'.format(type(topFace)))
                    if not topFace.isValid:
                       self.logger.debug('revalidating topFace')
                       topFace = reValidateFace(comp, topFaceRefPoint)
                       topFaceRefPoint = topFace.pointOnFace
                       self.debugFace(topFace)
                    self.logger.debug('topFace isValid = {}'.format(topFace.isValid))
                    transformVector = dbUtils.getTranslateVectorBetweenFaces(face, topFace)
                    self.logger.debug('creating transformVector to topFace = {} length = {}'.format(transformVector.asArray(), transformVector.length))
                else:
                    sketch = adsk.fusion.Sketch.cast(comp.sketches.add(face))
                    sketch.name = 'dogbone'
                    sketch.isComputeDeferred = True
                    self.logger.debug('creating face plane sketch - {}'.format(sketch.name))

                for selectedEdge in selectedFace.selectedEdges.values():

                    self.logger.debug('Processing edge - {}'.format(selectedEdge.edge.tempId))

                    if not selectedEdge.selected:
                        self.logger.debug('  Not selected. Skipping...')
                        continue

                    if not face.isValid:
                        self.logger.debug('Revalidating face')
                        face = reValidateFace(comp, selectedFace.refPoint)

                    if not selectedEdge.edge.isValid:
                        continue # edges that have been processed already will not be valid any more - at the moment this is easier than removing the
    #                    affected edge from self.edges after having been processed
                    try:
                        if not dbUtils.isEdgeAssociatedWithFace(face, makeNative(selectedEdge.edge)):
                            continue  # skip if edge is not associated with the face currently being processed
                    except:
                        pass

                    edge = makeNative(selectedEdge.edge)
                    startVertex = adsk.fusion.BRepVertex.cast(dbUtils.getVertexAtFace(face, edge))
                    centrePoint = startVertex.geometry.copy()

                    selectedEdgeFaces = edge.faces

                    if self.dbType == 'Mortise Dogbone':
                        (edge0, edge1) = dbUtils.getCornerEdgesAtFace(face, edge)
                        direction0 = dbUtils.correctedEdgeVector(edge0,startVertex)
                        direction1 = dbUtils.correctedEdgeVector(edge1,startVertex)
                        if self.longside:
                            if (edge0.length > edge1.length):
                                dirVect = direction0
                            else:
                                dirVect = direction1
                        else:
                            if (edge0.length > edge1.length):
                                dirVect = direction1
                            else:
                                dirVect = direction0
                    else:
                        dirVect = adsk.core.Vector3D.cast(dbUtils.getFaceNormal(makeNative(selectedEdgeFaces[0])).copy())
                        dirVect.add(dbUtils.getFaceNormal(makeNative(selectedEdgeFaces[1])))
                    dirVect.normalize()
                    dirVect.scaleBy(centreDistance)  #ideally radius should be linked to parameters,
                                                          # but hole start point still is the right quadrant
                    centrePoint.translateBy(dirVect)
                    if self.fromTop:
                        centrePoint.translateBy(transformVector)

                    centrePoint = sketch.modelToSketchSpace(centrePoint)

                    sketchPoint = sketch.sketchPoints.add(centrePoint)  #as the centre is placed on midline endPoint, it automatically gets constrained
                    length = (selectedEdge.edge.length + transformVector.length) if self.fromTop else makeNative(selectedEdge.edge).length
                    holeList.append([length, sketchPoint])
                    self.logger.info('hole added to list - length {}, {}'.format(length, sketchPoint.geometry.asArray()))

                depthList = set(map(lambda x: x[0], holeList))  #create a unique set of depths - using this in the filter will automatically group depths

                for depth in depthList:
                    self.logger.debug('processing holes at depth {}'.format(depth))
                    pointCollection = adsk.core.ObjectCollection.create()  #needed for the setPositionBySketchpoints
                    holeCount = 0
                    for hole in filter(lambda h: h[0] == depth, holeList):
                        pointCollection.add(hole[1])
                        holeCount+=1

                    if not face.isValid:
                        self.logger.debug('Revalidating face')
                        face = reValidateFace(comp, selectedFace.refPoint)

                    holes =  comp.features.holeFeatures
                    holeInput = holes.createSimpleInput(adsk.core.ValueInput.createByReal(self.radius*2))
                    holeInput.isDefaultDirection = True
                    holeInput.tipAngle = adsk.core.ValueInput.createByString('180 deg')
                    holeInput.participantBodies = [face.body]
                    holeInput.setPositionBySketchPoints(pointCollection)
                    holeInput.setDistanceExtent(adsk.core.ValueInput.createByReal(depth))

                    holes.add(holeInput)
                    self.logger.info('{} Holes added'.format(holeCount))
            sketch.isComputeDeferred = False

            endTlMarker = self.design.timeline.markerPosition-1
            if endTlMarker - startTlMarker >0:
                timelineGroup = self.design.timeline.timelineGroups.add(startTlMarker,endTlMarker)
                timelineGroup.name = 'dogbone'
#            self.logger.debug('doEvents - allowing fusion to refresh')
#            adsk.doEvents()

        if self.errorCount >0:
            dbUtils.messageBox('Reported errors:{}\nYou may not need to do anything, \nbut check holes have been created'.format(self.errorCount))

dog = DogboneCommand()

def run(context):
    try:
        dog.addButton()
    except:
        dbUtils.messageBox(traceback.format_exc())

def stop(context):
    try:
        dog.removeButton()
    except:
        dbUtils.messageBox(traceback.format_exc())


import logging
import json

from application.service import ClubService
from flask import Blueprint
from flask import request, Response
from application.auth.google_auth import auth_required, get_token_info

log = logging.getLogger('werkzeug')
log.setLevel(logging.ERROR)

# Variable declaration
application_json = "application/JSON"
api_resource = "/api/<resource>, e = "
plain_text = "plain/text"

mod = Blueprint('club_control', __name__)

# Get a list of details of all clubs
@mod.route('/clubs', methods=['GET'])
def get_clubs():
    try:
        clubs = ClubService.get_clubs()
        res = json.dumps(clubs, default=str)
        rsp = Response(res, status=200, content_type=application_json)
    except Exception as e:
        print(api_resource, e)
        rsp = Response(e, status=500, content_type=plain_text)
    return rsp

# Get details of a club specified by id
@mod.route('/clubs/<club_id>', methods=['GET'])
def get_club_by_id(club_id):
    try:
        event = ClubService.get_club(club_id)
        res = json.dumps(event, default=str)
        rsp = Response(res, status=200, content_type=application_json)
    except Exception as e:
        print(api_resource, e)
        rsp = Response(e, status=500, content_type=plain_text)
    return rsp

# Edit a club
@mod.route('/clubs/<club_id>', methods=['PUT'])
@auth_required
def edit_club(club_id):
    try:
        data = request.get_json()
        user_info = get_token_info()
        email_id = user_info["email"]
        if 'new_head' in data:
            head_email_id = data["new_head"]
            res, code = ClubService.assign_successor(
                email_id, club_id, head_email_id)
        else:
            club_information = data["club"]
            res, code = ClubService.edit_club(email_id, club_id,
                                              club_information)
        rsp = Response(res, status=code, content_type="text/plain")
    except Exception as e:
        print(api_resource, e)
        rsp = Response(e, status=500, content_type=plain_text)
    return rsp

# Delete a club
@mod.route('/clubs/<club_id>', methods=['DELETE'])
@auth_required
def delete_club(club_id):
    try:
        user_info = get_token_info()
        email_id = user_info["email"]
        res, code = ClubService.delete_club(email_id, club_id)
        rsp = Response(res, status=code, content_type="text/plain")
    except Exception as e:
        print(api_resource, e)
        rsp = Response(e, status=500, content_type=plain_text)
    return rsp

# Add a member to a club
@mod.route('/member/<club_id>', methods=['PUT'])
@auth_required
def add_member(club_id=None):
    try:
        data = request.get_json()
        user_info = get_token_info()
        email_id = user_info["email"]
        student_email_id = data["student_email_id"]
        res, code = ClubService.add_member(email_id, club_id, student_email_id)
        rsp = Response(res, status=code, content_type=application_json)
    except Exception as e:
        print(api_resource, e)
        rsp = Response(e, status=500, content_type=plain_text)
    return rsp

# Remove a member from a club
@mod.route('/member/<club_id>', methods=['DELETE'])
@auth_required
def remove_member(club_id=None):
    try:
        data = request.get_json()
        user_info = get_token_info()
        email_id = user_info["email"]
        student_email_id = data["student_email_id"]
        res, code = ClubService.remove_member(email_id, club_id,
                                              student_email_id)
        rsp = Response(res, status=code, content_type=application_json)
    except Exception as e:
        print(api_resource, e)
        rsp = Response(e, status=500, content_type=plain_text)
    return rsp


from musikla.core.events.transformers.transformer import Transformer
from musikla.core.events.transformers.balance_notes import BalanceNotesTransformer
from musikla.core.events.transformers.compose_notes import ComposeNotesTransformer
from musikla.audio.player import PlayerLike
from musikla.audio.interactive_player import InteractivePlayer
from musikla.core.context import Context
from musikla.core.music import Music, MusicBuffer
from musikla.core.events import MusicEvent
from typing import Dict, List, Optional, cast
from .keyboard import Keyboard

class KeyboardBuffer:
    @staticmethod
    def save_all ( context : Context, file : str, buffers ):
        from musikla.parser.printer import CodePrinter
        from musikla.libraries.music import function_to_mkl
        import musikla.parser.abstract_syntax_tree as ast
        import musikla.parser.abstract_syntax_tree.statements as ast_st
        import musikla.parser.abstract_syntax_tree.expressions as ast_ex

        body = ast_st.StatementsListNode( [] )

        body.statements.append( ast_st.VariableDeclarationStatementNode( 'buffers', ast_ex.ObjectLiteralNode( [] ) ) )

        for key, buffer in buffers.items():
            set_fn = ast_ex.PropertyAccessorNode( ast_ex.VariableExpressionNode( 'buffers' ), ast_ex.StringLiteralNode( 'set' ), True )

            key_p = ast_ex.NumberLiteralNode( key )
            music_p = function_to_mkl( context, buffer.to_music(), ast = True ) \
                if buffer else \
                ast_ex.NoneLiteralNode()

            body.statements.append( ast_ex.FunctionExpressionNode( set_fn, [ key_p, music_p ] ) )

        file_str = CodePrinter().print( body )

        with open( file, 'w+' ) as f:
            f.write( file_str )

    @staticmethod
    def load_all ( context : Context, file : str, buffers = None ):
        from musikla.parser.abstract_syntax_tree.expressions.object_literal_node import Object

        buffers = Object() if buffers is None else buffers

        forked_context = context.fork( symbols = context.symbols.fork() )

        context.script.import_module( forked_context, file, save_cache = False )

        saved_buffers = forked_context.symbols.lookup( "buffers", recursive = False )

        for key, music in saved_buffers.items():
            bf = buffers.get( key, default = None )

            if bf is None:
                bf = KeyboardBuffer( context, start = False )

            if music is not None:
                bf.from_music( music )

            buffers.set( key, bf )

        return buffers

    def __init__ ( self, context : Context, keyboards : List[Keyboard] = None, start : bool = True ):
        from .library import KeyboardLibrary
        lib : KeyboardLibrary = cast( KeyboardLibrary, context.library( KeyboardLibrary ) )

        self.context = context
        self.keyboards : List[Keyboard] = []
        self.music_buffer : Optional[MusicBuffer] = None
        self.start_time : Optional[int] = None
        self.collected_events : List[MusicEvent] = []
        self.player : PlayerLike = lib.player

        self.loop : bool = False
        self.mode : str = 'music'

        if keyboards is None:
            self.keyboards = lib.keyboards
        else:
            self.keyboards = keyboards

        if start: self.start()

    @property
    def duration ( self ) -> int:
        if self.collected_events:
            return self.collected_events[ -1 ].end_timestamp
        else:
            return 0

    @property
    def recording ( self ) -> bool:
        return self.music_buffer is not None

    def on_new_player ( self, keyboard : Keyboard, player : InteractivePlayer ):
        if self.music_buffer is not None:
            if player.buffers is not None:
                player.buffers.append( self.music_buffer )
            else:
                player.buffers = [ self.music_buffer ]

    def start ( self ):
        if self.music_buffer is None:
            self.start_time = self.player.get_time()

            self.music_buffer = MusicBuffer()

            for kb in self.keyboards: kb.add_new_player_observer( self.on_new_player )

    def stop ( self, discard : bool = False ):
        if self.music_buffer is not None:
            if not discard:

                collected : List[MusicEvent] = list( self.music_buffer.collect() )

                if collected:
                    # We consider the start time as being the first event's timestamp
                    st : int = collected[ 0 ].timestamp

                    collected = [ ev.clone( timestamp = ev.timestamp - st + self.duration ) for ev in collected ]

                    self.collected_events.extend( collected )

            self.music_buffer = None

            for kb in self.keyboards: kb.remove_new_player_observer( self.on_new_player )

            self.start_time = None

    def clear ( self ):
        self.stop( discard = True )

        self.collected_events.clear()

    def toggle ( self, clear : bool = True ):
        if self.recording:
            self.stop()
        else:
            if clear: self.clear()

            self.start()

    def __len__ ( self ):
        return len( self.collected_events )

    def __bool__ ( self ):
        return bool( self.collected_events )

    @property
    def duration_live ( self ):
        # Note that this method might return lower values than previous calls

        # When we are recording and we have at least on
        if self.music_buffer is not None and self.music_buffer.buffer:
            st : int = self.music_buffer.buffer[ 0 ].timestamp

            return self.player.get_time() - st + self.duration
        else:
            return self.duration

    def to_events ( self ):
        events = self.collected_events
        events = BalanceNotesTransformer.iter( events )
        events = ComposeNotesTransformer.iter( events )

        return events

    def to_events_live ( self, get_time = None, now : bool = False ):
        if self.music_buffer is not None:
            buffered : List[MusicEvent] = self.music_buffer.buffer

            if buffered:
                # We consider the start time as being the first event's timestamp
                st : int = buffered[ 0 ].timestamp

                buffered = [ ev.clone( timestamp = ev.timestamp - st + self.duration ) for ev in buffered ]

                if now and get_time is None:
                    st : int = buffered[ 0 ].timestamp

                    get_time = lambda: self.duration_live

            events = self.collected_events + buffered

            events = BalanceNotesTransformer.iter( events, get_time = get_time )
            events = ComposeNotesTransformer.iter( events )

            return events
        else:
            return self.to_events()

    def to_music ( self ):
        return Music( self.to_events() ).shared()

    def to_music_live ( self ):
        return Music( self.to_events_live() ).shared()

    def from_music ( self, music : Music ):
        self.clear()

        self.collected_events = list( music )

{'CodePrinter': 'musikla.parser.printer.CodePrinter', 'function_to_mkl': 'musikla.libraries.music.function_to_mkl', 'ast': 'musikla.parser.abstract_syntax_tree', 'ast_st': 'musikla.parser.abstract_syntax_tree.statements', 'ast_ex': 'musikla.parser.abstract_syntax_tree.expressions', 'Object': 'musikla.parser.abstract_syntax_tree.expressions.object_literal_node.Object'}


# Get the PIDs of datasets in a given dataverse (and optionally any dataverses in that dataverse).
# Includes deaccessioned datasets. Excludes harvested and linked datasets.

import csv
import glob
import json
import os
import requests
import sys
import time
from tkinter import filedialog
from tkinter import ttk
from tkinter import *
from urllib.parse import urlparse

####################################################################################

# Create GUI for getting user input

window = Tk()
window.title('Get dataset PIDs')
window.geometry('625x450')  # width x height

# Function called when Browse button is pressed
def retrieve_directory():
    global directory

    # Call the OS's file directory window and store selected object path as a global variable
    directory = filedialog.askdirectory()

    # Show user which directory she chose
    label_showChosenDirectory = Label(window, text='You chose: ' + directory, anchor='w', foreground='green', wraplength=500, justify='left')
    label_showChosenDirectory.grid(sticky='w', column=0, row=13)

# Function called when Start button is pressed
def retrieve_input():
    global dataverseUrl
    global apiKey
    global get_subdataverses

    # Record if user wants to search in subdataverses
    get_subdataverses = get_subdataverses.get()

    # Store what entered in the api key text box as a global variable
    apiKey = entry_apikey.get().rstrip()

    # Store what's entered in dataverseUrl text box as a global variable
    dataverseUrl = entry_dataverseUrl.get()

    # If user enters text in dataverseUrl text box, strip any white characters
    if dataverseUrl:
        dataverseUrl = str(dataverseUrl)
        dataverseUrl = dataverseUrl.strip()

        # If user also selected a directory, close the window
        if directory:
            window.destroy()

    # If no dataverseUrl is entered, display message that one is required
    else:
        print('A dataverse URL is required')
        label_dataverseUrlReqiured = Label(window, text='A dataverse URL is required.', foreground='red', anchor='w')
        label_dataverseUrlReqiured.grid(sticky='w', column=0, row=3)

# Create label for Dataverse URL field
label_dataverseUrl = Label(window, text='Dataverse URL:', anchor='w')
label_dataverseUrl.grid(sticky='w', column=0, row=0)

# Create Dataverse URL field
dataverseUrl = str()
entry_dataverseUrl = Entry(window, width=50, textvariable=dataverseUrl)
entry_dataverseUrl.grid(sticky='w', column=0, row=1, pady=2)
entry_dataverseUrl.insert(0, 'https://demo.dataverse.org/')

# Create help text for Dataverse URL field
label_dataverseUrlHelpText = Label(window, text='Example: https://demo.dataverse.org or https://demo.dataverse.org/dataverse/dataversealias', foreground='grey', anchor='w')
label_dataverseUrlHelpText.grid(sticky='w', column=0, row=2)

# Create empty row in grid to improve spacing between the two fields
window.grid_rowconfigure(4, minsize=25)

# Create "Include subdataverses" checkbox
get_subdataverses = IntVar()
c = Checkbutton(window, text="Include subdataverses", variable=get_subdataverses).grid(sticky='w', column=0, row=5)

# Create help text for "Include subdataverses" checkbox
label_apikeyHelpText = Label(
    window,
    text='If the URL of the "Root" Dataverse collection is entered, all datasets in the repository (in all subdataverses) will be found',
    foreground='grey', anchor='w', wraplength=500, justify='left')
label_apikeyHelpText.grid(sticky='w', column=0, row=6)

# Create empty row in grid to improve spacing between the two fields
window.grid_rowconfigure(7, minsize=25)

# Create label for API key field
label_apikey = Label(window, text='API key:', anchor='w')
label_apikey.grid(sticky='w', column=0, row=8)

# Create API key field
apiKey = str()
entry_apikey = Entry(window, width=50, textvariable=apiKey)
entry_apikey.grid(sticky='w', column=0, row=9, pady=2)

# Create help text for API key field
label_apikeyHelpText = Label(window, text='If no API is entered, only published datasets will be found', foreground='grey', anchor='w')
label_apikeyHelpText.grid(sticky='w', column=0, row=10)

# Create empty row in grid to improve spacing between the two fields
window.grid_rowconfigure(11, minsize=25)

# Create label for Browse directory button
label_browseDirectory = Label(window, text='Choose folder to store CSV file with info of dataset PIDs:', anchor='w')
label_browseDirectory.grid(sticky='w', column=0, row=12, pady=2)

# Create Browse directory button
button_browseDirectory = ttk.Button(window, text='Browse', command=lambda: retrieve_directory())
button_browseDirectory.grid(sticky='w', column=0, row=13)

# Create start button
button_Submit = ttk.Button(window, text='Start', command=lambda: retrieve_input())
button_Submit.grid(sticky='w', column=0, row=15, pady=40)

# Keep window open until it's closed
mainloop()

# Save current time to append it to main folder name
current_time = time.strftime('%Y.%m.%d_%H.%M.%S')

# Parse dataverseUrl to get installation name and alias
parsed = urlparse(dataverseUrl)
installationUrl = parsed.scheme + '://' + parsed.netloc
# alias = parsed.path.split('/')[2]

try:
    alias = parsed.path.split('/')[2]
except IndexError:
    alias = ''

# Get alias of the root dataverse (assumming the root dataverse's ID is 1, which isn't the case with UVA Dataverse)
url = f'%s/api/dataverses/1' % (installationUrl)
response = requests.get(url)
dataverse_data = response.json()
root_alias = dataverse_data['data']['alias']
installation_name = dataverse_data['data']['name']

####################################################################################

# If user provides no alias or the alias is the repository's root alias,
# use Search API to find PIDs of all datasets in repository

if not alias or alias == root_alias:

    # Create CSV file
    csv_file = 'dataset_pids_%s_%s.csv' % (installation_name.replace(' ', '_'), current_time)
    csv_file_path = os.path.join(directory, csv_file)

    with open(csv_file_path, mode='w', newline='') as f:
        f = csv.writer(f, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL)
        f.writerow(['persistent_id', 'persistentUrl', 'dataverse_name', 'dataverse_alias', 'publication_date'])

    # Report count of datasets
    if apiKey:
        url = '%s/api/v1/search?q=*&fq=-metadataSource:"Harvested"&type=dataset&per_page=1&start=0&sort=date&order=desc&key=%s' % (installationUrl, apiKey)
        response = requests.get(url)
        data = response.json()
        total = data['data']['total_count']
        print('\nSaving %s dataset PIDs\n(Search API returns the draft and published version of a dataset. List will be de-duplicated at the end):' % (total))
    else:
        url = '%s/api/v1/search?q=*&fq=-metadataSource:"Harvested"&type=dataset&per_page=1&start=0&sort=date&order=desc' % (installationUrl)
        response = requests.get(url)
        data = response.json()
        total = data['data']['total_count']
        print('\nSaving %s dataset PIDs:' % (total))

    # Initialization for paginating through Search API results and showing progress
    start = 0
    condition = True
    count = 0

    # Create variable for storing count of misindexed datasets
    misindexed_datasets_count = 0

    while condition:
        try:
            per_page = 10
            if apiKey:
                url = '%s/api/v1/search?q=*&fq=-metadataSource:"Harvested"&type=dataset&per_page=%s&start=%s&sort=date&order=desc&key=%s' % (installationUrl, per_page, start, apiKey)
            else:
                url = '%s/api/v1/search?q=*&fq=-metadataSource:"Harvested"&type=dataset&per_page=%s&start=%s&sort=date&order=desc' % (installationUrl, per_page, start)

            response = requests.get(url)
            data = response.json()

            # For each item object...
            for i in data['data']['items']:
                persistent_id = i['global_id']
                persistent_url = i['url']
                dataverse_name = i['name_of_dataverse']
                dataverse_alias = i['identifier_of_dataverse']
                publicationDate = i.get('published_at', 'UNPUBLISHED')

                with open(csv_file_path, mode='a', encoding='utf-8', newline='') as open_csv_file:
                    open_csv_file = csv.writer(open_csv_file, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL)

                    # Create new row with dataset and file info
                    open_csv_file.writerow([persistent_id, persistent_url, dataverse_name, dataverse_alias, publicationDate])

                    count += 1
                    print('%s of %s' % (count, total), end='\r', flush=True)

            # Update variables to paginate through the search results
            start = start + per_page

        # Print error message if misindexed datasets break the Search API call, and try the next page. (See https://github.com/IQSS/dataverse/issues/4225)
        except urllib.error.URLError:
            try:
                per_page = 1
                if apiKey:
                    url = '%s/api/v1/search?q=*&fq=-metadataSource:"Harvested"&type=dataset&per_page=%s&start=%s&sort=date&order=desc&key=%s' % (installationUrl, per_page, start, apiKey)
                else:
                    url = '%s/api/v1/search?q=*&fq=-metadataSource:"Harvested"&type=dataset&per_page=%s&start=%s&sort=date&order=desc' % (installationUrl, per_page, start)

                response = requests.get(url)
                data = response.json()

                # For each item object...
                for i in data['data']['items']:
                    persistent_id = i['global_id']
                    persistent_url = i['url']
                    dataverse_name = i['name_of_dataverse']
                    dataverse_alias = i['identifier_of_dataverse']
                    publicationDate = i.get('published_at', 'unpublished')

                    with open(csv_file_path, mode='a', encoding='utf-8', newline='') as open_csv_file:
                        open_csv_file = csv.writer(open_csv_file, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL)

                        # Create new row with dataset and file info
                        open_csv_file.writerow([persistent_id, persistent_url, dataverse_name, dataverse_alias, publicationDate])

                        print('%s of %s' % (count, total), end='\r', flush=True)

                    # Update variables to paginate through the search results
                    start = start + per_page

            except urllib.error.URLError:
                misindexed_datasets_count += 1
                start = start + per_page

        # Stop paginating when there are no more results
        condition = start < total

    print('\nDataset PIDs written to the CSV file: %s' % (count))

    if misindexed_datasets_count:
        print('\n\nUnretrievable dataset PIDs due to misindexing: %s\n' % (misindexed_datasets_count))

####################################################################################

# If user provides an alias, and it isn't the root dataverses's alias, use "Get content" endpoints instead of Search API

else:
    csv_file = 'dataset_pids_%s_%s.csv' % (alias, current_time)
    csv_file_path = os.path.join(directory, csv_file)

    with open(csv_file_path, mode='w', newline='') as f:
        f = csv.writer(f, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL)
        f.writerow(['persistent_id', 'persistentUrl', 'dataverse_name', 'dataverse_alias', 'publication_date'])

    # Get ID of given dataverse alias
    if apiKey:
        url = '%s/api/dataverses/%s?key=%s' % (installationUrl, alias, apiKey)
    else:
        url = '%s/api/dataverses/%s' % (installationUrl, alias)

    response = requests.get(url)
    data = response.json()
    parent_dataverse_id = data['data']['id']

    # Create list and add ID of given dataverse
    dataverse_ids = [parent_dataverse_id]

    # If user wants datasets in subdataverses, search for and include IDs of subdataverses (excludes linked dataverses)

    # Get each subdataverse in the given dataverse
    if get_subdataverses == 1:
        print('\nGetting dataverse IDs in %s:' % (alias))

        for dataverse_id in dataverse_ids:

            sys.stdout.write('.')
            sys.stdout.flush()

            if apiKey:
                url = '%s/api/dataverses/%s/contents?key=%s' % (installationUrl, dataverse_id, apiKey)
            else:
                url = '%s/api/dataverses/%s/contents' % (installationUrl, dataverse_id)

            response = requests.get(url)
            data = response.json()

            for i in data['data']:
                if i['type'] == 'dataverse':
                    dataverse_id = i['id']
                    dataverse_ids.extend([dataverse_id])

        print('\n\nFound 1 dataverse and %s subdataverses' % (len(dataverse_ids) - 1))

    # For each dataverse in the list, add the PIDs of all datasets to a CSV file - excludes linked and harvested datasets

    print('\nWriting dataset IDs to %s:' % (csv_file_path))

    count = 0

    with open(csv_file_path, mode='a', encoding='utf-8', newline='') as open_csv_file:
        open_csv_file = csv.writer(open_csv_file, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL)

        for dataverse_id in dataverse_ids:

            # Get name of dataverse
            if apiKey:
                url = '%s/api/dataverses/%s?key=%s' % (installationUrl, dataverse_id, apiKey)
            else:
                url = '%s/api/dataverses/%s' % (installationUrl, dataverse_id)
            response = requests.get(url, timeout=10)
            data = response.json()
            dataverse_name = data['data']['name']
            dataverse_alias = data['data']['alias']

            # Get content of dataverse
            if apiKey:
                url = '%s/api/dataverses/%s/contents?key=%s' % (installationUrl, dataverse_id, apiKey)
            else:
                url = '%s/api/dataverses/%s/contents' % (installationUrl, dataverse_id)

            response = requests.get(url)
            data = response.json()

            for i in data['data']:
                if i['type'] == 'dataset':
                    protocol = i['protocol']
                    authority = i['authority']
                    identifier = i['identifier']
                    persistent_id = '%s:%s/%s' % (protocol, authority, identifier)
                    persistent_url = i['persistentUrl']
                    publicationDate = i.get('publicationDate', 'unpublished')

                    count += 1

                    # Create new line with dataset PID
                    open_csv_file.writerow([persistent_id, persistent_url, dataverse_name, dataverse_alias, publicationDate])

                    # As a progress indicator, print a dot each time a row is written
                    sys.stdout.write('.')
                    sys.stdout.flush()

    print('\n\nDataset PIDs written to the CSV file: %s' % (count))


from warnings import warn

from statsmodels.sandbox.distributions.extras import ACSkewT_gen

from .check_nd_array_for_bad import check_nd_array_for_bad

def fit_skew_t_pdf(
    _1d_array,
    fit_fixed_location=None,
    fit_fixed_scale=None,
    fit_initial_location=None,
    fit_initial_scale=None,
):

    _1d_array = _1d_array[~check_nd_array_for_bad(_1d_array, raise_for_bad=False)]

    keyword_arguments = {}

    guessed_location = _1d_array.mean()

    guessed_scale = _1d_array.std() / 2

    if fit_fixed_location is not None:

        keyword_arguments["floc"] = fit_fixed_location

    if fit_fixed_scale is not None:

        keyword_arguments["fscale"] = fit_fixed_scale

    if fit_initial_location is not None:

        keyword_arguments["loc"] = fit_initial_location

    else:

        keyword_arguments["loc"] = guessed_location

    if fit_initial_scale is not None:

        keyword_arguments["scale"] = fit_initial_scale

    else:

        keyword_arguments["scale"] = guessed_scale

    skew_t_model = ACSkewT_gen()

    degree_of_freedom, shape, location, scale = skew_t_model.fit(
        _1d_array, **keyword_arguments
    )

    if 24 < abs(shape):

        warn("Refitting with scale = (standard deviation / 2) ...")

        keyword_arguments["fscale"] = guessed_scale

        degree_of_freedom, shape, location, scale = skew_t_model.fit(
            _1d_array, **keyword_arguments
        )

        if 24 < abs(shape):

            warn("Refitting with location = mean ...")

            keyword_arguments["floc"] = guessed_location

            degree_of_freedom, shape, location, scale = skew_t_model.fit(
                _1d_array, **keyword_arguments
            )

    return _1d_array.size, location, scale, degree_of_freedom, shape


import matplotlib.pyplot as plt

f = open("biggest_cities.txt", "r")

plt.plot([int(x.split(",")[3]) for x in f.readlines()][:15])
plt.ylabel("City size")
plt.xlabel("City rank")
plt.show()

f.close()


import asyncio

class CommentExecutor:

    def __init__(self):
        self.work_queue = asyncio.Queue()

    async def add_video(self, new_vids):
        for vid in new_vids:
            await self.work_queue.put(vid)

    async def execute(self, task, num_workers=5):
        tasks = [asyncio.create_task(task(self.work_queue)) for _ in range(num_workers)]

        await asyncio.gather(*tasks)


"""Prepare entry and gone transactions from comparing local hierarchy with proxy data."""
import datetime as dti
import pathlib
import random
from typing import Union

from kiertotie import (
    BASE_URL,
    DASH,
    EASING,
    ENCODING,
    ESP,
    HTTP_404_BYTES_TOKEN,
    HTTP_404_BYTES_TOKEN_LENGTH,
    HTTP_404_FILE,
    HTTP_404_SIZE_BYTES,
    NL,
    RATE,
    SP,
    TS_FORMAT,
    URL_ENC_SP,
    EntryType,
    load,
    log,
)

DEFAULT_SCRIPT = 'update.sh'

def shell(path: Union[str, pathlib.Path], commands: list[str]) -> None:
    """Dump the commands into a shell script at path."""
    with open(path, 'wt', encoding=ENCODING) as handle:
        handle.write(NL.join(commands))

def assess_files(
    upstreams: list[EntryType],
    anchor: pathlib.Path,
    root_folder: pathlib.Path,
    commands: list[str],
    verbose: bool = False,
) -> list[EntryType]:
    """DRY."""
    updates = []
    for n, entry in enumerate(upstreams, start=1):
        entry_path = entry['path']
        if entry_path == '.':
            continue
        path = pathlib.Path(str(entry_path))

        if not (anchor / root_folder / path.parent).is_dir():
            if verbose:
                commands.append(f'# - New file {root_folder}/{path} in new folder')
            updates.append(entry)
            continue

        if not (anchor / root_folder / path).is_file():
            if verbose:
                commands.append(f'# - New file {root_folder}/{path} in existing folder')
            updates.append(entry)
            continue

        stat_found = (anchor / root_folder / path).stat()
        size_bytes_found = stat_found.st_size
        log.debug(f'local path ({root_folder / path}) pointing to {size_bytes_found} bytes is interesting ...')
        if verbose:
            commands.append(
                f'# ... Local path {root_folder / path} pointing to {size_bytes_found} bytes is interesting ...'
            )
        sampled_bytes = b''
        removed_http_404 = False
        if size_bytes_found == HTTP_404_SIZE_BYTES:
            with open(anchor / root_folder / path, 'rb') as raw_reader:
                sampled_bytes = raw_reader.read(HTTP_404_BYTES_TOKEN_LENGTH)
                commands.append(
                    f'# ... ... Read initial {HTTP_404_BYTES_TOKEN_LENGTH} bytes'
                    f' from {root_folder / path} being {sampled_bytes}'  # type: ignore
                )
        if sampled_bytes and sampled_bytes == HTTP_404_BYTES_TOKEN:
            text_content = ''
            with open(anchor / root_folder / path, 'rt', encoding=ENCODING) as text_reader:
                text_content = text_reader.read()
            if text_content == HTTP_404_FILE:
                log.warning(
                    f'detected HTTP/404 response file ({root_folder / path})'
                    ' in local hierarchy and added removal command'
                )
                commands.append(f'echo Removing HTTP/404 response file {root_folder / path} from local hierarchy:')
                commands.append(f'rm -f {anchor / root_folder / path}')
                size_bytes_found = 0
                removed_http_404 = True

        size_bytes_upstream = entry['size']
        if size_bytes_found == size_bytes_upstream:
            if path.name not in ('timestamp.tx', 'timestamp.txt', 'md5sums.txt'):
                if verbose:
                    commands.append(f'# - Skipping same size file {root_folder}/{path} in existing folder')
                continue
            commands.append(f'# - Overwriting same size file {root_folder}/{path} in existing folder')
        elif removed_http_404:
            commands.append(
                f'# - Will replace HTTP/404 response file {root_folder}/{path} with {size_bytes_upstream} bytes'
                f' from upstream in existing folder'
            )
        else:
            if verbose:
                commands.append(
                    f'# - Different size file {root_folder}/{path} with {size_bytes_found}'
                    f' instead {size_bytes_upstream} bytes upstream in existing folder'
                )
        updates.append(entry)

    return updates

def process(
    proxy_data_path: Union[str, pathlib.Path],
    anchor_path: Union[str, pathlib.Path, None] = None,
    script_path: Union[str, pathlib.Path, None] = None,
    verbose: bool = False,
) -> int:
    """Generate folder tree below current working directory according to proxy data."""
    anchor = pathlib.Path.cwd() if anchor_path is None else pathlib.Path(anchor_path)
    log.debug(f'assuming anchor as ({anchor}) in process update')

    store_path = pathlib.Path(proxy_data_path)
    log.debug(f'loading proxy data from ({store_path}) in process update')
    repo = load(store_path)

    root_folder_str = store_path.name.split(DASH)[1]
    if root_folder_str == 'development':
        root_folder_str += '_releases'
    root_folder = pathlib.Path(root_folder_str)
    log.debug(f'assuming root folder as ({root_folder}) below anchor ({anchor}) in process update')

    script_path = pathlib.Path(DEFAULT_SCRIPT) if script_path is None else pathlib.Path(script_path)
    log.debug(f'creating shell script at ({script_path})')

    actions = ['#! /usr/bin/env bash']
    actions.append(f'# Derived root folder to be ({root_folder}) below anchor ({anchor})')
    actions.append(f'echo "Initializing the tree below root folder with random waits between 1 and {EASING} secs"')

    present = set()
    actions.append(f'# Inventarizing storage folders below {root_folder}')
    shared_root_str = f'{root_folder}/'
    for path in root_folder.rglob('*'):
        if not path.is_dir():
            continue
        path_str = str(path)[len(shared_root_str) :]
        if path_str:
            present.add(path_str)
    actions.append(f'# * found {len(present)} storage folders below {root_folder}')

    possibly_gone = set(f for f in present)
    upstream_folders = [
        f for f in reversed(sorted(f['path'] for f in repo['tree']['folders'])) if f != '.'  # type: ignore
    ]
    upstream_folder_born = {
        f['path']: dti.datetime.strptime(f['timestamp'], TS_FORMAT).replace(tzinfo=dti.timezone.utc)  # type: ignore
        for f in repo['tree']['folders']  # type: ignore
    }
    folder_count = repo['count_folders']
    maybe_enter = set(upstream_folders)
    actions.append('# Subtracting folders present upstream from gone and update enter section')
    for local_name in upstream_folders:
        possibly_gone.discard(local_name)  # type: ignore
        if local_name in present:
            maybe_enter.discard(local_name)
    gone_count = len(possibly_gone)
    actions.append(f"# * found {gone_count} gone storage folder{'' if gone_count == 1 else 's'} below {anchor}:")
    if verbose:
        for f in sorted(possibly_gone):
            actions.append(f'#  - {root_folder}/{f}')
    enter_count = len(maybe_enter)
    actions.append(f"# * found {enter_count} enter folder{'' if gone_count == 1 else 's'} below {anchor}:")
    if verbose:
        for f in sorted(maybe_enter):  # type: ignore
            actions.append(f'#  + {root_folder}/{f} from {upstream_folder_born[f]}')

    candidate_count = repo['count_files']
    actions.append(
        f'# Detected {candidate_count} candidate entrie{"" if candidate_count == 1 else "s"}'
        f' from upstream across {folder_count} folders below {anchor / root_folder}'
    )

    updates = assess_files(
        repo['tree']['files'],  # type: ignore
        anchor=anchor,
        root_folder=root_folder,
        commands=actions,
        verbose=verbose,
    )

    transfers = len(updates)
    size_files_bytes = sum(entry['size'] for entry in updates)  # type: ignore
    bytes_cum = 0
    for n, entry in enumerate(updates, start=1):
        entry_path = entry['path']
        if entry_path == '.':
            continue
        path = pathlib.Path(str(entry_path))
        path_str = str(path)
        size_bytes_upstream = int(entry['size'])
        secs_est = int(size_bytes_upstream / RATE)
        secs_est_disp = 'less than a second' if secs_est < 1 else f'approx. {secs_est} seconds'
        bytes_cum += size_bytes_upstream
        nap = random.randint(1, EASING)  # nosec B311
        if 'timestamp' in path_str or 'md5sum' in path_str:
            actions.append(f'echo not sleeping before transferring file {n} of {transfers}')
        else:
            actions.append(f'echo sleeping for {nap} secs before transferring file {n} of {transfers}')
            actions.append(f'sleep {nap}')
        actions.append(f'mkdir -p "{anchor}/{root_folder}/{path.parent}" || exit 1')
        actions.append(f'cd "{anchor}/{root_folder}/{path.parent}" || exit 1')
        actions.append('pwd')
        actions.append(
            f'echo started the transfer {n} of {transfers} requesting {size_bytes_upstream} bytes'
            f' assuming {secs_est_disp} at "$(date +"%Y-%m-%d %H:%M:%S +00:00")"'
        )
        if SP not in path_str:
            actions.append(f"echo curl -kORLs --limit-rate 2000k '{BASE_URL}{root_folder}/{path}'")
            actions.append(f"curl -kORLs --limit-rate 2000k '{BASE_URL}{root_folder}/{path}'")
        else:
            path_url_enc = path_str.replace(SP, URL_ENC_SP)
            path_local = f'{str(path.name).replace(SP, ESP)}'
            actions.append(
                f"echo curl -kRLs --limit-rate 2000k '{BASE_URL}{root_folder}/{path_url_enc}' -o '{path_local}'"
            )
            actions.append(f"curl -kRLs --limit-rate 2000k '{BASE_URL}{root_folder}/{path_url_enc}' -o '{path_local}'")
        actions.append(
            f'echo transfer is complete {n} of {transfers} for cum. {bytes_cum} of'
            f' tot. {size_files_bytes} bytes at "$(date +"%Y-%m-%d %H:%M:%S +00:00")"'
        )

    actions.append('echo OK')
    actions.append('')  # Final newline at end of fetch script

    shell(script_path, actions)
    log.debug(f'created shell script with {len(actions) - 1} lines at ({script_path}) from process update')

    return 0