// Package lua_client implementes client for fritzbox lua UI API
package lua_client

// Copyright 2020 Andreas Krebs
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import (
	"bytes"
	"crypto/md5"
	"encoding/json"
	"encoding/xml"
	"errors"
	"fmt"
	"io/ioutil"
	"net/http"
	"regexp"
	"strconv"
	"strings"

	"golang.org/x/text/encoding/unicode"
	"golang.org/x/text/transform"
)

// SessionInfo XML from login_sid.lua
type SessionInfo struct {
	SID       string `xml:"SID"`
	Challenge string `xml:"Challenge"`
	BlockTime int    `xml:"BlockTime"`
	Rights    string `xml:"Rights"`
}

// LuaSession for storing connection data and SID
type LuaSession struct {
	BaseURL     string
	Username    string
	Password    string
	SID         string
	SessionInfo SessionInfo
}

// LuaPage identified by path and params
type LuaPage struct {
	Path   string
	Params string
}

// LuaMetricValueDefinition definition for a single metric
type LuaMetricValueDefinition struct {
	Path    string
	Key     string
	OkValue string
	Labels  []string
}

// LuaMetricValue single value retrieved from lua page
type LuaMetricValue struct {
	Name   string
	Value  float64
	Labels map[string]string
}

// LabelRename regex to replace labels to get rid of translations
type LabelRename struct {
	Pattern regexp.Regexp
	Name    string
}

func (lua *LuaSession) doLogin(response string) error {
	urlParams := ""
	if response != "" {
		urlParams = fmt.Sprintf("?response=%s&user=%s", response, lua.Username)
	}

	resp, err := http.Get(fmt.Sprintf("%s/login_sid.lua%s", lua.BaseURL, urlParams))
	if err != nil {
		return fmt.Errorf("Error calling login_sid.lua: %s", err.Error())
	}

	defer resp.Body.Close()
	dec := xml.NewDecoder(resp.Body)

	err = dec.Decode(&lua.SessionInfo)
	if err != nil {
		return fmt.Errorf("Error decoding SessionInfo: %s", err.Error())
	}

	if lua.SessionInfo.BlockTime > 0 {
		return fmt.Errorf("To many failed logins, login blocked for %d seconds", lua.SessionInfo.BlockTime)
	}

	return nil
}

func (lmvDef *LuaMetricValueDefinition) createValue(name string, value float64) LuaMetricValue {
	lmv := LuaMetricValue{
		Name:   name,
		Value:  value,
		Labels: make(map[string]string),
	}

	return lmv
}

// Login perform loing and get SID
func (lua *LuaSession) Login() error {

	err := lua.doLogin("")
	if err != nil {
		return err
	}

	challenge := lua.SessionInfo.Challenge
	if lua.SessionInfo.SID == "0000000000000000" && challenge != "" {
		// no SID, but challenge so calc response
		hash := utf16leMd5(fmt.Sprintf("%s-%s", challenge, lua.Password))
		response := fmt.Sprintf("%s-%x", challenge, hash)
		err := lua.doLogin(response)

		if err != nil {
			return err
		}
	}

	sid := lua.SessionInfo.SID
	if sid == "0000000000000000" || sid == "" {
		return errors.New("LUA login failed - no SID received - check username and password")
	}

	lua.SID = sid

	return nil
}

// LoadData load a lua bage and return content
func (lua *LuaSession) LoadData(page LuaPage) ([]byte, error) {
	method := "POST"
	path := page.Path

	// handle method prefix
	pathParts := strings.SplitN(path, ":", 2)
	if len(pathParts) > 1 {
		method = pathParts[0]
		path = pathParts[1]
	}

	dataURL := fmt.Sprintf("%s/%s", lua.BaseURL, path)

	callDone := false
	var resp *http.Response
	var err error
	for !callDone {
		// perform login if no SID or previous call failed with (403)
		if lua.SID == "" || resp != nil {
			err = lua.Login()
			callDone = true // consider call done, since we tried login

			if err != nil {
				return nil, err
			}
		}

		// send by UI for data.lua: xhr=1&sid=xxxxxxx&lang=de&page=energy&xhrId=all&no_sidrenew=
		// but SID and page seem to be enough
		params := "sid=" + lua.SID
		if page.Params != "" {
			params += "&" + page.Params
		}

		if method == "POST" {
			resp, err = http.Post(dataURL, "application/x-www-form-urlencoded", bytes.NewBuffer([]byte(params)))
		} else if method == "GET" {
			resp, err = http.Get(dataURL + "?" + params)
		} else {
			err = fmt.Errorf("method %s is unsupported in path %s", method, page.Path)
		}

		if err != nil {
			return nil, err
		}
		defer resp.Body.Close()

		if resp.StatusCode == http.StatusOK {
			callDone = true
		} else if resp.StatusCode == http.StatusForbidden && !callDone {
			// we assume SID is expired, so retry login
		} else {
			return nil, fmt.Errorf("%s failed: %s", page.Path, resp.Status)
		}
	}

	body, err := ioutil.ReadAll(resp.Body)

	if err != nil {
		return nil, err
	}

	return body, nil
}

// ParseJSON generic parser for unmarshalling into map
func ParseJSON(jsonData []byte) (map[string]interface{}, error) {
	var data map[string]interface{}

	// Unmarshal or Decode the JSON to the interface.
	json.Unmarshal(jsonData, &data)

	return data, nil
}

func getRenamedLabel(labelRenames *[]LabelRename, label string) string {
	if labelRenames != nil {
		for _, lblRen := range *labelRenames {
			if lblRen.Pattern.MatchString(label) {
				return lblRen.Name
			}
		}
	}

	return label
}

func getValueFromHashOrArray(mapOrArray interface{}, key string, path string) (interface{}, error) {
	var value interface{}

	switch moa := mapOrArray.(type) {
	case map[string]interface{}:
		var exists bool
		value, exists = moa[key]
		if !exists {
			return nil, fmt.Errorf("hash '%s' has no element '%s'", path, key)
		}
	case []interface{}:
		// since type is array there can't be any labels to differentiate values, so only one value supported !
		index, err := strconv.Atoi(key)
		if err != nil {
			return nil, fmt.Errorf("item '%s' is an array, but index '%s' is not a number", path, key)
		}

		if index < 0 {
			// this is an index from the end of the values
			index += len(moa)
		}

		if index < 0 || index >= len(moa) {
			return nil, fmt.Errorf("index %d is invalid for array '%s' with length %d", index, path, len(moa))
		}
		value = moa[index]
	default:
		return nil, fmt.Errorf("item '%s' is not a hash or array, can't get value %s", path, key)
	}

	return value, nil
}

// GetMetrics get metrics from parsed lua page for definition and rename labels
func GetMetrics(labelRenames *[]LabelRename, data map[string]interface{}, metricDef LuaMetricValueDefinition) ([]LuaMetricValue, error) {

	var values []interface{}
	var err error
	if metricDef.Path != "" {
		pathItems := strings.Split(metricDef.Path, ".")
		values, err = _getValues(data, pathItems, "")
		if err != nil {
			return nil, err
		}
	} else {
		values = make([]interface{}, 1)
		values[0] = data
	}

	metrics := make([]LuaMetricValue, 0)
	keyItems := strings.Split(metricDef.Key, ".")

VALUE:
	for _, pathVal := range values {
		valUntyped := pathVal
		path := metricDef.Path

		// now handle if key is also splitted
		for _, key := range keyItems {
			valUntyped, err = getValueFromHashOrArray(valUntyped, key, path)
			if err != nil {
				// since we may have other values, we simply continue (should we report it?)
				continue VALUE
			}

			if path != "" {
				path += "."
			}
			path += key
		}

		var sVal = toString(valUntyped)
		var floatVal float64
		if metricDef.OkValue != "" {
			if metricDef.OkValue == sVal {
				floatVal = 1
			} else {
				floatVal = 0
			}
		} else {
			// convert value to float
			floatVal, err = strconv.ParseFloat(sVal, 64)
			if err != nil {
				continue VALUE
			}
		}

		// create metric value
		lmv := metricDef.createValue(path, floatVal)

		// add labels if pathVal is a hash
		valMap, isType := pathVal.(map[string]interface{})
		if isType {
			for _, l := range metricDef.Labels {
				lv, exists := valMap[l]
				if exists {
					lmv.Labels[l] = getRenamedLabel(labelRenames, toString(lv))
				}
			}
		}

		metrics = append(metrics, lmv)
	}

	if len(metrics) == 0 {
		if err == nil {
			// normal we should already have an error, this is just a fallback
			err = fmt.Errorf("no value found for item '%s' with key '%s'", metricDef.Path, metricDef.Key)
		}
		return nil, err
	}

	return metrics, nil
}

// from https://stackoverflow.com/questions/33710672/golang-encode-string-utf16-little-endian-and-hash-with-md5
func utf16leMd5(s string) []byte {
	enc := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewEncoder()
	hasher := md5.New()
	t := transform.NewWriter(hasher, enc)
	t.Write([]byte(s))
	return hasher.Sum(nil)
}

// helper for retrieving values from parsed JSON
func _getValues(data interface{}, pathItems []string, parentPath string) ([]interface{}, error) {

	var err error
	values := make([]interface{}, 0)
	value := data
	curPath := parentPath

	for i, p := range pathItems {
		if p == "*" {
			// handle * case to get all values
			var subvals []interface{}
			switch vv := value.(type) {
			case []interface{}:
				for index, u := range vv {
					subvals, err = _getValues(u, pathItems[i+1:], fmt.Sprintf("%s.%d", curPath, index))

					if subvals != nil {
						values = append(values, subvals...)
					}
				}
			case map[string]interface{}:
				for subK, subV := range vv {
					subvals, err = _getValues(subV, pathItems[i+1:], fmt.Sprintf("%s.%s", curPath, subK))

					if subvals != nil {
						values = append(values, subvals...)
					}
				}
			default:
				err = fmt.Errorf("item '%s' is neither a hash or array", curPath)
			}

			if len(values) == 0 {
				if err == nil {
					err = fmt.Errorf("item '%s.*' has no values", curPath)
				}

				return nil, err
			}

			return values, nil
		}

		// this is a single value
		value, err = getValueFromHashOrArray(value, p, curPath)
		if err != nil {
			return nil, err
		}

		if curPath == "" {
			curPath = p
		} else {
			curPath += "." + p
		}
	}

	values = append(values, value)

	return values, nil
}

func toString(value interface{}) string {
	// should we better check or simple convert everything ????
	return fmt.Sprintf("%v", value)
}