/*
Copyright © contributors to CloudNativePG, established as
CloudNativePG a Series of LF Projects, 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

    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.

SPDX-License-Identifier: Apache-2.0
*/

package configfile

import (
	"os"
	"path/filepath"

	"github.com/cloudnative-pg/machinery/pkg/fileutils"

	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
)

var _ = Describe("update Postgres configuration files", func() {
	var tmpDir string

	_ = BeforeEach(func() {
		var err error
		tmpDir, err = os.MkdirTemp("", "configuration-test-")
		Expect(err).NotTo(HaveOccurred())
	})

	_ = AfterEach(func() {
		Expect(os.RemoveAll(tmpDir)).To(Succeed())
	})

	It("must append missing keys", func() {
		initialContent := "# Do not edit this file manually!\n" +
			"# It will be overwritten by the ALTER SYSTEM command.\n" +
			"primary_conninfo = 'host=someHost user=someUser application_name=nodeNameBis'\n" +
			"\n" +
			"recovery_target_timeline = 'latest'\n"

		testFile := filepath.Join(tmpDir, "custom.conf")
		Expect(fileutils.WriteStringToFile(testFile, initialContent)).To(BeTrue())

		Expect(UpdatePostgresConfigurationFile(testFile, map[string]string{
			"test.key":         "test.value",
			"primary_conninfo": "host=someUpdatedHost user=someUpdatedUser application_name=nodeNameBis",
		})).To(BeTrue())

		wantedContent := "# Do not edit this file manually!\n" +
			"# It will be overwritten by the ALTER SYSTEM command.\n" +
			"primary_conninfo = 'host=someUpdatedHost user=someUpdatedUser application_name=nodeNameBis'\n" +
			"\n" +
			"recovery_target_timeline = 'latest'\n" +
			"test.key = 'test.value'\n"

		finalContent, err := fileutils.ReadFile(testFile)
		Expect(err).NotTo(HaveOccurred())
		Expect(string(finalContent)).To(Equal(wantedContent))
	})

	It("must remove missing managed keys", func() {
		initialContent := "# Do not edit this file manually!\n" +
			"# It will be overwritten by the ALTER SYSTEM command.\n" +
			"primary_conninfo = 'host=someHost user=someUser application_name=nodeNameBis'\n" +
			"\n" +
			"recovery_target_timeline = 'latest'\n"

		testFile := filepath.Join(tmpDir, "custom.conf")
		Expect(fileutils.WriteStringToFile(testFile, initialContent)).To(BeTrue())

		Expect(UpdatePostgresConfigurationFile(testFile, map[string]string{
			"test.key":         "test.value",
			"primary_conninfo": "host=someUpdatedHost user=someUpdatedUser application_name=nodeNameBis",
		}, "recovery_target_timeline")).To(BeTrue())

		wantedContent := "# Do not edit this file manually!\n" +
			"# It will be overwritten by the ALTER SYSTEM command.\n" +
			"primary_conninfo = 'host=someUpdatedHost user=someUpdatedUser application_name=nodeNameBis'\n" +
			"\n" +
			"test.key = 'test.value'\n"

		finalContent, err := fileutils.ReadFile(testFile)
		Expect(err).NotTo(HaveOccurred())
		Expect(string(finalContent)).To(Equal(wantedContent))
	})

	It("must not change managed keys when present", func() {
		initialContent := "# Do not edit this file manually!\n" +
			"# It will be overwritten by the ALTER SYSTEM command.\n" +
			"primary_conninfo = 'host=someHost user=someUser application_name=nodeNameBis'\n" +
			"\n" +
			"recovery_target_timeline = 'latest'\n"

		testFile := filepath.Join(tmpDir, "custom.conf")
		Expect(fileutils.WriteStringToFile(testFile, initialContent)).To(BeTrue())

		Expect(UpdatePostgresConfigurationFile(testFile, map[string]string{
			"test.key":         "test.value",
			"primary_conninfo": "host=someUpdatedHost user=someUpdatedUser application_name=nodeNameBis",
		}, "primary_conninfo")).To(BeTrue())

		wantedContent := "# Do not edit this file manually!\n" +
			"# It will be overwritten by the ALTER SYSTEM command.\n" +
			"primary_conninfo = 'host=someUpdatedHost user=someUpdatedUser application_name=nodeNameBis'\n" +
			"\n" +
			"recovery_target_timeline = 'latest'\n" +
			"test.key = 'test.value'\n"

		finalContent, err := fileutils.ReadFile(testFile)
		Expect(err).NotTo(HaveOccurred())
		Expect(string(finalContent)).To(Equal(wantedContent))
	})

	It("must work with missing files", func() {
		testFile := filepath.Join(tmpDir, "custom.conf")
		Expect(fileutils.FileExists(testFile)).To(BeFalse())

		Expect(UpdatePostgresConfigurationFile(testFile, map[string]string{
			"primary_conninfo": "host=someHost user=someUser application_name=nodeName",
		})).To(BeTrue())

		wantedContent := "primary_conninfo = 'host=someHost user=someUser application_name=nodeName'\n"

		finalContent, err := fileutils.ReadFile(testFile)
		Expect(err).NotTo(HaveOccurred())
		Expect(string(finalContent)).To(Equal(wantedContent))
	})

	It("must add missing includes", func() {
		initialContent := "# This is a test file!\n" +
			"# Comments are ignored\n" +
			"\n" +
			"one_key = 'one value'\n" +
			"another_key = On"

		testFile := filepath.Join(tmpDir, "postgresql.conf")
		Expect(fileutils.WriteStringToFile(testFile, initialContent)).To(BeTrue())

		Expect(EnsureIncludes(testFile, "custom.conf", "override.conf")).To(BeTrue())

		wantedContent := "# This is a test file!\n" +
			"# Comments are ignored\n" +
			"\n" +
			"one_key = 'one value'\n" +
			"another_key = On\n" +
			"\n" +
			"# load CloudNativePG custom.conf configuration\n" +
			"include 'custom.conf'\n" +
			"\n" +
			"# load CloudNativePG override.conf configuration\n" +
			"include 'override.conf'\n"

		finalContent, err := fileutils.ReadFile(testFile)
		Expect(err).NotTo(HaveOccurred())
		Expect(string(finalContent)).To(Equal(wantedContent))
	})

	It("must left untouched existing includes", func() {
		initialContent := "# This is a test file!\n" +
			"# Comments are ignored\n" +
			"one_key = 'one value'\n" +
			"another_key = On\n" +
			"\n" +
			"# customized comment\n" +
			"include 'custom.conf'"

		testFile := filepath.Join(tmpDir, "postgresql.conf")
		Expect(fileutils.WriteStringToFile(testFile, initialContent)).To(BeTrue())

		Expect(EnsureIncludes(testFile, "custom.conf", "override.conf")).To(BeTrue())

		wantedContent := "# This is a test file!\n" +
			"# Comments are ignored\n" +
			"one_key = 'one value'\n" +
			"another_key = On\n" +
			"\n" +
			"# customized comment\n" +
			"include 'custom.conf'\n" +
			"\n" +
			"# load CloudNativePG override.conf configuration\n" +
			"include 'override.conf'\n"

		finalContent, err := fileutils.ReadFile(testFile)
		Expect(err).NotTo(HaveOccurred())
		Expect(string(finalContent)).To(Equal(wantedContent))
	})
})

var _ = Describe("Update configuration files", func() {
	It("must append missing keys", func() {
		initialContent := []string{
			"# Do not edit this file manually!",
			"# It will be overwritten by the ALTER SYSTEM command.",
			"primary_conninfo = 'host=someHost user=someUser application_name=nodeName'",
			"",
			"recovery_target_timeline = 'latest'",
		}

		updatedContent := UpdateConfigurationContents(initialContent, map[string]string{
			"test.key": "test.value",
		})

		wantedContent := []string{
			"# Do not edit this file manually!",
			"# It will be overwritten by the ALTER SYSTEM command.",
			"primary_conninfo = 'host=someHost user=someUser application_name=nodeName'",
			"",
			"recovery_target_timeline = 'latest'",
			"test.key = 'test.value'",
		}

		Expect(updatedContent).To(Equal(wantedContent))
	})

	It("must remove repeated keys", func() {
		initialContent := []string{
			"# Do not edit this file manually!",
			"# It will be overwritten by the ALTER SYSTEM command.",
			"primary_conninfo = 'host=someHost1 user=someUser1 application_name=nodeName1'",
			"",
			"recovery_target_timeline = 'latest'",
			"primary_conninfo = 'host=someHost2 user=someUser2 application_name=nodeName2'",
		}

		updatedContent := UpdateConfigurationContents(initialContent, map[string]string{
			"primary_conninfo": "host=someHost user=someUser application_name=nodeName",
		})

		wantedContent := []string{
			"# Do not edit this file manually!",
			"# It will be overwritten by the ALTER SYSTEM command.",
			"primary_conninfo = 'host=someHost user=someUser application_name=nodeName'",
			"",
			"recovery_target_timeline = 'latest'",
		}

		Expect(updatedContent).To(Equal(wantedContent))
	})
})

var _ = Describe("escapePostgresConfLiteral", func() {
	DescribeTable("produces a value that round-trips through the PostgreSQL config file parser",
		func(input, expected string) {
			Expect(escapePostgresConfLiteral(input)).To(Equal(expected))
		},
		Entry("plain string", "hello", "'hello'"),
		Entry("empty string", "", "''"),
		Entry("single quote", "hello'world", "'hello''world'"),
		Entry("backslash", `hello\world`, `'hello\\world'`),
		Entry("backslash followed by n (two chars)", `a\nb`, `'a\\nb'`),
		Entry("literal newline", "a\nb", `'a\nb'`),
		Entry("literal carriage return", "a\rb", `'a\rb'`),
		Entry("literal tab", "a\tb", `'a\tb'`),
		Entry("literal backspace", "a\bb", `'a\bb'`),
		Entry("literal form feed", "a\fb", `'a\fb'`),
		Entry("backslash and quote together",
			`a'\b`,
			`'a''\\b'`),
		Entry("everything at once",
			"a'b\\c\nd\te",
			`'a''b\\c\nd\te'`),
	)

	It("produces a quoted literal that PostgreSQL reverses back to the original", func() {
		// The config file parser applies these rules inside single-quoted strings:
		//   ''   -> '
		//   \\   -> \
		//   \n   -> newline
		//   \r   -> CR
		//   \t   -> tab
		//   \b   -> backspace
		//   \f   -> form feed
		//   \X   -> X (backslash stripped for any other X)
		reverse := func(quoted string) string {
			// Expect surrounding single quotes
			Expect(quoted[:1]).To(Equal("'"))
			Expect(quoted[len(quoted)-1:]).To(Equal("'"))
			inner := quoted[1 : len(quoted)-1]
			var b []byte
			for i := 0; i < len(inner); i++ {
				switch inner[i] {
				case '\'':
					Expect(i+1 < len(inner)).To(BeTrue())
					Expect(inner[i+1]).To(Equal(byte('\'')))
					b = append(b, '\'')
					i++
				case '\\':
					Expect(i+1 < len(inner)).To(BeTrue())
					switch inner[i+1] {
					case '\\':
						b = append(b, '\\')
					case 'n':
						b = append(b, '\n')
					case 'r':
						b = append(b, '\r')
					case 't':
						b = append(b, '\t')
					case 'b':
						b = append(b, '\b')
					case 'f':
						b = append(b, '\f')
					default:
						b = append(b, inner[i+1])
					}
					i++
				default:
					b = append(b, inner[i])
				}
			}
			return string(b)
		}

		cases := []string{
			"hello",
			"",
			"hello'world",
			`hello\world`,
			`a\nb`,
			"a\nb",
			"a\rb",
			"a\tb",
			"a'b\\c\nd\te",
			`multiple 'quotes' and \back\slashes`,
		}
		for _, value := range cases {
			Expect(reverse(escapePostgresConfLiteral(value))).To(Equal(value),
				"round-trip failed for input %q", value)
		}
	})
})

var _ = Describe("UpdateConfigurationContents with special characters", func() {
	It("escapes single quotes in values", func() {
		updated := UpdateConfigurationContents(nil, map[string]string{
			"recovery_target_name": "my'restore'point",
		})
		Expect(updated).To(ConsistOf(
			"recovery_target_name = 'my''restore''point'",
		))
	})

	It("escapes backslashes without generating E-strings", func() {
		// pq.QuoteLiteral generates `E'...'` when the input contains a backslash,
		// which the postgresql.conf lexer does not understand. We must produce
		// the plain single-quoted form with doubled backslashes.
		updated := UpdateConfigurationContents(nil, map[string]string{
			"archive_command": `test \ command`,
		})
		Expect(updated).To(ConsistOf(
			`archive_command = 'test \\ command'`,
		))
	})

	It("escapes literal newlines", func() {
		updated := UpdateConfigurationContents(nil, map[string]string{
			"recovery_target_name": "line1\nline2",
		})
		Expect(updated).To(ConsistOf(
			`recovery_target_name = 'line1\nline2'`,
		))
	})
})

var _ = Describe("Remove configuration files option", func() {
	It("keeps the initial input if the option to be removed is not matched", func() {
		initialContent := []string{
			"# Do not edit this file manually!",
			"# It will be overwritten by the ALTER SYSTEM command.",
			"primary_conninfo = 'host=someHost user=someUser application_name=nodeName'",
			"",
			"recovery_target_timeline = 'latest'",
		}

		updatedContent := RemoveOptionsFromConfigurationContents(initialContent, "archive_mode")

		Expect(updatedContent).To(Equal(initialContent))
	})

	It("must delete lines with the given option", func() {
		initialContent := []string{
			"# Do not edit this file manually!",
			"# It will be overwritten by the ALTER SYSTEM command.",
			"primary_conninfo = 'host=someHost user=someUser application_name=nodeName'",
			"",
			"archive_mode = 'on'",
			"recovery_target_timeline = 'latest'",
			"archive_mode = 'always'",
		}

		updatedContent := RemoveOptionsFromConfigurationContents(initialContent, "archive_mode")

		wantedContent := []string{
			"# Do not edit this file manually!",
			"# It will be overwritten by the ALTER SYSTEM command.",
			"primary_conninfo = 'host=someHost user=someUser application_name=nodeName'",
			"",
			"recovery_target_timeline = 'latest'",
		}

		Expect(updatedContent).To(Equal(wantedContent))
	})
})
