/* jshint esversion: 9 */

const version = 23095;

const fs = require('fs');
const fetch = require('node-fetch');

let condix = {}, ruleix = {};

if ( process.argv.length !== 4 ) {
    console.error("Usage: node tools/import_reactor_backup.js <reactor-backup-file> <controller-id>");
    process.exit(1);
}
if ( ! ( fs.existsSync( "app.js" ) && fs.existsSync( "storage" ) ) ) {
    console.error("ERROR: This script must only be run from the Reactor app directory.");
    process.exit(1);
}

let fn = process.argv[2];
let controller = process.argv[3];

let lastn = 0;
function getUID( prefix ) {
    let n = Date.now() - 1602820800000;
    if ( n <= lastn ) {
        n = lastn + 1;
    }
    lastn = n;
    return (prefix || "") + n.toString(36);
}

function isEmpty( s ) {
    return "undefined" === typeof s || null === s || "" === s;
}

function fixConditions1( node, parent, depth, rule ) {
    condix[ node.id ] = node;
    node.__parent = node.__parent || parent;
    if ( "group" === ( node.type || "group" ) ) {
        // console.log("fixConditions1 group", node.id, node.name, node.type);
        node.type = node.type || "group";
        node.op = node.operator || node.op || "and";
        node.__depth = depth || 0;
        delete node.operator;
        ( node.conditions || [] ).forEach( (n, ix) => {
            n.__index = ix;
            n.__rule = node.__rule;
            fixConditions1( n, node.id, node.__depth + 1, rule );
        });
    } else {
        // console.log("fixConditions1 cond", node.id, node.type);
        node.data = node.data || {};
        if ( "undefined" !== typeof node.operator ) {
            node.data.op = node.operator;
            delete node.operator;
            delete node.op;
        }
        if ( "group" !== ( node.type || "group" ) ) {
            Object.keys( node ).forEach( n => {
                if ( null === n.match( /^(id|name|type|data|options)$/ ) && null === n.match( /^__/ ) ) {
                    node.data[n] = node[n];
                    delete node[n];
                }
            });
            if ( "0" === node.data.nocase || 0 === node.data.nocase ) {
                node.data.nocase = false;
            } else if ( false !== node.data.nocase ) {
                delete node.data.nocase;
            }
        }
        if ( ( node.data.op || "").match( /^(change|bet|nob)$/ ) ) {
            m = ( node.data.value || "" ).split( /,/ );
            if ( m.length < 2 ) {
                m.push( null );
            }
            node.data.value = m;
        }
        /* Additional type-specific conversion */
        switch ( node.type ) {
            case "service": {
                    node.type = "entity";
                    node.data.entity = controller + ">device_" + node.data.device;
                    delete node.data.devicename;
                    delete node.data.deviceName;
                    delete node.data.device_name;
                    delete node.data.device;
                    /* ??? service/variable -> variable */
                    let k = ( node.data.service || "" ).replace( /(urn|serviceId):/ig, "" )
                        .replace( /[^a-z0-9]/ig, "_" );
                    node.data.attribute = 'x_vera_svc_' + k + "." + node.data.variable;
                    delete node.data.service;
                    delete node.data.variable;
                }
                break;

            case "var": {
                    node.data.rule = rule.id;
                }
                break;

            case "trange": {
                    let keys = [ "year", "mon", "day", "hr", "min" ];
                    let m = node.data.value || "";
                    if ( "string" === typeof m ) {
                        m = m.split( /,/ );
                    }
                    for ( let k=0; k<keys.length; ++k ) {
                        let v = parseInt( m[k] || "" );
                        if ( ! isNaN( v ) ) {
                            if ( ! node.data.start ) {
                                node.data.start = {};
                            }
                            if ( "mon" === keys[k] ) --v;
                            node.data.start[keys[k]] = v;
                        }
                    }
                    for ( let k=0; k<keys.length; ++k ) {
                        let v = parseInt( m[5+k] || "" );
                        if ( ! isNaN( v ) ) {
                            if ( ! node.data.end ) {
                                node.data.end = {};
                            }
                            if ( "mon" === keys[k] ) --v;
                            node.data.end[keys[k]] = v;
                        }
                    }
                    delete node.data.value;
                }
                break;

            case "weekday": {
                    /* MSR uses array, and Lua weekdays are 1=Sunday where JS is 0 */
                    node.data.days = [];
                    let m = node.data.value || "";
                    if ( "string" === typeof m ) {
                        m = m.split( /,/ );
                    }
                    m.forEach( day => {
                        if ( "" !== day ) {
                            node.data.days.push( parseInt(day)-1 );
                        }
                    });
                    delete node.data.value;
                }
                break;

            case "sun": {
                    let m = node.data.value || "";
                    if ( "string" === typeof m ) {
                        m = m.split( /,/ );
                    }
                    let s = m[0].match( /^([^+-]+)(.\d+)/ );
                    if ( s === null ) throw Error("Invalid sun condition start value in " + String(node.data.value) );
                    node.data.start = s[1];
                    node.data.start_offset = parseInt( s[2] ) || 0;
                    if ( m.length > 1 && m[1] !== "" ) {
                        s = m[1].match( /^([^+-]+)(.\d+)/ );
                        if ( s === null ) throw Error("Invalid sun condition end value in " + String(node.data.value) );
                        node.data.end = s[1];
                        node.data.end_offset = parseInt( s[2] ) || 0;
                    }
                    delete node.data.value;
                }
                break;

            case "notify": {
                    if ( "" === ( node.data.method ) ) {
                        node.type = "comment";
                        node.data = { comment: "This was a Vera-native notification; there is no translation for these. " };
                    }
                }
                break;

            case "housemode": {
                    node.type = "entity";
                    node.data = { entity: controller + ">housemode", attribute: 'x_vera_housemode.mode', value: node.data.value };
                    if ( "is" === node.data.op ) {
                        node.data.op = "in";
                    } else {
                        node.data.op = "change";
                    }
                }
                break;

            case "ishome": {
                    if ( "is" === node.data.op ) {
                        /* ??? only first user? */
                        node.type = "entity";
                        node.data = { entity: controller + ">user_" + node.data.value.split( /,/ )[0],
                            attribute: "x_vera_user.is_home", op: "istrue" };
                    } else if ( "is not" === node.data.op ) {
                        /* ??? only first user? */
                        node.type = "entity";
                        node.data = { entity: controller + ">user_" + node.data.value.split( /,/ )[0],
                            attribute: "x_vera_user.is_home", op: "isfalse" };
                    } else if ( "at" === node.data.op ) {
                        /* value is user,tagid */
                        node.type = "entity";
                        node.data = { entity: controller + ">geo_" + node.data.value.replace(/,/, '_'),
                            attribute: "x_vera_geotag.in_region", op: "istrue" };
                    } else {
                        /* value is user,tagid */
                        node.type = "entity";
                        node.data = { entity: controller + ">geo_" + node.data.value.replace(/,/, '_'),
                            attribute: "x_vera_geotag.in_region", op: "isfalse" };
                    }
                }
                break;

            case "grpstate": {
                    node.type = "rule";
                    node.data.rule = node.data.groupid;
                    delete node.data.groupid;
                    delete node.data.groupname;
                    delete node.data.device;
                    delete node.data.devicename;
                }
                break;

            case 'interval': {
                    if ( isEmpty( node.data.relcond ) ) {
                        /* Relative to time (default) */
                        let n = ( node.data.basetime || "0,0" ).split( /,/ );
                        if ( ! isEmpty( node.data.basedate ) ) {
                            let m = node.data.basedate.split( /,/ );
                            m[1] = parseInt(m[1]) - 1;
                            node.data.basedate = new Date( m[0], m[1], m[2], n[0], n[1], 0 ).getTime();
                            delete node.data.basetime;
                        } else {
                            delete node.data.basedate;
                            node.data.basetime = { hr: parseInt(n[0]), min: parseInt(n[1]) };
                        }
                        delete node.data.relcond;
                        delete node.data.relto;
                    }
                }
                break;

            case 'reload': {
                    node.type = 'entity';
                    node.data = { entity: controller + '>system', attribute: 'x_vera_sys.reloads', op: 'change' };
                }
                break;

            default:
                /* Nada */
        }
        // console.log("fixConditions1 final", node );
    }
}

function checkActivity( activity, rule ) {
    ( activity.groups || [] ).forEach( group => {
        ( group.actions || [] ).forEach( action => {
            Object.keys( action ).forEach( n => {
                if ( null === n.match( /^(id|type|index|data)$/ ) &&
                    null === n.match( /^__/ ) ) {
                    if ( ! action.data ) {
                        action.data = {};
                    }
                    let m = String( action[ n ] ).match( /{[^}]+}/ );
                    if ( m ) {
                        let v = String( action[ n ] ).replace( /{([^}]+)}/g, "${{ $1 }}" );
                        action.data[ n ] = v;
                    } else {
                        action.data[ n ] = action[ n ];
                    }
                    delete action[ n ];
                }
            });
            switch( action.type ) {
                case 'device': {
                        action.type = "entity";
                        let k = ( action.data.service || "" ).replace( /(urn|serviceId):/ig, "" )
                            .replace( /[^a-z0-9]/ig, "_" );
                        action.data.action = 'x_vera_svc_' + k + "." + action.data.action;
                        if ( action.data.parameters ) {
                            action.data.args = {};
                            Object.values( action.data.parameters ).forEach( o => {
                                let val = String( o.value ).replace( /{([^}]+)}/g, "\${{ $1 }}" );
                                action.data.args[ o.name ] = val;
                            });
                            delete action.data.parameters;
                        }
                        action.data.entity = controller + ">device_" + action.data.device;
                        delete action.data.device;
                        delete action.data.devicename;
                        delete action.data.service;
                    }
                    break;

                case 'housemode': {
                        let n = parseInt( action.data.housemode ) || 0;
                        action.type = 'entity';
                        action.data = { entity: controller + '>housemode', action: 'x_vera_housemode.set', args: {} };
                        action.data.args.mode = (['home','home','away','night','vacation'])[ n ];
                    }
                    break;

                case 'runscene': {
                        action.type = 'entity';
                        action.data = { entity: controller + '>scene_' + action.data.scene, action: 'script.run' };
                    }
                    break;

                case 'rungsa':
                case 'stopgsa':
                    action.type = action.type.replace( "gsa", "" );
                    break;

                case 'request':
                    if ( Array.isArray( action.data.headers ) ) {
                        action.data.headers.forEach( (obj,ix) => {
                            let m = obj.match( /^([^:]+):\s*(.*)$/ );
                            if ( null !== m ) {
                                action.data.headers[ ix ] = { key: m[1].trim(), value: m[2].trim() };
                            } else {
                                action.data.headers[ ix ] = { key: obj, value: "" };
                            }
                        });
                    }
                    break;

                case 'runlua':
                    action.type = "entity";
                    let ltext = action.data.lua;
                    if ( action.data.encoded_lua ) {
                        ltext = Buffer.from( ltext, "base64" ).toString( 'utf-8' );
                    }
                    action.data = { entity: controller + '>system', action: 'x_vera_sys.runlua', args: { lua: ltext } };
                    delete action.data.lua;
                    delete action.data.encoded_lua;
                    break;

                case 'resetlatch':
                    action.type = "comment";
                    action.data = { comment: "\"Reset Latch\" action was here; not supported in MSR" };
                    break;

                case 'notify': {
                        action.data.method = { SM: 'SMTP', PO: 'Pushover', PR: 'Prowl', SY: 'Syslog' }[ action.data.method ] || "Alert";
                        delete action.data.notifyid;
                    }
                    break;

                case 'setvar': {
                        action.data.var = action.data.variable;
                        action.data.rule = rule.id;
                        delete action.data.variable;
                    }
                    break;

                default:
                    /* Nada */
            }
        });
    });
}


/* GO! */

/* Fetch rulesets from API */
console.log("Fetching current rulesets...");
fetch( 'http://127.0.0.1:8111/api/v1/rulesets', { timeout: 15000 } ).then( resp => {
    resp.json().then( rulesets => {
        let promoted = {};

        (rulesets || []).forEach( ruleset => {
            ( ruleset.rules || [] ).forEach( rule => {
                ruleix[ rule.id ] = rule;
                rule.__ruleset = ruleset.id;
                rule.triggers.__rule = rule.id;
            });
        });

        /* Read the input backup file */
        let r;
        try {
            r = fs.readFileSync( fn );
        } catch ( err ) {
            console.error("Can't read " + fn + ":", err);
            process.exit(2);
        }
        try {
            r = JSON.parse( r );
            if ( "object" !== typeof r.sensors ) {
                throw new Error("File does not contain a Vera/openLuup Reactor backup");
            }
        } catch ( err ) {
            console.error("Can't parse backup data from " + fn + ":", err);
            process.exit(2);
        }
        if ( ! (r.version || "").match( /^3\./ ) ) {
            console.error("The backup file must be from a current version of Vera Reactor. This file reports", r.version);
            process.exit(3);
        }

        let numRS = 0;
        let numRule = 0;
        let numPromote = 0;

        console.log("Pass 1: Translating...");

        /* Pass one, convert each ReactorSensor to a RuleSet, and fix up contents. */

        Object.keys( r.sensors ).forEach( sensorid => {
            let sensor = r.sensors[ sensorid ];
            let config = sensor.config || {};
            console.log("ReactorSensor", sensorid, sensor.name);
            ++numRS;
            let ruleset = rulesets.find( el => ( "rs" + sensorid ) === el.id );
            if ( !ruleset ) {
                console.log( "Creating ruleset for ReactorSensor ", sensorid, sensor.name );
                ruleset = { id: "rs" + sensorid, name: sensor.name, rules: [] };
                rulesets.push( ruleset );
            }
            ruleset.imported_from = fn;
            ruleset.imported_on = Date.now();
            let rule = ruleix[ 'rule-' + sensorid ];
            if ( !rule ) {
                rule = { id: "rule-" + sensorid, name: ( ( config.conditions || {} ).root || {} ).name || sensor.name,
                    imported_from: fn, imported_on: Date.now() };
                ruleset.rules = ruleset.rules || [];
                ruleset.rules.push( rule );
                console.log( "Creating rule", rule.id );
                rule.__ruleset = ruleset.id;
                ruleix[ rule.id ] = rule;
                ++numRule;
            } else {
                console.log( "Rewriting rule", rule.id );
            }
            rule.imported_from = fn;
            rule.imported_on = Date.now();
            rule.imported_with = version;
            rule.options = {};
            rule.triggers = ( config.conditions || {} ).root || { op: "and", conditions: [] };
            rule.triggers.__rule = rule.id;
            rule.triggers.id = rule.id + '-trig';
            rule.triggers.name = "Triggers";
            rule.triggers.disabled = true;
            rule.triggers.type = "group";
            fixConditions1( rule.triggers, undefined, 0, rule );
            rule.constraints = rule.constraints ||
                { id: rule.id + '-const', type: 'group', conditions: [], op: 'and' };

            /* If root (trig) has options, no can do, push down to new group with new ID */
            if ( Object.keys( rule.triggers.options || {} ).length > 0 ) {
                let new_grp = { ...rule.triggers }; /* copy */
                rule.triggers.conditions = [ new_grp ];
                delete rule.triggers.options;
                new_grp.id = getUID( 'grp' );
                new_grp.name = rule.name;
                delete new_grp.disabled;
            }

            /* Actvities */
            var flatten_activity = function( dest, act ) {
                ( act.groups || [] ).forEach( group => {
                    if ( group.delay ) {
                        dest.push( { type: 'delay', data: { delay: group.delay } } );
                    }
                    ( group.actions || [] ).forEach( act => {
                        dest.push( act );
                    });
                });
            };
            Object.keys( config.activities || {} ).forEach( key => {
                let m = key.match( /^([^.]+)\.(true|false)/ );
                checkActivity( config.activities[key], rule );
                if ( m && m[1] !== "root" ) {
                    let cond = condix[ m[1] ];
                    if ( cond ) {
                        if ( m[2] === 'false' ) {
                            cond.__react_reset = [];
                            flatten_activity( cond.__react_reset, config.activities[key] );
                        } else {
                            cond.__react_set = [];
                            flatten_activity( cond.__react_set, config.activities[key] );
                        }
                    }
                }
            });
            /* Save the root activities on the rule */
            rule.react_set = { id: rule.id + ":S", rule: rule.id };
            rule.react_set.actions = [];
            flatten_activity( rule.react_set.actions,
                ( config.activities || {} )[ 'root.true' ] || {} );
            rule.react_reset = { id: rule.id + ":R", rule: rule.id };
            rule.react_reset.actions = [];
            flatten_activity( rule.react_reset.actions,
                ( config.activities || {} )[ 'root.false' ] || {} );

            /* Expressions */
            Object.keys( config.variables || {} ).forEach( varname => {
                let v = config.variables[ varname ];
                rule.expressions = rule.expressions || {};
                let expr = v.expression;
                let m = expr.match( /getstate\(\s*([^,]+),\s*([^,]+),\s*([^)]+)/ );
                if ( m ) {
                    try {
                        let cap = m[2].trim();
                        cap = cap.replace( /["']([^"']+)["']/, "$1" );
                        cap = cap.replace( /(urn|serviceId):/g, "" ).replace( /[^a-z0-9]/ig, "_" );
                        expr = expr.substring( 0, m.index ) +
                            'getEntity(' + m[1] + ').attributes.x_vera_svc_' + cap + '.' + m[3].replace( /["']([^"']+)["']/, "$1" ) +
                            expr.substring( m.index + m[0].length );
                    } catch ( err ) {
                        console.log( "Could not update getstate() expression; you'll need to do it manually.", expr );
                    }
                }
                rule.expressions[ varname ] = { name: varname, expr: expr, index: v.index || 32767 };
            });
        });

        /* Pass two, find non-root groups referenced by other conditions, and promote them to stand-alone rules */

        console.log( "Pass 2: Rule restructure..." );

        Object.keys( condix ).forEach( nodeid => {
            let node = condix[nodeid];
            if ( !node ) {
                /* Already promoted group, don't scan. */
                return;
            }
            let promote = false;
            if ( ( node.__react_set || [] ).length > 0 || ( node.__react_reset || [] ).length > 0 ) {
                /* Actions on subgroup, not rule. Must promote */
                promote = node.id;
                console.log("Group", node.id, "has activities, promoting to rule");
            } else if ( "rule" === node.type ) {
                let rule = ruleix[ node.data.rule ];
                if ( !rule ) {
                    if ( ! promoted[ node.data.rule ] ) {
                        console.log("Found 'rule' condition in", node.id, "referring to group", node.data.rule,
                            ", promoting target group to rule");
                        promote = node.data.rule;
                    } else {
                        // console.log( "Group",node.data.r,"already promoted to rule",promoted[node.data.rule]);
                        node.data.rule = promoted[ node.data.rule ];
                    }
                }
            } else if ( "group" === ( node.type || "group" ) && "nul" !== node.op && node.__parent && "nul" === node.__parent.op ) {
                /* Non-null group with null parent group; promote. */
                console.log( "Group", node.id, "in NUL group, assuming modular logic and promoting to rule" );
                promote = node.id;
            }
            let grp = false;
            if ( promote ) {
                grp = condix[ promote ];
            }
            if ( grp ) {
                /* Not yet promoted */
                console.log("Promoting", promote);
                ++numPromote;
                let parent = grp.__parent;
                let grpix = grp.__index;
                rule = ruleix[ 'rule-' + grp.id ];
                if ( !rule ) {
                    rule = { id: "rule-" + grp.id, name: grp.name || grp.id };
                    ruleix[ rule.id ] = rule;
                    /* Now make sure the new rule is a member of the (parent's) ruleset */
                    let parent_rule = ruleix[ condix[ parent ].__rule ];
                    // console.log("parent rule is", parent_rule);
                    let rulesetid = parent_rule.__ruleset;
                    let ruleset = rulesets.find( el => el.id === rulesetid );
                    if ( ! ruleset ) {
                        throw new Error("missing ruleset " + rulesetid);
                    }
                    rule.__ruleset = ruleset.id;
                    ruleset.rules.push( rule );
                    console.log("Created new rule for promotion", rule.id, rule.name,
                        "appended to ruleset", ruleset.id, ruleset.name);
                }
                rule.triggers = grp;
                rule.triggers.name = 'Triggers';
                rule.triggers.id = rule.id + '-trig';
                rule.triggers.disabled = true;
                rule.constraints = rule.constraints ||
                    { id: rule.id + '-const', type: 'group', conditions: [], op: 'and' };
                promoted[ promote ] = rule.id;
                if ( "rule" === node.type ) {
                    node.data.rule = rule.id;
                }
                grp.__depth = 0;
                delete grp.__parent;
                delete grp.__index;
                grp.__rule = rule.id;
                rule.imported_from = fn;
                rule.imported_on = Date.now();
                rule.imported_with = version;
                rule.options = {};

                /* Now detach subgroup from parent and replace with rule condition */
                let pgrp = condix[ parent ];
                let cond = { id: "cond" + getUID(), type: "rule", data: { rule: rule.id, op: 'istrue' },
                    __parent: parent, __index: grpix, __depth: pgrp.depth + 1 };
                pgrp.conditions[ grpix ] = cond;
                condix[ cond.id ] = cond;

                /* Move activities */
                rule.react_set = { id: rule.id + ":S", rule: rule.id };
                rule.react_set.actions = grp.__react_set || [];
                rule.react_reset = { id: rule.id + ":R", rule: rule.id };
                rule.react_reset.actions = grp.__react_reset || [];

                /* Delete promoted group from index??? */
                delete condix[ grp.id ];
            }
        });

        /* Write */

        console.log("Processing of", numRS, "ReactorSensors done; created", numRule,
            "new MSR rules, promoted", numPromote, "groups to rules.");

        console.log("Writing new rules, please wait...");

        fs.writeFileSync( "convert.tmp", JSON.stringify( rulesets, ( key, val ) => {
                return key.match( /^__/ ) ? undefined : val;
            }, 4 ) );

        rulesets.forEach( set => {
            ( set.rules || [] ).forEach( rule => {
                fetch( 'http://127.0.0.1:8111/api/v1/data/rules/' + rule.id + '/set', {
                    method: 'PUT',
                    body: JSON.stringify( { id: rule.id, container: "rules", tss: 0, tsc: 0, value: rule },
                        ( key, val ) => { return key.match( /^__/ ) ? undefined : val; }, 4 ),
                    headers: { 'content-type': 'application/json' }
                });
            });

            r = ( set.rules || [] ).map( rule => rule.id );
            set.rules = r;
            fetch( 'http://127.0.0.1:8111/api/v1/data/rulesets/' + set.id + '/set', {
                method: 'PUT',
                body: JSON.stringify( { id: set.id, container: "rulesets", tss: 0, tsc: 0, value: set },
                    ( key, val ) => { return key.match( /^__/ ) ? undefined : val; }, 4 ),
                headers: { 'content-type': 'application/json' }
            });
        });

        r = rulesets.map( set => set.id );
        fetch( 'http://127.0.0.1:8111/api/v1/data/rulesets/index/set', {
            method: 'PUT',
            body: JSON.stringify( { id: "index", container: "rulesets", tss: 0, tsc: 0, value: r },
                ( key, val ) => { return key.match( /^__/ ) ? undefined : val; }, 4 ),
            headers: { 'content-type': 'application/json' }
        });

        console.log( "" );
        console.log("Done! Now please stop and restart Reactor.");
    });

}).catch( err => {
    console.error(err);
    console.error("Make sure Reactor is up and running when you run this tool.");
});

/* ??? notifications -> data.message */
