/*
Copyright 2020 The Kubernetes Authors.

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.
*/
// TODO: add logging
// TODO: add timeouts
package api

import (
	"bytes"
	"crypto/tls"
	"encoding/json"
	"io"
	"net/http"
	"os"
	"strings"

	"github.com/pkg/errors"
	log "github.com/sirupsen/logrus"
)

// TODO: Ensure DNS Deploy Type Defaults to no-deploy instead of ""
type BluecatConfig struct {
	GatewayHost      string `json:"gatewayHost"`
	GatewayUsername  string `json:"gatewayUsername,omitempty"`
	GatewayPassword  string `json:"gatewayPassword,omitempty"`
	DNSConfiguration string `json:"dnsConfiguration"`
	DNSServerName    string `json:"dnsServerName"`
	DNSDeployType    string `json:"dnsDeployType"`
	View             string `json:"dnsView"`
	RootZone         string `json:"rootZone"`
	SkipTLSVerify    bool   `json:"skipTLSVerify"`
}

type GatewayClient interface {
	GetBluecatZones(zoneName string) ([]BluecatZone, error)
	GetHostRecords(zone string, records *[]BluecatHostRecord) error
	GetCNAMERecords(zone string, records *[]BluecatCNAMERecord) error
	GetHostRecord(name string, record *BluecatHostRecord) error
	GetCNAMERecord(name string, record *BluecatCNAMERecord) error
	CreateHostRecord(zone string, req *BluecatCreateHostRecordRequest) error
	CreateCNAMERecord(zone string, req *BluecatCreateCNAMERecordRequest) error
	DeleteHostRecord(name string, zone string) (err error)
	DeleteCNAMERecord(name string, zone string) (err error)
	GetTXTRecords(zone string, records *[]BluecatTXTRecord) error
	GetTXTRecord(name string, record *BluecatTXTRecord) error
	CreateTXTRecord(zone string, req *BluecatCreateTXTRecordRequest) error
	DeleteTXTRecord(name string, zone string) error
	ServerFullDeploy() error
}

// GatewayClientConfig defines the configuration for a Bluecat Gateway Client
type GatewayClientConfig struct {
	Cookie           http.Cookie
	Token            string
	Host             string
	DNSConfiguration string
	View             string
	RootZone         string
	DNSServerName    string
	SkipTLSVerify    bool
}

// BluecatZone defines a zone to hold records
type BluecatZone struct {
	ID         int    `json:"id"`
	Name       string `json:"name"`
	Properties string `json:"properties"`
	Type       string `json:"type"`
}

// BluecatHostRecord defines dns Host record
type BluecatHostRecord struct {
	ID         int    `json:"id"`
	Name       string `json:"name"`
	Properties string `json:"properties"`
	Type       string `json:"type"`
}

// BluecatCNAMERecord defines dns CNAME record
type BluecatCNAMERecord struct {
	ID         int    `json:"id"`
	Name       string `json:"name"`
	Properties string `json:"properties"`
	Type       string `json:"type"`
}

// BluecatTXTRecord defines dns TXT record
type BluecatTXTRecord struct {
	ID         int    `json:"id"`
	Name       string `json:"name"`
	Properties string `json:"properties"`
}

type BluecatCreateHostRecordRequest struct {
	AbsoluteName string `json:"absolute_name"`
	IP4Address   string `json:"ip4_address"`
	TTL          int    `json:"ttl"`
	Properties   string `json:"properties"`
}

type BluecatCreateCNAMERecordRequest struct {
	AbsoluteName string `json:"absolute_name"`
	LinkedRecord string `json:"linked_record"`
	TTL          int    `json:"ttl"`
	Properties   string `json:"properties"`
}

type BluecatCreateTXTRecordRequest struct {
	AbsoluteName string `json:"absolute_name"`
	Text         string `json:"txt"`
}

type BluecatServerFullDeployRequest struct {
	ServerName string `json:"server_name"`
}

// NewGatewayClient creates and returns a new Bluecat gateway client
func NewGatewayClientConfig(cookie http.Cookie, token, gatewayHost, dnsConfiguration, view, rootZone, dnsServerName string, skipTLSVerify bool) GatewayClientConfig {
	// TODO: do not handle defaulting here
	//
	// Right now the Bluecat gateway doesn't seem to have a way to get the root zone from the API. If the user
	// doesn't provide one via the config file we'll assume it's 'com'
	if rootZone == "" {
		rootZone = "com"
	}
	return GatewayClientConfig{
		Cookie:           cookie,
		Token:            token,
		Host:             gatewayHost,
		DNSConfiguration: dnsConfiguration,
		DNSServerName:    dnsServerName,
		View:             view,
		RootZone:         rootZone,
		SkipTLSVerify:    skipTLSVerify,
	}
}

// GetBluecatGatewayToken retrieves a Bluecat Gateway API token.
func GetBluecatGatewayToken(cfg BluecatConfig) (string, http.Cookie, error) {
	var username string
	if cfg.GatewayUsername != "" {
		username = cfg.GatewayUsername
	}
	if v, ok := os.LookupEnv("BLUECAT_USERNAME"); ok {
		username = v
	}

	var password string
	if cfg.GatewayPassword != "" {
		password = cfg.GatewayPassword
	}
	if v, ok := os.LookupEnv("BLUECAT_PASSWORD"); ok {
		password = v
	}

	body, err := json.Marshal(map[string]string{
		"username": username,
		"password": password,
	})
	if err != nil {
		return "", http.Cookie{}, errors.Wrap(err, "could not unmarshal credentials for bluecat gateway config")
	}
	url := cfg.GatewayHost + "/rest_login"

	response, err := executeHTTPRequest(cfg.SkipTLSVerify, http.MethodPost, url, "", bytes.NewBuffer(body), http.Cookie{})
	if err != nil {
		return "", http.Cookie{}, errors.Wrap(err, "error obtaining API token from bluecat gateway")
	}
	defer response.Body.Close()

	responseBody, err := io.ReadAll(response.Body)
	if err != nil {
		return "", http.Cookie{}, errors.Wrap(err, "failed to read login response from bluecat gateway")
	}

	if response.StatusCode != http.StatusOK {
		return "", http.Cookie{}, errors.Errorf("got HTTP response code %v, detailed message: %v", response.StatusCode, string(responseBody))
	}

	jsonResponse := map[string]string{}
	err = json.Unmarshal(responseBody, &jsonResponse)
	if err != nil {
		return "", http.Cookie{}, errors.Wrap(err, "error unmarshaling json response (auth) from bluecat gateway")
	}

	// Example response: {"access_token": "BAMAuthToken: abc123"}
	// We only care about the actual token string - i.e. abc123
	// The gateway also creates a cookie as part of the response. This seems to be the actual auth mechanism, at least
	// for now.
	return strings.Split(jsonResponse["access_token"], " ")[1], *response.Cookies()[0], nil
}

func (c GatewayClientConfig) GetBluecatZones(zoneName string) ([]BluecatZone, error) {
	zonePath := expandZone(zoneName)
	url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration + "/views/" + c.View + "/" + zonePath

	response, err := executeHTTPRequest(c.SkipTLSVerify, http.MethodGet, url, c.Token, nil, c.Cookie)
	if err != nil {
		return nil, errors.Wrapf(err, "error requesting zones from gateway: %v, %v", url, zoneName)
	}
	defer response.Body.Close()

	if response.StatusCode != http.StatusOK {
		return nil, errors.Errorf("received http %v requesting zones from gateway in zone %v", response.StatusCode, zoneName)
	}

	zones := []BluecatZone{}
	json.NewDecoder(response.Body).Decode(&zones)

	// Bluecat Gateway only returns subzones one level deeper than the provided zone
	// so this recursion is needed to traverse subzones until none are returned
	for _, zone := range zones {
		zoneProps := SplitProperties(zone.Properties)
		subZones, err := c.GetBluecatZones(zoneProps["absoluteName"])
		if err != nil {
			return nil, errors.Wrapf(err, "error retrieving subzones from gateway: %v", zoneName)
		}
		zones = append(zones, subZones...)
	}

	return zones, nil
}

func (c GatewayClientConfig) GetHostRecords(zone string, records *[]BluecatHostRecord) error {
	zonePath := expandZone(zone)
	// Remove the trailing 'zones/'
	zonePath = strings.TrimSuffix(zonePath, "zones/")

	url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration + "/views/" + c.View + "/" + zonePath + "host_records/"

	response, err := executeHTTPRequest(c.SkipTLSVerify, http.MethodGet, url, c.Token, nil, c.Cookie)
	if err != nil {
		return errors.Wrapf(err, "error requesting host records from gateway in zone %v", zone)
	}
	defer response.Body.Close()

	if response.StatusCode != http.StatusOK {
		return errors.Errorf("received http %v requesting host records from gateway in zone %v", response.StatusCode, zone)
	}

	json.NewDecoder(response.Body).Decode(records)
	log.Debugf("Get Host Records Response: %v", records)

	return nil
}

func (c GatewayClientConfig) GetCNAMERecords(zone string, records *[]BluecatCNAMERecord) error {
	zonePath := expandZone(zone)
	// Remove the trailing 'zones/'
	zonePath = strings.TrimSuffix(zonePath, "zones/")

	url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration + "/views/" + c.View + "/" + zonePath + "cname_records/"

	response, err := executeHTTPRequest(c.SkipTLSVerify, http.MethodGet, url, c.Token, nil, c.Cookie)
	if err != nil {
		return errors.Wrapf(err, "error retrieving cname records from gateway in zone %v", zone)
	}
	defer response.Body.Close()

	if response.StatusCode != http.StatusOK {
		return errors.Errorf("received http %v requesting cname records from gateway in zone %v", response.StatusCode, zone)
	}

	json.NewDecoder(response.Body).Decode(records)
	log.Debugf("Get CName Records Response: %v", records)

	return nil
}

func (c GatewayClientConfig) GetTXTRecords(zone string, records *[]BluecatTXTRecord) error {
	zonePath := expandZone(zone)
	// Remove the trailing 'zones/'
	zonePath = strings.TrimSuffix(zonePath, "zones/")

	url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration + "/views/" + c.View + "/" + zonePath + "text_records/"

	response, err := executeHTTPRequest(c.SkipTLSVerify, http.MethodGet, url, c.Token, nil, c.Cookie)
	if err != nil {
		return errors.Wrapf(err, "error retrieving txt records from gateway in zone %v", zone)
	}
	defer response.Body.Close()

	if response.StatusCode != http.StatusOK {
		return errors.Errorf("received http %v requesting txt records from gateway in zone %v", response.StatusCode, zone)
	}

	log.Debugf("Get Txt Records response: %v", response)
	json.NewDecoder(response.Body).Decode(records)
	log.Debugf("Get TXT Records Body: %v", records)

	return nil
}

func (c GatewayClientConfig) GetHostRecord(name string, record *BluecatHostRecord) error {
	url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration +
		"/views/" + c.View + "/" +
		"host_records/" + name + "/"

	response, err := executeHTTPRequest(c.SkipTLSVerify, http.MethodGet, url, c.Token, nil, c.Cookie)
	if err != nil {
		return errors.Wrapf(err, "error retrieving host record %v from gateway", name)
	}
	defer response.Body.Close()

	if response.StatusCode != http.StatusOK {
		return errors.Errorf("received http %v while retrieving host record %v from gateway", response.StatusCode, name)
	}

	json.NewDecoder(response.Body).Decode(record)
	log.Debugf("Get Host Record Response: %v", record)
	return nil
}

func (c GatewayClientConfig) GetCNAMERecord(name string, record *BluecatCNAMERecord) error {
	url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration +
		"/views/" + c.View + "/" +
		"cname_records/" + name + "/"

	response, err := executeHTTPRequest(c.SkipTLSVerify, http.MethodGet, url, c.Token, nil, c.Cookie)
	if err != nil {
		return errors.Wrapf(err, "error retrieving cname record %v from gateway", name)
	}
	defer response.Body.Close()

	if response.StatusCode != http.StatusOK {
		return errors.Errorf("received http %v while retrieving cname record %v from gateway", response.StatusCode, name)
	}

	json.NewDecoder(response.Body).Decode(record)
	log.Debugf("Get CName Record Response: %v", record)
	return nil
}

func (c GatewayClientConfig) GetTXTRecord(name string, record *BluecatTXTRecord) error {
	url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration +
		"/views/" + c.View + "/" +
		"text_records/" + name + "/"

	response, err := executeHTTPRequest(c.SkipTLSVerify, http.MethodGet, url, c.Token, nil, c.Cookie)
	if err != nil {
		return errors.Wrapf(err, "error retrieving record %v from gateway", name)
	}
	defer response.Body.Close()

	if response.StatusCode != http.StatusOK {
		return errors.Errorf("received http %v while retrieving txt record %v from gateway", response.StatusCode, name)
	}

	json.NewDecoder(response.Body).Decode(record)
	log.Debugf("Get TXT Record Response: %v", record)

	return nil
}

func (c GatewayClientConfig) CreateHostRecord(zone string, req *BluecatCreateHostRecordRequest) error {
	zonePath := expandZone(zone)
	// Remove the trailing 'zones/'
	zonePath = strings.TrimSuffix(zonePath, "zones/")

	url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration + "/views/" + c.View + "/" + zonePath + "host_records/"
	body, err := json.Marshal(req)
	if err != nil {
		return errors.Wrap(err, "could not marshal body for create host record")
	}

	response, err := executeHTTPRequest(c.SkipTLSVerify, http.MethodPost, url, c.Token, bytes.NewBuffer(body), c.Cookie)
	if err != nil {
		return errors.Wrapf(err, "error creating host record %v in gateway", req.AbsoluteName)
	}
	defer response.Body.Close()

	if response.StatusCode != http.StatusCreated {
		return errors.Errorf("received http %v while creating host record %v in gateway", response.StatusCode, req.AbsoluteName)
	}

	return nil
}

func (c GatewayClientConfig) CreateCNAMERecord(zone string, req *BluecatCreateCNAMERecordRequest) error {
	zonePath := expandZone(zone)
	// Remove the trailing 'zones/'
	zonePath = strings.TrimSuffix(zonePath, "zones/")

	url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration + "/views/" + c.View + "/" + zonePath + "cname_records/"
	body, err := json.Marshal(req)
	if err != nil {
		return errors.Wrap(err, "could not marshal body for create cname record")
	}

	response, err := executeHTTPRequest(c.SkipTLSVerify, http.MethodPost, url, c.Token, bytes.NewBuffer(body), c.Cookie)
	if err != nil {
		return errors.Wrapf(err, "error creating cname record %v in gateway", req.AbsoluteName)
	}
	defer response.Body.Close()

	if response.StatusCode != http.StatusCreated {
		return errors.Errorf("received http %v while creating cname record %v to alias %v in gateway", response.StatusCode, req.AbsoluteName, req.LinkedRecord)
	}

	return nil
}

func (c GatewayClientConfig) CreateTXTRecord(zone string, req *BluecatCreateTXTRecordRequest) error {
	zonePath := expandZone(zone)
	// Remove the trailing 'zones/'
	zonePath = strings.TrimSuffix(zonePath, "zones/")

	url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration + "/views/" + c.View + "/" + zonePath + "text_records/"
	body, err := json.Marshal(req)
	if err != nil {
		return errors.Wrap(err, "could not marshal body for create txt record")
	}

	response, err := executeHTTPRequest(c.SkipTLSVerify, http.MethodPost, url, c.Token, bytes.NewBuffer(body), c.Cookie)
	if err != nil {
		return errors.Wrapf(err, "error creating txt record %v in gateway", req.AbsoluteName)
	}
	defer response.Body.Close()

	if response.StatusCode != http.StatusCreated {
		return errors.Errorf("received http %v while creating txt record %v in gateway", response.StatusCode, req.AbsoluteName)
	}

	return nil
}

func (c GatewayClientConfig) DeleteHostRecord(name string, zone string) (err error) {
	url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration +
		"/views/" + c.View + "/" +
		"host_records/" + name + "." + zone + "/"

	response, err := executeHTTPRequest(c.SkipTLSVerify, http.MethodDelete, url, c.Token, nil, c.Cookie)
	if err != nil {
		return errors.Wrapf(err, "error deleting host record %v from gateway", name)
	}

	if response.StatusCode != http.StatusNoContent {
		return errors.Errorf("received http %v while deleting host record %v from gateway", response.StatusCode, name)
	}

	return nil
}

func (c GatewayClientConfig) DeleteCNAMERecord(name string, zone string) (err error) {
	url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration +
		"/views/" + c.View + "/" +
		"cname_records/" + name + "." + zone + "/"

	response, err := executeHTTPRequest(c.SkipTLSVerify, http.MethodDelete, url, c.Token, nil, c.Cookie)
	if err != nil {
		return errors.Wrapf(err, "error deleting cname record %v from gateway", name)
	}
	if response.StatusCode != http.StatusNoContent {
		return errors.Errorf("received http %v while deleting cname record %v from gateway", response.StatusCode, name)
	}

	return nil
}

func (c GatewayClientConfig) DeleteTXTRecord(name string, zone string) error {
	url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration +
		"/views/" + c.View + "/" +
		"text_records/" + name + "." + zone + "/"

	response, err := executeHTTPRequest(c.SkipTLSVerify, http.MethodDelete, url, c.Token, nil, c.Cookie)
	if err != nil {
		return errors.Wrapf(err, "error deleting txt record %v from gateway", name)
	}
	if response.StatusCode != http.StatusNoContent {
		return errors.Errorf("received http %v while deleting txt record %v from gateway", response.StatusCode, name)
	}

	return nil
}

func (c GatewayClientConfig) ServerFullDeploy() error {
	log.Infof("Executing full deploy on server %s", c.DNSServerName)
	url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration + "/server/full_deploy/"
	requestBody := BluecatServerFullDeployRequest{
		ServerName: c.DNSServerName,
	}

	body, err := json.Marshal(requestBody)
	if err != nil {
		return errors.Wrap(err, "could not marshal body for server full deploy")
	}

	response, err := executeHTTPRequest(c.SkipTLSVerify, http.MethodPost, url, c.Token, bytes.NewBuffer(body), c.Cookie)
	if err != nil {
		return errors.Wrap(err, "error executing full deploy")
	}

	if response.StatusCode != http.StatusCreated {
		responseBody, err := io.ReadAll(response.Body)
		if err != nil {
			return errors.Wrap(err, "failed to read full deploy response body")
		}
		return errors.Errorf("got HTTP response code %v, detailed message: %v", response.StatusCode, string(responseBody))
	}

	return nil
}

// SplitProperties is a helper function to break a '|' separated string into key/value pairs
// i.e. "foo=bar|baz=mop"
func SplitProperties(props string) map[string]string {
	propMap := make(map[string]string)
	// remove trailing | character before we split
	props = strings.TrimSuffix(props, "|")

	splits := strings.Split(props, "|")
	for _, pair := range splits {
		items := strings.Split(pair, "=")
		propMap[items[0]] = items[1]
	}

	return propMap
}

// IsValidDNSDeployType validates the deployment type provided by a users configuration is supported by the Bluecat Provider.
func IsValidDNSDeployType(deployType string) bool {
	validDNSDeployTypes := []string{"no-deploy", "full-deploy"}
	for _, t := range validDNSDeployTypes {
		if t == deployType {
			return true
		}
	}
	return false
}

// expandZone takes an absolute domain name such as 'example.com' and returns a zone hierarchy used by Bluecat Gateway,
// such as '/zones/com/zones/example/zones/'
func expandZone(zone string) string {
	ze := "zones/"
	parts := strings.Split(zone, ".")
	if len(parts) > 1 {
		last := len(parts) - 1
		for i := range parts {
			ze = ze + parts[last-i] + "/zones/"
		}
	} else {
		ze = ze + zone + "/zones/"
	}
	return ze
}

func executeHTTPRequest(skipTLSVerify bool, method, url, token string, body io.Reader, cookie http.Cookie) (*http.Response, error) {
	httpClient := &http.Client{
		Transport: &http.Transport{
			Proxy: http.ProxyFromEnvironment,
			TLSClientConfig: &tls.Config{
				InsecureSkipVerify: skipTLSVerify,
			},
		},
	}
	request, err := http.NewRequest(method, url, body)
	if err != nil {
		return nil, err
	}
	if request.Method == http.MethodPost {
		request.Header.Add("Content-Type", "application/json")
	}
	request.Header.Add("Accept", "application/json")

	if token != "" {
		request.Header.Add("Authorization", "Basic "+token)
	}
	request.AddCookie(&cookie)

	return httpClient.Do(request)
}
