//  Copyright 2024 Google LLC
//
//  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
//
//     https://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.

// Package textconfig provides a way to read and write plain text configuration
// files (such as sshd.conf, pam.conf, etc).
package textconfig

import (
	"fmt"
	"os"
	"strings"

	"github.com/GoogleCloudPlatform/galog"
	"github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file"
)

// Position is the position of a block in the file.
type Position int

const (
	// DefaultSpacer is the default spacer used to separate key & value when
	// writing the file.
	DefaultSpacer = " "

	// Top is the position identifier for the top of the file.
	Top Position = iota
	// Bottom is the position identifier for the bottom of the file.
	Bottom
)

// Entry is the configuration entry, it indicates the key and value of the
// entry.
type Entry struct {
	// key is the key of the entry.
	key string
	// value is the value of the entry.
	value string
}

// NewEntry creates a new entry.
func NewEntry(key, value string) *Entry {
	return &Entry{key: key, value: value}
}

// format returns the formatted entry string (applying the provided spacer
// between the key and value).
func (en *Entry) format(spacer string) string {
	return strings.TrimSpace(fmt.Sprintf("%s%s%s", en.key, spacer, en.value))
}

// Block is a group of entries that are in the same position in the file.
type Block struct {
	// entries is the list of entries in the block.
	entries []*Entry
	// position is the position of the block in the file (top or bottom).
	position Position
}

// NewBlock creates a new block for the given position.
func NewBlock(pos Position) *Block {
	return &Block{position: pos}
}

// Append appends an entry to the block.
func (bl *Block) Append(key, value string) {
	bl.entries = append(bl.entries, &Entry{key, value})
}

// lines returns the formatted lines of the block.
func (bl *Block) lines(delimiter *Delimiter, spacer string) []string {
	var lines []string

	// Block without entries, no need to write anything - avoid writing empty
	// blocks (with delimiters only).
	if len(bl.entries) == 0 {
		return nil
	}

	lines = append(lines, delimiter.Start)
	for _, entry := range bl.entries {
		lines = append(lines, entry.format(spacer))
	}
	lines = append(lines, delimiter.End)

	return lines
}

// Delimiter is the delimiter for a block. It indicates the block start matching
// pattern and the block end matching pattern.
type Delimiter struct {
	// Start is the start matching pattern.
	Start string
	// End is the end matching pattern.
	End string
}

// Handle is represents the configuration file.
type Handle struct {
	// file is the path to the configuration file.
	file string
	// mode is the mode of the file.
	mode os.FileMode
	// blocks is the list of blocks in the file.
	blocks []*Block
	// opts is the options for the configuration file.
	opts Options
}

// Options is the options for the configuration file.
type Options struct {
	// Delimiters is the default delimiter for the file, it is used when writing
	// the file - it's also considered for reading and identifying blocks of
	// interest along with the known delimiters (ever supported delimiters).
	Delimiters *Delimiter
	// KnownDelimiters is the list of known delimiters for the file i.e. previously
	// used delimiters and still supported for backwards compatibility.
	KnownDelimiters []*Delimiter
	// AllDelimiters is the list of all delimiters for the file, it's ordered as:
	// - Delimiters
	// - KnownDelimiters
	AllDelimiters []*Delimiter
	// Spacer is the spacer used to separate key & value when writing the file.
	Spacer string
	// deprecatedEntries is the list of deprecated entries that should be removed
	// from the file.
	DeprecatedEntries []*Entry
}

// New creates a new configuration file handle.
func New(file string, mode os.FileMode, opts Options) *Handle {
	var allDelimiters []*Delimiter

	allDelimiters = append(allDelimiters, opts.Delimiters)
	allDelimiters = append(allDelimiters, opts.KnownDelimiters...)

	for _, dd := range allDelimiters {
		if dd == nil {
			continue
		}
		opts.AllDelimiters = append(opts.AllDelimiters, dd)
	}

	if opts.Spacer == "" {
		opts.Spacer = DefaultSpacer
	}

	return &Handle{file: file, mode: mode, opts: opts}
}

// AddBlock adds a block to the configuration handle.
func (h *Handle) AddBlock(block *Block) {
	h.blocks = append(h.blocks, block)
}

// Cleanup removes the blocks and deprecated entries managed by the Handle from
// the file.
func (h *Handle) Cleanup() error {
	if !file.Exists(h.file, file.TypeFile) {
		galog.V(1).Debugf("File %s does not exist, skipping config cleanup.", h.file)
		return nil
	}
	return h.cleanupFile()
}

// cleanupFile is the internal implementation of file cleaning up, it reads the
// file, cleans up the managed blocks and deprecated entries, and writes the
// file.
func (h *Handle) cleanupFile() error {
	data, err := os.ReadFile(h.file)
	if err != nil {
		return fmt.Errorf("failed to read file %q: %v", h.file, err)
	}

	lines := strings.Split(string(data), "\n")
	output := h.cleanup(lines)

	if err := os.WriteFile(h.file, []byte(strings.Join(output, "\n")), h.mode); err != nil {
		return fmt.Errorf("failed to write file %q: %v", h.file, err)
	}

	return nil
}

// cleanup removes our managed blocks.
func (h *Handle) cleanup(lines []string) []string {
	var output []string
	inBlock := false

	for _, line := range lines {
		if h.matchStart(line) {
			inBlock = true
			continue
		}
		if h.matchEnd(line) {
			inBlock = false
			continue
		}

		// Leave out any lines inside a block.
		if inBlock {
			continue
		}

		// Leave out any deprecated entries.
		foundDeprecatedEntry := false
		for _, entry := range h.opts.DeprecatedEntries {
			if strings.HasPrefix(strings.TrimSpace(line), entry.key) && strings.HasSuffix(strings.TrimSpace(line), entry.value) {
				foundDeprecatedEntry = true
				break
			}
		}
		if foundDeprecatedEntry {
			continue
		}

		output = append(output, line)
	}

	return output
}

// matchStart returns true if the line matches any of the delimiters' start.
func (h *Handle) matchStart(line string) bool {
	for _, dd := range h.opts.AllDelimiters {
		if strings.TrimSpace(line) == dd.Start {
			return true
		}
	}
	return false
}

// matchEnd returns true if the line matches any of the delimiters' end.
func (h *Handle) matchEnd(line string) bool {
	for _, dd := range h.opts.AllDelimiters {
		if strings.TrimSpace(line) == dd.End {
			return true
		}
	}
	return false
}

// Apply applies the changes to the file.
func (h *Handle) Apply() error {
	var cleanedup []string

	if file.Exists(h.file, file.TypeFile) {
		// use internal function cleanupFile() and avoid double checking file
		// exists.
		if err := h.cleanupFile(); err != nil {
			return fmt.Errorf("failed to cleanup file %q: %v", h.file, err)
		}

		data, err := os.ReadFile(h.file)
		if err != nil {
			return fmt.Errorf("failed to read file %q: %v", h.file, err)
		}

		lines := strings.Split(string(data), "\n")
		cleanedup = h.cleanup(lines)
	}

	var topBlocks []*Block
	var bottomBlocks []*Block

	for _, block := range h.blocks {
		switch block.position {
		case Top:
			topBlocks = append(topBlocks, block)
		case Bottom:
			bottomBlocks = append(bottomBlocks, block)
		}
	}

	var output []string

	for _, block := range topBlocks {
		output = append(output, block.lines(h.opts.Delimiters, h.opts.Spacer)...)
	}

	output = append(output, cleanedup...)

	for _, block := range bottomBlocks {
		output = append(output, block.lines(h.opts.Delimiters, h.opts.Spacer)...)
	}

	if err := os.WriteFile(h.file, []byte(strings.Join(output, "\n")), h.mode); err != nil {
		return fmt.Errorf("failed to write file %q: %v", h.file, err)
	}

	return nil
}
