package requestor

import (
	"bytes"
	"crypto/cipher"
	"crypto/ed25519"
	"crypto/sha512"
	"crypto/tls"
	"encoding/base64"
	"encoding/binary"
	"encoding/json"
	"fmt"
	"io"
	"io/ioutil"
	"math/rand"
	"net/http"
	"net/http/cookiejar"
	liburl "net/url"
	"strings"
	"time"

	"gitlab.wtotem.net/webtotem/logger"
	"golang.org/x/net/publicsuffix"

	// "golang.org/x/crypto/blake2b"
	"golang.org/x/crypto/chacha20"
	"golang.org/x/crypto/curve25519"
	"golang.org/x/crypto/poly1305"
)

var log logger.Logger
var defaultProxies []string

func init() {
	log = logger.NewLogger(&logger.LoggerInfo{})
	cnf, err := initConfig()
	if err != nil {
		log.Errorf("Error while init config. Error: %v", err)
		return
	}
	defaultProxies = cnf.Get("proxies").StringSlice(nil)
	log.Debugf("Proxies len: %d", len(defaultProxies))
}

const (
	tempLen                = 32
	sessionStartRetryCount = 1
)

// Command - struct for description command for sending to PHP agent
type Command struct {
	Module string      `json:"module"`
	Key    string      `json:"key"`
	Method string      `json:"method"`
	CMD    string      `json:"cmd"`
	Params interface{} `json:"params"`
}

// AgentSession - main type for sending request to agents
type AgentSession interface {
	Start() error
	SendRequest(cmd *Command, result interface{}) error
	RawSendRequest(cmd *Command) (io.Reader, error)
	SendRequestWithTimeout(cmd *Command, result interface{}, timeout time.Duration) error
	RawSendRequestWithTimeout(cmd *Command, timeout time.Duration) (io.Reader, error)

	Stop()
}

// AgentSessionImplementor - implementation of AgentSession type
type AgentSessionImplementor struct {
	AgentURL   string
	AgentKey   string
	PrivateKey string
	SessionKey string
	UserID     string

	SessionTimeout time.Duration

	log LibLogger
	//	Cookie        string
	//cookieJar     *cookiejar.Jar
	client        *http.Client
	LastTimestamp uint64
}

// getNonce - function for generating random 12-byte nonce
func getNonce() []byte {
	chars := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789=#$%!@&*?.,<>/\\"
	b := make([]byte, 12)
	for i := range b {
		b[i] = chars[rand.Intn(len(chars))]
	}
	return b
}

// encrypt - method for encrypt request to agent
func (session *AgentSessionImplementor) encrypt(data []byte) ([]byte, error) {
	var polyKey [32]byte
	if len(session.SessionKey) != 32 || len(session.AgentKey) != 32 {
		return nil, fmt.Errorf("[ERROR]: Incorrect key or token. Length must be 32. Length %d", len(session.SessionKey))
	}
	for i, it := range session.AgentKey {
		polyKey[i] = byte(it)
	}

	nonce := getNonce()
	var mac [16]byte
	chacha20stream, err := chacha20.NewUnauthenticatedCipher([]byte(session.SessionKey), nonce)
	if err != nil {
		return nil, fmt.Errorf("Error! while creating chacha20 cipher. Error: %v", err)
	}
	var out bytes.Buffer
	writer := &cipher.StreamWriter{S: chacha20stream, W: &out}
	_, err = writer.Write(data)
	if err != nil {
		return nil, fmt.Errorf("Error! while encoding message. Error: %v", err)
	}
	poly1305.Sum(&mac, out.Bytes(), &polyKey)
	result := nonce
	for _, it := range mac {
		result = append(result, it)
	}
	result = append(result, out.Bytes()...)
	return result, nil
}

// decrypt - method for decrypt response from agent
func (session *AgentSessionImplementor) decrypt(data []byte) ([]byte, error) {
	if len(data) < 29 {
		return nil, fmt.Errorf("Error! Body length is too low. Length: %d", len(data))
	}
	session.log.Infof("[session.decrypt]: body: %.100s", data)
	var polyKey [32]byte
	for i, it := range session.AgentKey {
		polyKey[i] = byte(it)
	}

	nonce := data[:12]
	var mac [16]byte
	for i, it := range data[12:28] {
		mac[i] = it
	}
	body := data[28:]
	if !poly1305.Verify(&mac, body, &polyKey) {
		return nil, fmt.Errorf("Error! MAC checking false")
	}
	chacha20stream, err := chacha20.NewUnauthenticatedCipher([]byte(session.SessionKey), nonce)
	if err != nil {
		return nil, fmt.Errorf("Error! while creating chacha20 cipher. Error: %v", err)
	}
	reader := &cipher.StreamReader{S: chacha20stream, R: bytes.NewReader(body)}
	return ioutil.ReadAll(reader)
}

// decrypt - method for decrypt response from agent
func (session *AgentSessionImplementor) streamDecrypt(data []byte) (io.Reader, error) {
	if len(data) < 29 {
		return nil, fmt.Errorf("Error! Body length is too low. Length: %d", len(data))
	}
	session.log.Infof("[session.decrypt]: body: %.100s", data)
	var polyKey [32]byte
	for i, it := range session.AgentKey {
		polyKey[i] = byte(it)
	}

	nonce := data[:12]
	var mac [16]byte
	for i, it := range data[12:28] {
		mac[i] = it
	}
	body := data[28:]
	if !poly1305.Verify(&mac, body, &polyKey) {
		return nil, fmt.Errorf("Error! MAC checking false")
	}
	chacha20stream, err := chacha20.NewUnauthenticatedCipher([]byte(session.SessionKey), nonce)
	if err != nil {
		return nil, fmt.Errorf("Error! while creating chacha20 cipher. Error: %v", err)
	}
	reader := &cipher.StreamReader{S: chacha20stream, R: bytes.NewReader(body)}
	return reader, nil
}

// postRequest - method for sending and receiving raw data to PHP agent
func (session *AgentSessionImplementor) postRequest(url string, data []byte, depth int, isNewTimeout bool, timeout time.Duration) (response []byte, err error) {
	if depth >= 3 {
		session.log.Errorf("[postRequest]: Too many redirections!")
		return nil, fmt.Errorf("Too many redirections!")
	}
	defer func() {
		if err != nil && depth < 1 {
			session.log.Warnf("[postRequest]: Error: %v. Try to change scheme", err)
			var parsedURL *liburl.URL
			parsedURL, err = liburl.ParseRequestURI(url)
			if err != nil {
				session.log.Errorf("[postRequest]: Error while parsing URL. Error: %v", err)
				return
			}
			parsedURL.Host = parsedURL.Hostname()
			switch parsedURL.Scheme {
			case "http":
				parsedURL.Scheme = "https"
			case "https":
				parsedURL.Scheme = "http"
			}
			session.log.Infof("[postRequest]: Try to send request with another scheme. URL: %s", parsedURL.String())
			response, err = session.postRequest(parsedURL.String(), data, depth+1, isNewTimeout, timeout)
		}
	}()
	currentTimestamp := make([]byte, 8)
	binary.LittleEndian.PutUint64(currentTimestamp, uint64(time.Now().UnixNano()))
	body := append(currentTimestamp, data...)
	req, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
	req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.183 Safari/537.36 Vivaldi/1.96.1147.42")

	privateKey, err := base64.StdEncoding.DecodeString(session.PrivateKey)
	if err != nil {
		return nil, err
	}
	if len(privateKey) != 64 || len(session.AgentKey) != 32 {
		return nil, fmt.Errorf("Error! Private key length is not correct. Must be 64. Length: %v", len(privateKey))
	}
	//req.Header.Set("Cookie", session.Cookie)
	// sign request
	// privKey := ed25519.NewKeyFromSeed(privateKey)
	sign := ed25519.Sign(ed25519.PrivateKey(privateKey), body)
	var signHMAC [16]byte
	var polyKey [32]byte
	for i, it := range session.AgentKey {
		polyKey[i] = byte(it)
	}
	poly1305.Sum(&signHMAC, sign, &polyKey)
	fullSign := append(signHMAC[:], sign...)

	req.Header.Set("Wt-Signature", base64.StdEncoding.EncodeToString(fullSign))
	req.Header.Set("Accept", "*/*")
	req.Header.Set("Content-Type", "application/octet-stream")
	req.Header.Set("Accept-Charset", "*")
	req.Header.Set("Accept-Encoding", "*")
	req.Header.Set("Accept-Language", "*")

	// session.log.Printf("BODY: %v", base64.StdEncoding.EncodeToString(body))
	// session.log.Printf("SIGN: %v", base64.StdEncoding.EncodeToString(fullSign))
	// // TEST
	// rawPublicKey := "unfKXPROYYMV6o3rnFmbFfanMxIUWztvJVnJcoLH4LM="
	// publicKey, _ := base64.StdEncoding.DecodeString(rawPublicKey)
	// if ed25519.Verify(ed25519.PublicKey(publicKey), body, sign) {
	// 	session.log.Infof("Sign verify successfull")
	// } else {
	// 	session.log.Infof("Sign verify failed")
	// }

	req.AddCookie(&http.Cookie{
		Name:  "wt-signature",
		Value: liburl.QueryEscape(base64.StdEncoding.EncodeToString(fullSign)),
	})

	if isNewTimeout {
		session.client.Timeout = timeout
	} else {
		session.client.Timeout = session.SessionTimeout
	}

	resp, err := session.client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if response, err = ioutil.ReadAll(resp.Body); err != nil {
		return nil, err
	}
	for header, value := range resp.Header {
		session.log.Infof("%s:\t%+v", header, value)
	}
	session.log.Infof("BODY: %.1000s\n", response)
	if resp.Header.Get("Set-Cookie") != "" {
		session.log.Infof("[postRequest]: Incoming cookie: %v", resp.Header.Get("Set-Cookie"))
	}
	if resp.StatusCode == 301 || resp.StatusCode == 302 {
		loc, err := resp.Location()
		if err != nil {
			return nil, err
		}
		return session.postRequest(loc.String(), data, depth+1, isNewTimeout, timeout)
	}
	if resp.StatusCode != 200 {

		return nil, fmt.Errorf("Received wrong status code: %d", resp.StatusCode)
	}

	session.AgentURL = url

	return []byte(strings.TrimLeft(string(response), "\n\r\t\v ")), nil
}

// startSession - function for getting session key from agent
func (session *AgentSessionImplementor) startSession() error {
	session.log.Infof("[session.startSession]: Starting new session")
	defer session.log.Infof("[session.startSession]: End session starting")

	rand.Seed(time.Now().UnixNano())

	var privateKey [32]byte
	for i := range privateKey[:] {
		privateKey[i] = byte(rand.Intn(256))
	}

	var publicKey [32]byte
	curve25519.ScalarBaseMult(&publicKey, &privateKey)

	session.SessionKey = session.AgentKey
	encrypted, err := session.encrypt(publicKey[:])
	if err != nil {
		session.log.Errorf("[session.startSession]: Error while encrpyting public key. Error: %v", err)
		return err
	}

	session.log.Infof("LENGTH %d", len(encrypted))

	resp, err := session.postRequest(session.AgentURL, encrypted, 0, false, 0)

	if err != nil {
		session.log.Errorf("[session.startSession]: Error while sending request. Error: %v", err)
		return err
	}

	//add decryption with standard key
	decrypted, err := session.decrypt(resp)
	if err != nil {
		session.log.Errorf("[session.startSession]: Error while decrypting response from agent. Error: %v", err)
		return err
	}
	if len(decrypted) != 32 {
		session.log.Errorf("[session.startSession]: Incoming public key from agent has incorrect len. Must be 32. Length: %v", len(decrypted))
		return fmt.Errorf("[session.startSession]: Incoming public key from agent has incorrect len. Must be 32. Length: %v", len(decrypted))
	}

	var sessionKey, CSPublicKey [32]byte
	for i, it := range decrypted {
		CSPublicKey[i] = it
	}
	curve25519.ScalarMult(&sessionKey, &privateKey, &CSPublicKey)
	session.SessionKey = string(sessionKey[:])

	session.log.Infof("[session.startSession]: Session key is created. Key: %x", session.SessionKey)
	session.log.Infof("[session.startSession]: Start testing new session key")
	// session key is successfully get. Send random encrypt data to test session key

	var temp [tempLen]byte
	for i := range temp[:] {
		temp[i] = byte(rand.Intn(256))
	}

	encrypted, err = session.encrypt(temp[:])
	if err != nil {
		session.log.Errorf("[session.startSession]: Error while encrypting temp. Error: %v", err)
		return err
	}
	session.log.Infof("LENGTH %d", len(encrypted))
	tempHash := sha512.Sum512(temp[:])
	resp, err = session.postRequest(session.AgentURL, append(tempHash[:], encrypted...), 0, false, 0)

	if err != nil {
		session.log.Errorf("[session.startSession]: Error while sending request. Error: %v", err)
		return err
	}

	//add decryption with standard key
	decrypted, err = session.decrypt(resp)
	if err != nil {
		session.log.Errorf("[session.startSession]: Error while decrypting response from agent. Error: %v", err)
		return err
	}

	if len(decrypted) != tempLen+len(session.UserID) {
		session.log.Errorf("[session.startSession]: Response must contains temp data plus userID. Expected response length: %v. Length: %v", tempLen+len(session.UserID), len(decrypted))
		return fmt.Errorf("[session.startSession]: Response must contains temp data plus userID. Expected response length: %v. Length: %v", tempLen+len(session.UserID), len(decrypted))
	}

	session.log.Infof("Response: %s", string(decrypted))
	// check temp data
	for i, it := range temp[:] {
		if it != decrypted[i] {
			session.log.Errorf("[session.startSession]: Error! Temp data and agent response don't match. Temp: %x. Response: %x", temp, decrypted[:tempLen])
			return fmt.Errorf("[session.startSession]: Error! Temp data and agent response don't match. Temp: %x. Response: %x", temp, decrypted[:tempLen])
		}
	}

	// check user ID
	for i, it := range session.UserID {
		if byte(it) != decrypted[tempLen+i] {
			session.log.Errorf("[session.startSession]: Error! UserID doesn't match with response UserID. UserID: %s, Response: %s", session.UserID, string(decrypted[tempLen:]))
			return fmt.Errorf("[session.startSession]: Error! UserID doesn't match with response UserID. UserID: %s, Response: %s", session.UserID, string(decrypted[tempLen:]))
		}
	}

	session.log.Infof("[session.startSession]: Session key was successfully created")

	return nil
}

// Start - method for starting new session
func (session *AgentSessionImplementor) Start() error {
	var err error
	for i := 0; i < sessionStartRetryCount; i++ {
		session.log.Infof("[session.Start retry #%d]: New session start", i)
		if err = session.startSession(); err != nil {
			session.log.Warnf("[session.Start retry #%d]: Session start failed. Error: %v", i, err)
		} else {
			break
		}
	}
	if err != nil {
		session.log.Errorf("[session.Start]: Error while starting session. Error: %v", err)
		if defaultProxies != nil && len(defaultProxies) > 0 {
			session.log.Debugf("[session.Start]: Try to create session via default proxies")
			for _, proxyURL := range defaultProxies {
				session.log.Debugf("[session.Start]: Try to use proxy %s", proxyURL)
				sessionOpt := GetSessionProxyOption(proxyURL)
				sessionOpt.apply(session)

				if err = session.startSession(); err != nil {
					session.log.Warnf("[session.Start]: Session start failed. Error: %v", err)
				} else {
					break
				}
			}
		}
	}
	return err
}

// Stop - function for closing session
func (session *AgentSessionImplementor) Stop() {
	result := make(map[string]interface{})
	err := session.SendRequest(&Command{
		CMD:    "logout",
		Params: nil,
	}, &result)
	if err != nil {
		session.log.Errorf("[session.Stop]: Error while sending request. Error: %v", err)
	}
}

// SendRequest - method for sending Command to PHP agent
func (session *AgentSessionImplementor) SendRequest(cmd *Command, v interface{}) error {
	marshaled, err := json.Marshal(cmd)
	if err != nil {
		return err
	}
	session.log.Infof("[INFO (ApplicationLayer)] [%.20s]  Sending %.1500s\n", session.AgentURL, marshaled)
	encrypted, err := session.encrypt(marshaled)
	if err != nil {
		return err
	}
	resp, err := session.postRequest(session.AgentURL, encrypted, 0, false, 0)
	if err != nil {
		return err
	}
	result, err := session.decrypt(resp)
	if err != nil {
		return err
	}
	session.log.Infof("[INFO (ApplicationLayer)] [%.20s] Received %.1500s\n", session.AgentURL, string(result))
	rawTimestamp := result[:8]
	timestamp := binary.LittleEndian.Uint64(rawTimestamp)
	session.log.Infof("[INFO (ApplicationLayer)] [%.20s] Timestamp from agent: %v", session.AgentURL, timestamp)
	if timestamp == session.LastTimestamp {
		session.log.Errorf("[INFO (ApplicationLayer)] [%.20s] Error! Timestamp and last timestamp is equal", session.AgentURL)
		return fmt.Errorf("[INFO (ApplicationLayer)] [%.20s] Error! Timestamp and last timestamp is equal", session.AgentURL)
	}
	var js struct {
		Version int             `json:"version"`
		Result  json.RawMessage `json:"result"`
		Error   int             `json:"error"`
		Text    string          `json:"text"`
	}
	if err := json.Unmarshal(result[8:], &js); err != nil {
		return fmt.Errorf("Error while unmarshall response. Error: %v", err)
	}
	if js.Error != 0 {
		return &AgentError{
			Code: js.Error,
			Text: js.Text,
		}
	}
	return json.Unmarshal(js.Result, v)
}

// SendRequestWithTimeout - method for sending Command to PHP agent with timeout
func (session *AgentSessionImplementor) SendRequestWithTimeout(cmd *Command, v interface{}, timeout time.Duration) error {
	marshaled, err := json.Marshal(cmd)
	if err != nil {
		return err
	}
	session.log.Infof("[INFO (ApplicationLayer)] [%.20s]  Sending %.1500s\n", session.AgentURL, marshaled)
	encrypted, err := session.encrypt(marshaled)
	if err != nil {
		return err
	}
	resp, err := session.postRequest(session.AgentURL, encrypted, 0, true, timeout)
	if err != nil {
		return err
	}
	result, err := session.decrypt(resp)
	if err != nil {
		return err
	}
	session.log.Infof("[INFO (ApplicationLayer)] [%.20s] Received %.1500s\n", session.AgentURL, string(result))
	rawTimestamp := result[:8]
	timestamp := binary.LittleEndian.Uint64(rawTimestamp)
	session.log.Infof("[INFO (ApplicationLayer)] [%.20s] Timestamp from agent: %v", session.AgentURL, timestamp)
	if timestamp == session.LastTimestamp {
		session.log.Errorf("[INFO (ApplicationLayer)] [%.20s] Error! Timestamp and last timestamp is equal", session.AgentURL)
		return fmt.Errorf("[INFO (ApplicationLayer)] [%.20s] Error! Timestamp and last timestamp is equal", session.AgentURL)
	}
	var js struct {
		Version int             `json:"version"`
		Result  json.RawMessage `json:"result"`
		Error   int             `json:"error"`
		Text    string          `json:"text"`
	}
	if err := json.Unmarshal(result[8:], &js); err != nil {
		return fmt.Errorf("Error while unmarshall response. Error: %v", err)
	}
	if js.Error != 0 {
		return &AgentError{
			Code: js.Error,
			Text: js.Text,
		}
	}
	return json.Unmarshal(js.Result, v)
}

func (session *AgentSessionImplementor) rawSendRequest(cmd *Command, timeout time.Duration) (io.Reader, error) {
	marshaled, err := json.Marshal(cmd)
	if err != nil {
		return nil, err
	}
	session.log.Infof("[INFO (ApplicationLayer)] [%.20s]  Sending %.50s\n", session.AgentURL, marshaled)
	encrypted, err := session.encrypt(marshaled)
	if err != nil {
		return nil, err
	}
	resp, err := session.postRequest(session.AgentURL, encrypted, 0, timeout != 0, timeout)
	if err != nil {
		return nil, err
	}
	resultStream, err := session.streamDecrypt(resp)
	if err != nil {
		return nil, err
	}
	rawTimestamp := make([]byte, 8)
	if n, err := resultStream.Read(rawTimestamp); err != nil || n != 8 {
		session.log.Errorf("[INFO (ApplicationLayer)] [%.20s] Error! Can't extract timestamp from agent", session.AgentURL)
		return nil, fmt.Errorf("[INFO (ApplicationLayer)] [%.20s] Error! Can't extract timestamp from agent", session.AgentURL)
	}
	timestamp := binary.LittleEndian.Uint64(rawTimestamp)
	session.log.Infof("[INFO (ApplicationLayer)] [%.20s] Timestamp from agent: %v", session.AgentURL, timestamp)
	if timestamp == session.LastTimestamp {
		session.log.Errorf("[INFO (ApplicationLayer)] [%.20s] Error! Timestamp and last timestamp is equal", session.AgentURL)
		return nil, fmt.Errorf("[INFO (ApplicationLayer)] [%.20s] Error! Timestamp and last timestamp is equal", session.AgentURL)
	}
	return resultStream, nil
}

// RawSendRequest - method for sending Command to PHP agent with raw response
func (session *AgentSessionImplementor) RawSendRequest(cmd *Command) (io.Reader, error) {
	return session.rawSendRequest(cmd, 0)
}

// RawSendRequestWithTimeout - method for sending Command to PHP agent with raw response
func (session *AgentSessionImplementor) RawSendRequestWithTimeout(cmd *Command, timeout time.Duration) (io.Reader, error) {
	return session.rawSendRequest(cmd, timeout)
}

// NewAgentSession - function for creating new session with agent
func NewAgentSession(URL string, privateKey string, userID string, agentKey string, opts ...SessionOption) AgentSession {
	cookieJar, _ := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List})

	result := &AgentSessionImplementor{
		AgentURL:       URL,
		PrivateKey:     privateKey,
		UserID:         userID,
		AgentKey:       agentKey,
		log:            NewLibLogger(false),
		SessionTimeout: time.Second * 30,
		client: &http.Client{
			CheckRedirect: func(req *http.Request, via []*http.Request) error {
				return http.ErrUseLastResponse
			},
			Transport: &http.Transport{
				DisableCompression: true,
				TLSClientConfig:    &tls.Config{InsecureSkipVerify: true},
				DisableKeepAlives:  true,
			},
			Jar: cookieJar,
		},
	}

	for _, opt := range opts {
		opt.apply(result)
	}
	return result
}
