// noinspection JSPotentiallyInvalidUsageOfThis

import {invokeCallback, mixin} from "@/common.js";
import {chart, createShape, deleteShapeId, dragging, draggingShapeIds, drawShape, widget} from "@/charts/chart.js";
import Color from "color";
import {dirtyItems, dirtyPoints, nearestOhlcStart} from "@/charts/chart-misc.js";
import {defined, toPrecision} from "@/misc.js";


//
// Usage of Shapes:
// const shape = new Shape(ShapeType.HLine)
// shape.draw()
// shape.points
// > [{time:17228394, price:42638.83}]
//
// shape.model stores the shape-specific control points, as defined by the shape subclass, and may be a vue ref.
// Use the shape.model.* fields in components to get reactive effects with the chart, or pass an onModel callback
// to the constructor
//


export const ShapeType = {
    // this "enum" is a record of the TradingView keystrings

    /*
    The `name` field must match the user-facing name in the TradingView toolbar UI
    TradingView drawing tool codes:
    https://www.tradingview.com/charting-library-docs/latest/api/modules/Charting_Library/#supportedlinetools
    text anchored_text note anchored_note signpost double_curve arc icon emoji sticker arrow_up arrow_down arrow_left arrow_right price_label price_note arrow_marker flag vertical_line horizontal_line cross_line horizontal_ray trend_line info_line trend_angle arrow ray extended parallel_channel disjoint_angle flat_bottom anchored_vwap pitchfork schiff_pitchfork_modified schiff_pitchfork balloon comment inside_pitchfork pitchfan gannbox gannbox_square gannbox_fixed gannbox_fan fib_retracement fib_trend_ext fib_speed_resist_fan fib_timezone fib_trend_time fib_circles fib_spiral fib_speed_resist_arcs fib_channel xabcd_pattern cypher_pattern abcd_pattern callout triangle_pattern 3divers_pattern head_and_shoulders fib_wedge elliott_impulse_wave elliott_triangle_wave elliott_triple_combo elliott_correction elliott_double_combo cyclic_lines time_cycles sine_line long_position short_position forecast date_range price_range date_and_price_range bars_pattern ghost_feed projection rectangle rotated_rectangle circle ellipse triangle polyline path curve cursor dot arrow_cursor eraser measure zoom brush highlighter regression_trend fixed_range_volume_profile
    */
    Segment: {name: 'Trend Line', code: 'trend_line'},
    Ray: {name: 'Ray', code: 'ray', drawingProp: 'linetoolray'},
    Line: {name: 'Extended Line', code: 'extended', drawingProp: 'linetoolray'},
    HRay: {name: 'Horizontal Ray', code: 'horizontal_ray'},
    HLine: {name: 'Horizontal Line', code: 'horizontal_line', drawingProp: 'linetoolhorzline'},
    VLine: {name: 'Vertical Line', code: 'vertical_line', drawingProp: 'linetoolvertline'},
    PriceRange: {name: 'Price Range', code: 'price_range'},
    DateRange: {name: 'Date Range', code: 'date_range', drawingProp: 'linetooldaterange'},
}


export function allocationText(buy, weight, amount, baseSymbol, amountSymbol = null, parts = 1, separator = ' ') {
    const hasAmount = amount !== null && amount !== undefined && amount > 0
    if (hasAmount)
        amount = Number(amount)
    const hasWeight = weight !== null && weight !== undefined && weight !== 1
    if (hasWeight)
        weight = Number(weight)
    let text = buy === undefined ? '' : buy ? 'Buy ' : 'Sell '
    if (hasWeight)
        text += `${(weight * 100).toFixed(1)}%`
    const hasSymbol = baseSymbol !== null && baseSymbol !== undefined
    if (hasAmount && hasSymbol) {
        if (hasWeight)
            text += separator
        if (amountSymbol!==null && amountSymbol!==baseSymbol)
            text += `${baseSymbol} worth ${toPrecision(amount,3)} ${amountSymbol}`
        else
            text += `${toPrecision(amount,3)} ${baseSymbol}`
    }
    if (parts > 1)
        text += separator + `in ${Math.round(parts)} parts`
    return text
}


export class Shape {

    constructor(type, onModel=null, onDelete=null, props=null, readonly=false, overrides={}) {
        // the Shape object manages synchronizing internal data with a corresponding TradingView shape

        // each shape in the class hierarchy overrides setModel() to cause effects in TradingView
        // TV callbacks are returned to the on*() handlers, primarily onPoints() and onProps(). Handlers
        // of TV callbacks should call updateModel() with just the kv's they want to change. Any changes
        // are then passed onto the onModel() callback.

        // the model object has various attributes defined by each shape subclass.  each subclass's constructor
        // must call setModel(model) after first calling super.constructor() and optionally declaring any model
        // defaults on this.model.

        // Shape: {
        //   lineColor, textColor, color, // lineColor and textColor default to color
        // }

        this.debug = false
        this.id = null // TradingView shapeId, or null if no TV shape created yet (drawing mode)
        this.type = type  // ShapeType
        this.model = {}  // set to nothing at first
        this.tvCallbacks = null
        this.ourPoints = null
        this.tvPoints = null
        this.creationOptions = readonly ?
            {disableSave:true, disableUndo:true, disableSelection:true, lock:true} :
            {disableSave:true, disableUndo:true, disableSelection:false}
        this.creationOptions.overrides = overrides
        this.ourProps = {}
        if (props !== null)
            this.ourProps = mixin(props, this.ourProps)
        this.tvProps = {}
        if (onModel !== null)
            this.onModel = onModel
        if (onDelete !== null )
            this.onDelete = onDelete

        //
        // Model values handled by this base class
        //

        this.model.color = null
        // if allocation is set, a percentage is displayed
        this.model.allocation = null
        // if maxAllocation is set, the line color (but not text color) is shaded down based on allocation
        this.model.maxAllocation = null
        // both amount and amountSymbol must be set in order to display amount text
        this.model.amount = null
        this.model.baseSymbol = null
        this.model.amountSymbol = null
        this.model.extraText = null
        this.model.textLocation = null  // defaults to 'above' if not set

        // LEAF SUBCLASSES MUST CALL setModel(model) AFTER ALL CONSTRUCTION.
    }

    //
    // primary interface methods
    //

    setModel(model) {
        if (defined(model.color))
            this.model.color = model.color
        if (defined(model.allocation))
            this.model.allocation = model.allocation
        if (defined(model.maxAllocation))
            this.model.maxAllocation = model.maxAllocation
        if (defined(model.amount))
            this.model.amount = model.amount
        if (defined(model.amountSymbol))
            this.model.amountSymbol = model.amountSymbol
        if (defined(model.baseSymbol))
            this.model.baseSymbol = model.baseSymbol
        if (defined(model.extraText))
            this.model.extraText = model.extraText
        if (defined(model.breakout))
            this.model.breakout = model.breakout
        if (defined(model.textLocation))
            this.model.textLocation = model.textLocation
        if (defined(model.buy))
            this.model.buy = model.buy

        const newProps = {}

        // text color
        const color = this.model.color ? this.model.color : '#0044ff'
        newProps.textcolor = color

        // line color
        if (defined(this.model.allocation) && defined(this.model.maxAllocation)) {
            const w = this.model.allocation / this.model.maxAllocation
            if (!w)
                newProps.linecolor = 'rgba(0,0,0,0)'
            else {
                // weighted line color
                let c = new Color(color).rgb()
                // the perceptual color of a weight follows Stevens's Power Law
                // https://en.wikipedia.org/wiki/Stevens's_power_law
                newProps.linecolor = c.alpha(Math.pow(w,0.67)).string()
            }
        }
        else
            newProps.linecolor = color

        // text label
        let text = allocationText(this.model.buy, this.model.allocation, this.model.amount, this.model.baseSymbol, this.model.amountSymbol)
        if (this.model.breakout)
            text += ' ' + (this.model.textLocation==='above' ? '▲Breakout▲' : '▼Breakdown▼')
        if (this.model.extraText)
            text += ' '+this.model.extraText
        if (this.debug) text = `${this.id} ` + text
        if (!text.length)
            newProps.showLabel = false
        else {
            newProps.text = text
            newProps.showLabel = true
            newProps.textLocation = this.model.textLocation
            newProps.vertLabelsAlign =
                this.model.textLocation === 'above' ? 'bottom' :
                this.model.textLocation === 'below' ?  'top' : null;
        }

        if (this.debug && this.id)
            console.log('newProps', this.id, chart.getShapeById(this.id).getProperties(), newProps)
        this.setProps(newProps)
    }

    onModel(model, oldModel, changedKeys) {}  // model was changed by a TradingView user action

    updateModel(changes) {
        const oldModel = {...this.model}
        if (this.debug) console.log('updateModel', this.id, changes)
        const changedKeys = []
        for (const k of Object.keys(changes)) {
            if( changes[k] !== undefined && changes[k] !== this.model[k] ) {
                changedKeys.push(k)
                this.model[k] = changes[k]
            }
        }
        if (changedKeys.length)
            this.onModel(this.model, oldModel, changedKeys)
    }

    colorProps() {
        if (!this.model.color&&!this.model.lineColor&&!this.model.textColor) return null
        const o = {}
        const p = this.type.drawingProp
        const lc = this.model.lineColor ? this.model.lineColor : this.model.color;
        const tc = this.model.textColor ? this.model.textColor : this.model.color;
        const tl = this.model.textLocation ? this.model.textLocation : 'above';
        if (lc)
            o[p+".linecolor"] = lc
        if (tc)
            o[p+".textcolor"] = tc
        if (tl)
            o[p+".textlocation"] = tl
        return o
    }

    draw() {
        // have the user draw this shape
        if (this.debug) console.log(`draw ${this.type.name}`, this.model)
        if (this.id)
            throw Error(`Shape already exists ${this.id}`)
        const or = this.drawingOverrides();
        // if (this.debug) console.log('drawing overrides', or)
        widget.applyOverrides(or)
        drawShape(this.type, new ShapeTVCallbacks(this))
    }

    // return an object with property defaults, e.g. { "linetoolhorzline.linecolor": "#7f11e0" }
    // https://www.tradingview.com/charting-library-docs/latest/api/modules/Charting_Library#drawingoverrides
    drawingOverrides() {
        return mixin(this.colorProps(), this.creationOptions)
    }

    create() {
        if (this.id !== null) return
        // programatically create the shape using the current this.points
        if( this.ourPoints && this.ourPoints.length ) {
            this.doCreate(this.ourPoints, this.ourProps, this.creationOptions)
        }
    }

    doCreate(points, props, options={}) {
        // createShape(this.type, this.points, {overrides:this.props}, new ShapeTVCallbacks(this))
        options = mixin(options, this.drawingOverrides())
        options['overrides'] = props
        this.tvPoints = this.pointsToTvOhlcStart(points)
        this.tvCallbacks = new ShapeTVCallbacks(this);
        const id = createShape(this.type, this.tvPoints, options, this.tvCallbacks)
        // todo set id?
        if (this.debug) console.log('created', id, this.type.name, this.tvPoints)
    }

    onCreate(points, props) {
        // the user has finished creating all the control points.  drawing mode is exited and the initial shape is created.
        this.setPoints(points)
        this.setProps(props)
    }


    createOrDraw() {
        if(this.id) return
        if(this.ourPoints && this.ourPoints.length)
            this.create()
        else
            this.draw()
    }


    tvShape() {
        try {
            return this.id === null ? null : chart.getShapeById(this.id);
        }
        catch (e) {
            console.error(`Could not get chart shape ${this.id}`)
            return null
        }
    }


    setPoints(points) {
        // setting points to null will delete the shape from the chart. setting points to a valid value will cause the
        // shape to be drawn.
        if (this.debug) console.error('setPoints', this.id, this.ourPoints, points)
        this.ourPoints = points
        if (points === null || !points.length)
            this.delete()
        else {
            if (this.id === null)
                this.create()
            else {
                points = this.pointsToTvOhlcStart(points)
                if (dirtyPoints(this.tvPoints, points)) {
                    /*
                    setPoints(e) {
                        if (this._source.isFixed())
                            return;
                        const t = o(this._source);
                        if (t !== e.length)
                            throw new Error(`Wrong points count. Required: ${t}, provided: ${e.length}`);
                        const i = this._pointsConverter.apiPointsToDataSource(e);
                        this._model.startChangingLinetool(this._source),
                        this._model.changeLinePoints(this._source, i),
                        this._model.endChangingLinetool(!0),
                        this._source.createServerPoints()
                    }
                    */
                    const s = this.tvShape();
                    const lbe = s._model._lineBeingEdited
                    const lpbc = s._model._linePointBeingChanged
                    const lpbe = s._model._linePointBeingEdited
                    if (lpbc!==null)
                        s._model.endChangingLinetool(!0)
                        // s._source.createServerPoints() // todo necessary?
                    s.setPoints(points)
                    if (lpbc!==null)
                        s._model.startChangingLinetool(lbe, lpbc, lpbe)
                }
            }
        }
    }

    // diagonals need to override this to adjust their price as well.
    pointsToTvOhlcStart(points, periodSeconds = null) {
        return points === null ? null : points.map((p) => {
            return {time: nearestOhlcStart(p.time, periodSeconds), price: p.price}
        })
    }

    onPoints(points) {} // the control points of an existing shape were changed

    onDrag(points) { this.onPoints(points) }

    setProps(props) {
        if (!props || Object.keys(props).length===0) return
        if (this.debug) console.log('setProps', this.id, props)
        this.ourProps = mixin(props, this.ourProps)
        if(this.id) {
            const p = dirtyItems(this.tvProps, props)
            this.tvProps = this.tvProps === null ? p : mixin(p, this.tvProps)
            if (this.debug) console.log('setting tv props', this.tvProps)
            this.tvShape().setProperties(p)
        }
    }

    onProps(props) {  // the display properties of an existing shape were changed
        if (this.debug) console.log('shape onProps', this.id, this.model, props)
        if (props.textcolor && typeof props.textcolor !== 'object' && props.textcolor !== this.tvProps.textcolor)
            this.updateModel({color:props.textcolor})
        else if (props.linecolor && typeof props.linecolor !== 'object' && props.linecolor !== this.tvProps.linecolor)
            this.updateModel({color:props.linecolor})
    }

    beingDragged() {
        return draggingShapeIds.indexOf(this.id) !== -1
    }


    delete() {
        if (this.debug) console.log('shape.delete', this.id)
        this.ourPoints = null
        this.tvPoints = null
        this.tvProps = null
        if (this.id === null) return
        if (this.tvCallbacks !== null) {
            this.tvCallbacks.enabled = false
            this.tvCallbacks = null
        }
        deleteShapeId(this.id)
        this.id = null
    }


    //
    // Overridable shape callbacks initiated by TradingView
    //

    onDraw() {}  // start drawing a new shape for the builder
    onRedraw() {}  // the mouse moved while in drawing mode
    onUndraw() {}  // drawing was canceled by clicking on a different tool
    onAddPoint() {}  // the user clicked a point while drawing (that point is added to the points list)
    onMove(points) {} // the shape was moved by dragging a drawing element not the control point
    onHide(props) {}
    onShow(props) {}
    onClick() {}  // the shape was selected
    onDelete() {}

}


class ShapeTVCallbacks {
    // These methods are called by TradingView and provide some default handling before invoking our own Shape callbacks

    constructor(shape) {
        this.shape = shape
        this.enabled = true  // gets disabled when the shape is deleted, squelching any further callbacks
    }

    onCreate(shapeId, _tvShape, points, props) {
        if (!this.enabled) return // possible when shape is deleted and re-created
        this.shape.id = shapeId
        invokeCallback(this.shape, 'onCreate', points, props)
        invokeCallback(this.shape, 'onPoints', points)
    }

    onPoints(shapeId, _tvShape, points) {
        if (!this.enabled) return // possible when shape is deleted and re-created
        if (this.shape.debug) console.log('tvcb onPoints', shapeId, points)
        if (!dragging || this.shape.beingDragged())
            this.shape.onPoints(points)
        this.shape.tvPoints = points
    }

    onProps(shapeId, _tvShape, props) {
        if (!this.enabled) return // possible when shape is deleted and re-created
        if (this.shape.debug) console.log('tvOnProps', shapeId, props)
        if (!dragging) // do not listen to props during redraw
            this.shape.onProps(props)
        this.shape.tvProps = props
    }

    onDraw() {
        if (!this.enabled) return // possible when shape is deleted and re-created
        invokeCallback(this.shape, 'onDraw')
    }

    onRedraw() {
        if (!this.enabled) return // possible when shape is deleted and re-created
        invokeCallback(this.shape, 'onRedraw')
    }

    onUndraw() {
        if (!this.enabled) return // possible when shape is deleted and re-created
        invokeCallback(this.shape, 'onUndraw')
    }

    onAddPoint() {
        if (!this.enabled) return // possible when shape is deleted and re-created
        invokeCallback(this.shape, 'onAddPoint')
    }

    onMove(shapeId, _tvShape, points) {
        if (!this.enabled) return // possible when shape is deleted and re-created
        invokeCallback(this.shape, 'onMove',points)
    }

    onDrag(shapeId, _tvShape, points) {
        if (!this.enabled) return // possible when shape is deleted and re-created
        if (this.shape.debug) console.log('onDrag', shapeId)
        invokeCallback(this.shape, 'onDrag', points)
    }

    onHide(shapeId, _tvShape, props) {
        if (!this.enabled) return // possible when shape is deleted and re-created
        invokeCallback(this.shape, 'onHide',props)
    }

    onShow(shapeId, _tvShape, props) {
        if (!this.enabled) return // possible when shape is deleted and re-created
        invokeCallback(this.shape, 'onShow',props)
    }

    onClick(shapeId, _tvShape) {
        if (!this.enabled) return // possible when shape is deleted and re-created
        invokeCallback(this.shape, 'onClick')
    }

    onDelete(shapeId) {
        this.enabled = false
        this.shape.id = null
        invokeCallback(this.shape, 'onDelete', shapeId)
    }
}


export class Line extends Shape {
}


export class HLine extends Line {
    constructor(model, onModel=null, onDelete=null, props=null, readonly=false) {
        super(ShapeType.HLine, onModel, onDelete, props, readonly, {
            // todo this isnt working
            linestyle: 0,
            linewidth: 2,
            italic: false,
        })

        // Model
        this.model.price = null

        this.setModel(model) // call setModel at the end
    }

    delete() {
        this.model.price = null
        super.delete()
    }


    setModel(model) {
        super.setModel(model)
        if (model.price === null) {
            this.model.price = null
            this.delete()
        }
        else if (model.price !== this.model.price) {
            this.model.price = model.price
            this.setPoints([{time:0,price:this.model.price}])
        }
    }

    onPoints(points) {
        super.onPoints(points);
        const price = points[0].price;
        this.updateModel({price})
    }

}


export class VLine extends Line {
    constructor(model, onModel=null, onDelete=null, props=null) {
        super(ShapeType.VLine, onModel, onDelete, props)

        // Model
        this.model.time = null

        this.setModel(model) // call setModel at the end
    }


    delete() {
        this.model.time = null
        super.delete()
    }


    setModel(model) {
        // if (this.debug) console.error('VLine setModel', this.id, {...this.model}, model)
        super.setModel(model)
        if (model.time === null)
            this.delete()
        else if (model.time !== this.model.time) {
            this.model.time = model.time
            this.setPoints([{time: model.time, price: 0}])
        }
    }

    onPoints(points) {
        const time = points[0].time;
        if ( time !== this.tvPoints[0].time ) {
            super.onPoints(points);
            this.updateModel({time: time})
        }
    }

}


export class DLine extends Line {
    constructor(model, onModel=null, onDelete=null, props=null, readonly=false) {
        super(ShapeType.Ray, onModel, onDelete, props, readonly,{
            // todo style overrides
        })

        // Model
        this.model.pointA = null  // {time:..., price:...}
        this.model.pointB = null
        this.model.extendLeft = true
        this.model.extendRight = true

        this.setModel(model) // call setModel at the end
    }


    drawingOverrides() {
        const result = super.drawingOverrides();
        result.extendLeft = this.model.extendLeft
        result.extendRight = this.model.extendRight
        return result
    }


    delete() {
        this.model.pointA = null
        this.model.pointB = null
        super.delete();
    }

    setModel(model) {
        super.setModel(model)
        if (model.pointA === null && model.pointB === null)
            this.delete()
        else {
            this.setProps({extendLeft:model.extendLeft, extendRight: model.extendRight})
            if ('pointA' in model && 'pointB' in model) {
                const newPoints = [model.pointA, model.pointB];
                const oldPoints = [this.model.pointA, this.model.pointB];
                if (dirtyPoints(oldPoints, newPoints)) {
                    this.model.pointA = newPoints[0]
                    this.model.pointB = newPoints[1]
                    this.setPoints(newPoints)
                }
            }
        }
    }

    onPoints(points) {
        let dirty = this.tvPoints === null
        for (let i=0; !dirty && i<points.length; i++) {
            if( points[i].time !== this.tvPoints[i].time || points[i].price !== this.tvPoints[i].price )
                dirty = true
        }
        if (dirty) {
            super.onPoints(points);
            this.updateModel({pointA: points[0], pointB: points[1]})
        }
    }

    onProps(props) {
        super.onProps(props);
        this.updateModel({extendLeft: props.extendLeft, extendRight: props.extendRight})
    }


/* todo tim
    pointsToTvOhlcStart(points, periodSeconds = null) {
        if (points === null) return null
        const [v,w] = points
        const [b,m] = computeInterceptSlope(v.time, v.price, w.time, w.price)
        points.map((p) => {
            let {time, price} = p
            const aligned = nearestOhlcStart(time, periodSeconds);
            if (time !== aligned) {
                time = aligned
                price = b + m * aligned
            }
            return {time, price}
        })
    }
*/
}


export class DateRange extends Shape {
    constructor(model, onModel=null, onDelete=null, props=null) {
        super(ShapeType.DateRange, onModel, onDelete, props)
    }


    setModel(model) {
        super.setModel(model);
        if (model.startTime !== this.model.startTime || model.endTime !== this.model.endTime)
            this.setPoints([{time: model.startTime}, {time: model.endTime}])
    }
}
