package server

/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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 (
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"strconv"
	"strings"
	"time"

	"github.com/apache/trafficcontrol/v8/lib/go-log"
	"github.com/apache/trafficcontrol/v8/lib/go-tc"
	"github.com/apache/trafficcontrol/v8/traffic_ops/traffic_ops_golang/api"
	"github.com/apache/trafficcontrol/v8/traffic_ops/traffic_ops_golang/dbhelpers"
)

// InvalidStatusForDeliveryServicesAlertText returns a string describing that
// setting a server to 'status' invalidates the Active delivery services
// identified in 'dsIDs'.
//
// If 'dsIDs' is empty/nil, returns an empty string.
func InvalidStatusForDeliveryServicesAlertText(prefix, serverType string, dsIDs []int) string {
	if len(dsIDs) < 1 {
		return ""
	}
	alertText := prefix
	if len(dsIDs) == 1 {
		alertText += fmt.Sprintf(" #%d", dsIDs[0])
	} else if len(dsIDs) == 2 {
		alertText += fmt.Sprintf("s #%d and #%d", dsIDs[0], dsIDs[1])
	} else {
		dsNums := make([]string, 0, len(dsIDs)-1)
		for _, dsID := range dsIDs[:len(dsIDs)-1] {
			dsNums = append(dsNums, "#"+strconv.Itoa(dsID))
		}
		alertText += fmt.Sprintf("s %s, and #%d", strings.Join(dsNums, ", "), dsIDs[len(dsIDs)-1])
	}
	typeMsg := tc.CacheTypeEdge.String()
	if strings.HasPrefix(serverType, tc.OriginTypeName) {
		typeMsg = tc.OriginTypeName
	}
	alertText += fmt.Sprintf(" with no '%s' or '%s' %s servers", tc.CacheStatusOnline, tc.CacheStatusReported, typeMsg)
	return alertText
}

// UpdateStatusHandler is the handler for PUT requests to the /servers/{{ID}}/status API endpoint.
func UpdateStatusHandler(w http.ResponseWriter, r *http.Request) {
	inf, userErr, sysErr, errCode := api.NewInfo(r, []string{"id"}, []string{"id"})
	tx := inf.Tx.Tx
	if userErr != nil || sysErr != nil {
		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
		return
	}
	defer inf.Close()
	reqObj := tc.ServerPutStatus{}
	if err := json.NewDecoder(r.Body).Decode(&reqObj); err != nil {
		api.HandleErr(w, r, tx, http.StatusBadRequest, errors.New("malformed JSON: "+err.Error()), nil)
		return
	}

	id := inf.IntParams["id"]
	serverInfo, exists, err := dbhelpers.GetServerInfo(id, tx)
	if err != nil {
		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, err)
		return
	}
	if !exists {
		api.HandleErr(w, r, tx, http.StatusNotFound, fmt.Errorf("server ID %d not found", id), nil)
		return
	}
	cdnName, err := dbhelpers.GetCDNNameFromServerID(inf.Tx.Tx, int64(id))
	if err != nil {
		api.HandleErr(w, r, inf.Tx.Tx, http.StatusInternalServerError, nil, err)
		return
	}
	userErr, sysErr, statusCode := dbhelpers.CheckIfCurrentUserHasCdnLock(inf.Tx.Tx, string(cdnName), inf.User.UserName)
	if statusCode == http.StatusForbidden {
		userErr = fmt.Errorf("this action will result in server updates being queued and %v", userErr)
	}
	if userErr != nil || sysErr != nil {
		api.HandleErr(w, r, inf.Tx.Tx, statusCode, userErr, sysErr)
		return
	}
	status := tc.StatusNullable{}
	statusExists := false
	if reqObj.Status.Name != nil {
		status, statusExists, err = dbhelpers.GetStatusByName(*reqObj.Status.Name, tx)
	} else if reqObj.Status.ID != nil {
		status, statusExists, err = dbhelpers.GetStatusByID(*reqObj.Status.ID, tx)
	} else {
		api.HandleErr(w, r, tx, http.StatusBadRequest, errors.New("status is required"), nil)
		return
	}
	if err != nil {
		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, err)
		return
	}
	if !statusExists {
		api.HandleErr(w, r, tx, http.StatusBadRequest, errors.New("invalid status (does not exist)"), nil)
		return
	}

	if *status.Name == tc.CacheStatusAdminDown.String() || *status.Name == tc.CacheStatusOffline.String() {
		if reqObj.OfflineReason == nil {
			api.HandleErr(w, r, tx, http.StatusBadRequest, errors.New("offlineReason is required for "+tc.CacheStatusAdminDown.String()+" or "+tc.CacheStatusOffline.String()+" status"), nil)
			return
		}
		*reqObj.OfflineReason = inf.User.UserName + ": " + *reqObj.OfflineReason
	} else {
		reqObj.OfflineReason = nil
	}

	existingStatus, existingStatusUpdatedTime := checkExistingStatusInfo(id, tx)
	if *status.Name != string(tc.CacheStatusOnline) && *status.Name != string(tc.CacheStatusReported) && *status.ID != existingStatus {
		dsIDs, err := getActiveDeliveryServicesThatOnlyHaveThisServerAssigned(id, serverInfo.Type, tx)
		if err != nil {
			sysErr = fmt.Errorf("getting Delivery Services to which server #%d is assigned that have no other servers: %v", id, err)
			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, sysErr)
			return
		}
		if len(dsIDs) > 0 {
			prefix := fmt.Sprintf("setting server status to '%s' would leave Active Delivery Service", *status.Name)
			alertText := InvalidStatusForDeliveryServicesAlertText(prefix, serverInfo.Type, dsIDs)
			api.WriteAlerts(w, r, http.StatusConflict, tc.CreateAlerts(tc.ErrorLevel, alertText))
			return
		}
	}
	if err := updateServerStatusAndOfflineReason(existingStatus, *status.ID, id, existingStatusUpdatedTime, reqObj.OfflineReason, tx); err != nil {
		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, err)
		return
	}
	offlineReason := ""
	if reqObj.OfflineReason != nil {
		offlineReason = *reqObj.OfflineReason
	}
	msg := "Updated status [ " + *status.Name + " ] for " + serverInfo.HostName + "." + serverInfo.DomainName + " [ " + offlineReason + " ]"

	// queue updates on child servers if server is ^EDGE or ^MID
	if strings.HasPrefix(serverInfo.Type, tc.CacheTypeEdge.String()) || strings.HasPrefix(serverInfo.Type, tc.CacheTypeMid.String()) {
		if err := queueUpdatesOnChildCaches(tx, serverInfo.CDNID, serverInfo.CachegroupID); err != nil {
			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, err)
			return
		}
		msg += " and queued updates on all child caches"
	}
	api.CreateChangeLogRawTx(api.ApiChange, msg, inf.User, tx)
	api.WriteRespAlert(w, r, tc.SuccessLevel, msg)
}

// queueUpdatesOnChildCaches queues updates on child caches of the given cdnID and parentCachegroupID and returns an error (if one occurs).
func queueUpdatesOnChildCaches(tx *sql.Tx, cdnID, parentCachegroupID int) error {
	q := `
/* topology_descendants finds the descendant topology nodes of the topology node
 * for the cachegroup containing server $2.
 */
WITH RECURSIVE topology_descendants AS (
/* This is the base case of the recursive CTE, the topology node for the
 * cachegroup containing cachegroup $2.
 */
	SELECT tcp.parent child, NULL cachegroup
	FROM cachegroup c
	JOIN topology_cachegroup tc ON c."name" = tc.cachegroup
	JOIN topology_cachegroup_parents tcp ON tc.id = tcp.parent
	WHERE c.id = $2
UNION ALL
/* Find all direct topology child nodes tc of a given topology descendant td. */
	SELECT tcp.child, tc.cachegroup
	FROM topology_descendants td, topology_cachegroup_parents tcp
	JOIN topology_cachegroup tc ON tcp.child = tc.id
	WHERE td.child = tcp.parent
/* server_topology_descendants is the set of every server whose cachegroup is a
 * descendant topology node found by topology_descendants.
 */
), server_topology_descendants AS (
SELECT c.id
FROM cachegroup c
JOIN topology_descendants td ON c."name" = td.cachegroup
)
UPDATE public.server
SET config_update_time = now()
WHERE server.cdn_id = $1
	   AND (server.cachegroup IN (
			SELECT id
			FROM cachegroup
			WHERE parent_cachegroup_id = $2
				OR secondary_parent_cachegroup_id = $2
			)
			OR server.cachegroup IN (SELECT stc.id FROM server_topology_descendants stc));
`
	if _, err := tx.Exec(q, cdnID, parentCachegroupID); err != nil {
		return errors.New("queueing updates on child caches: " + err.Error())
	}
	return nil
}

// checkExistingStatusInfo returns the existing status and status_last_updated values for the server in question
func checkExistingStatusInfo(serverID int, tx *sql.Tx) (int, time.Time) {
	status := 0
	var statusLastUpdated time.Time
	q := `SELECT status,
status_last_updated
FROM server
WHERE id = $1`
	response, err := tx.Query(q, serverID)
	if err != nil {
		log.Errorf("couldn't get status/ status_last_updated for server with id %v", serverID)
		return status, statusLastUpdated
	}
	defer response.Close()
	for response.Next() {
		if err := response.Scan(&status, &statusLastUpdated); err != nil {
			log.Errorf("couldn't get status/ status_last_updated of server with id %v, err: %v", serverID, err.Error())
		}
	}
	return status, statusLastUpdated
}

// updateServerStatusAndOfflineReason updates a server's status and offline_reason and returns an error (if one occurs).
func updateServerStatusAndOfflineReason(existingStatus, statusID, serverID int, existingStatusUpdatedTime time.Time, offlineReason *string, tx *sql.Tx) error {
	newStatusUpdatedTime := time.Now()
	// Set the status_last_updated time to the current time ONLY IF the new status is different from the old one
	if existingStatus == statusID {
		newStatusUpdatedTime = existingStatusUpdatedTime
	}
	q := `
UPDATE server
SET    status = $1,
       offline_reason = $2,
       status_last_updated = $3
WHERE  id = $4
`
	if _, err := tx.Exec(q, statusID, offlineReason, &newStatusUpdatedTime, serverID); err != nil {
		return errors.New("updating server status and offline_reason: " + err.Error())
	}
	return nil
}
