//go:build unix

package etchosts

import (
	"os"
	"path/filepath"
	"testing"

	"github.com/stretchr/testify/assert"
	"golang.org/x/sys/unix"
)

const baseFileContent1Spaces = `127.0.0.1 localhost localhost.localdomain localhost4 localhost4.localdomain4
::1 localhost localhost.localdomain localhost6 localhost6.localdomain6
`

const baseFileContent1Tabs = `127.0.0.1	localhost	localhost.localdomain	localhost4	localhost4.localdomain4
::1	localhost	localhost.localdomain	localhost6	localhost6.localdomain6
`

const baseFileContent1Mixed = `127.0.0.1	localhost localhost.localdomain localhost4 localhost4.localdomain4
::1	localhost localhost.localdomain localhost6 localhost6.localdomain6
`

const targetFileContent1 = `127.0.0.1	localhost localhost.localdomain localhost4 localhost4.localdomain4
::1	localhost localhost.localdomain localhost6 localhost6.localdomain6
`

const baseFileContent2 = `127.0.0.1	localhost
::1	localhost
1.1.1.1	name1
2.2.2.2	name2
`

const targetFileContent2 = `127.0.0.1	localhost
::1	localhost
1.1.1.1	name1
2.2.2.2	name2
`

const baseFileContent3Comments1 = `127.0.0.1	localhost #localhost
::1	localhost
# with comments
`

const baseFileContent3Comments2 = `#localhost
`

const targetFileContent3 = `127.0.0.1	localhost
::1	localhost
`

const baseFileContent4 = `127.0.0.1	localhost
`

const targetFileContent4 = `1.1.1.1	name1
2.2.2.2	name2 name3 name4
127.0.0.1	localhost
`

const targetFileContent5 = `1.1.1.1	name1 name2
2.2.2.2	name3
127.0.1.1	localhost
`

const baseFileContent6 = `127.0.0.1	localhost
::1	localhost
1.1.1.1 host.containers.internal host.docker.internal
`

const targetFileContent6 = `127.0.0.1	localhost
::1	localhost
1.1.1.1	host.containers.internal host.docker.internal
`

const baseFileContent7 = `
1.1.1.1
`

const targetFileContent7 = `127.0.0.1	localhost
::1	localhost
`

func TestNew(t *testing.T) {
	tests := []struct {
		name string
		// only used to trigger fails for not existing files
		baseFileName              string
		baseFileContent           string
		noWriteBaseFile           bool
		extraHosts                []string
		containerIPs              HostEntries
		hostContainersInternal    string
		expectedTargetFileContent string
		wantErrString             string
	}{
		{
			name:                      "with spaces",
			baseFileContent:           baseFileContent1Spaces,
			expectedTargetFileContent: targetFileContent1,
		},
		{
			name:                      "with tabs",
			baseFileContent:           baseFileContent1Tabs,
			expectedTargetFileContent: targetFileContent1,
		},
		{
			name:                      "with spaces and tabs",
			baseFileContent:           baseFileContent1Mixed,
			expectedTargetFileContent: targetFileContent1,
		},
		{
			name:                      "with more entries",
			baseFileContent:           baseFileContent2,
			expectedTargetFileContent: targetFileContent2,
		},
		{
			name:                      "with no entries",
			baseFileContent:           "",
			expectedTargetFileContent: targetFileContent3,
		},
		{
			name:                      "base file is empty",
			baseFileContent:           "",
			noWriteBaseFile:           true,
			expectedTargetFileContent: targetFileContent3,
		},
		{
			name:                      "with comments 1",
			baseFileContent:           baseFileContent3Comments1,
			expectedTargetFileContent: targetFileContent3,
		},
		{
			name:                      "with comments 2",
			baseFileContent:           baseFileContent3Comments2,
			expectedTargetFileContent: targetFileContent3,
		},
		{
			name:                      "extra hosts",
			baseFileContent:           baseFileContent4,
			extraHosts:                []string{"name1:1.1.1.1", "name2;name3;name4:2.2.2.2"},
			expectedTargetFileContent: targetFileContent4,
		},
		{
			name:                      "extra hosts with localhost",
			baseFileContent:           "",
			extraHosts:                []string{"name1;name2:1.1.1.1", "name3:2.2.2.2", "localhost:127.0.1.1"},
			expectedTargetFileContent: targetFileContent5,
		},
		{
			name:                      "with more entries and extra host",
			baseFileContent:           baseFileContent2,
			extraHosts:                []string{"name1:1.1.1.1"},
			expectedTargetFileContent: "1.1.1.1\tname1\n" + targetFileContent2,
		},
		{
			name:                      "with more entries and extra host",
			baseFileContent:           baseFileContent2,
			extraHosts:                []string{"name1:1.1.1.1"},
			expectedTargetFileContent: "1.1.1.1\tname1\n" + targetFileContent2,
		},
		{
			name:                      "with more entries and extra host",
			baseFileContent:           baseFileContent2,
			extraHosts:                []string{"name1:1.1.1.1"},
			expectedTargetFileContent: "1.1.1.1\tname1\n" + targetFileContent2,
		},
		{
			name:                      "container ips",
			baseFileContent:           baseFileContent1Spaces,
			containerIPs:              []HostEntry{{IP: "1.2.3.4", Names: []string{"conname", "hostname"}}},
			expectedTargetFileContent: targetFileContent1 + "1.2.3.4\tconname hostname\n",
		},
		{
			name:            "container ips 2",
			baseFileContent: baseFileContent1Spaces,
			containerIPs: []HostEntry{
				{IP: "1.2.3.4", Names: []string{"conname", "hostname"}},
				{IP: "fd::1", Names: []string{"conname", "hostname"}},
			},
			expectedTargetFileContent: targetFileContent1 + "1.2.3.4\tconname hostname\nfd::1\tconname hostname\n",
		},
		{
			name:                      "container ips and extra hosts",
			baseFileContent:           baseFileContent1Spaces,
			extraHosts:                []string{"name1:1.1.1.1"},
			containerIPs:              []HostEntry{{IP: "1.2.3.4", Names: []string{"conname", "hostname"}}},
			expectedTargetFileContent: "1.1.1.1\tname1\n" + targetFileContent1 + "1.2.3.4\tconname hostname\n",
		},
		{
			name:                      "container ips and extra hosts 2",
			baseFileContent:           baseFileContent2,
			extraHosts:                []string{"name1:1.1.1.1"},
			containerIPs:              []HostEntry{{IP: "1.2.3.4", Names: []string{"conname", "hostname"}}},
			expectedTargetFileContent: "1.1.1.1\tname1\n" + targetFileContent2 + "1.2.3.4\tconname hostname\n",
		},
		{
			name:                      "container ip name is not added when name is already present",
			baseFileContent:           baseFileContent2,
			containerIPs:              []HostEntry{{IP: "1.2.3.4", Names: []string{"name1", "hostname"}}},
			expectedTargetFileContent: targetFileContent2 + "1.2.3.4\thostname\n",
		},
		{
			name:                      "container ip name is not added when name is already present 2",
			baseFileContent:           baseFileContent2,
			containerIPs:              []HostEntry{{IP: "1.2.3.4", Names: []string{"name1"}}},
			expectedTargetFileContent: targetFileContent2,
		},
		{
			name:                      "container ip name is not added when name is already present in extra hosts",
			baseFileContent:           baseFileContent1Spaces,
			extraHosts:                []string{"somename:1.1.1.1"},
			containerIPs:              []HostEntry{{IP: "1.2.3.4", Names: []string{"somename", "hostname"}}},
			expectedTargetFileContent: "1.1.1.1\tsomename\n" + targetFileContent1 + "1.2.3.4\thostname\n",
		},
		{
			name:                      "with host.containers.internal ip",
			baseFileContent:           baseFileContent1Spaces,
			hostContainersInternal:    "10.0.0.1",
			expectedTargetFileContent: targetFileContent1 + "10.0.0.1\thost.containers.internal host.docker.internal\n",
		},
		{
			name:                      "with host.containers.internal ip and host-gateway",
			baseFileContent:           baseFileContent1Spaces,
			extraHosts:                []string{"gatewayname:host-gateway"},
			hostContainersInternal:    "10.0.0.1",
			expectedTargetFileContent: "10.0.0.1\tgatewayname\n" + targetFileContent1 + "10.0.0.1\thost.containers.internal host.docker.internal\n",
		},
		{
			name:                      "host.containers.internal not added when already present in extra hosts",
			baseFileContent:           baseFileContent1Spaces,
			extraHosts:                []string{"host.containers.internal:1.1.1.1"},
			hostContainersInternal:    "10.0.0.1",
			expectedTargetFileContent: "1.1.1.1\thost.containers.internal\n" + targetFileContent1 + "10.0.0.1\thost.docker.internal\n",
		},
		{
			name:                      "host.containers.internal and host.docker.internal not added when already present in extra hosts",
			baseFileContent:           baseFileContent1Spaces,
			extraHosts:                []string{"host.containers.internal:1.1.1.1", "host.docker.internal:1.1.1.1"},
			hostContainersInternal:    "10.0.0.1",
			expectedTargetFileContent: "1.1.1.1\thost.containers.internal\n1.1.1.1\thost.docker.internal\n" + targetFileContent1,
		},
		{
			name:                      "host.containers.internal not added when already present in base hosts",
			baseFileContent:           baseFileContent6,
			hostContainersInternal:    "10.0.0.1",
			expectedTargetFileContent: targetFileContent6,
		},
		{
			name:                      "invalid hosts content",
			baseFileContent:           baseFileContent7,
			expectedTargetFileContent: targetFileContent7,
		},
		// errors
		{
			name:            "base file does not exists",
			baseFileName:    "does/not/exists123456789",
			noWriteBaseFile: true,
			wantErrString:   "no such file or directory",
		},
		{
			name:            "invalid extra hosts hostname empty",
			baseFileContent: baseFileContent1Spaces,
			extraHosts:      []string{":1.1.1.1"},
			wantErrString:   "hostname in host entry \":1.1.1.1\" is empty",
		},
		{
			name:            "invalid extra hosts empty ip",
			baseFileContent: baseFileContent1Spaces,
			extraHosts:      []string{"name:"},
			wantErrString:   "IP address in host entry \"name:\" is empty",
		},
		{
			name:            "invalid extra hosts empty ip",
			baseFileContent: baseFileContent1Spaces,
			extraHosts:      []string{"name:"},
			wantErrString:   "IP address in host entry \"name:\" is empty",
		},
		{
			name:            "invalid extra hosts format",
			baseFileContent: baseFileContent1Spaces,
			extraHosts:      []string{"name"},
			wantErrString:   "unable to parse host entry \"name\": incorrect format",
		},
		{
			name:            "invalid host-gateway",
			baseFileContent: baseFileContent1Spaces,
			extraHosts:      []string{"gatewayname:host-gateway"},
			wantErrString:   "host containers internal IP address is empty",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			baseHostFile := tt.baseFileName
			if !tt.noWriteBaseFile {
				baseHostFile = filepath.Join(t.TempDir(), "basehosts")
				err := os.WriteFile(baseHostFile, []byte(tt.baseFileContent), 0o644)
				assert.NoError(t, err, "failed to create base host file: %v", err)
			}

			targetFile := filepath.Join(t.TempDir(), "target")

			params := &Params{
				BaseFile:                 baseHostFile,
				ExtraHosts:               tt.extraHosts,
				ContainerIPs:             tt.containerIPs,
				HostContainersInternalIP: tt.hostContainersInternal,
				TargetFile:               targetFile,
			}

			err := New(params)
			if tt.wantErrString != "" {
				assert.ErrorContains(t, err, tt.wantErrString)
				return
			}
			assert.NoError(t, err, "New() failed")

			content, err := os.ReadFile(targetFile)
			assert.NoErrorf(t, err, "failed to read target host file: %v", err)
			assert.Equal(t, tt.expectedTargetFileContent, string(content), "check hosts content")
		})
	}
}

func TestAdd(t *testing.T) {
	tests := []struct {
		name                      string
		baseFileContent           string
		entries                   HostEntries
		expectedTargetFileContent string
		wantErrString             string
	}{
		{
			name:                      "add entry",
			baseFileContent:           baseFileContent1Mixed,
			entries:                   HostEntries{{IP: "1.1.1.1", Names: []string{"name1", "name2"}}},
			expectedTargetFileContent: targetFileContent1 + "1.1.1.1\tname1 name2\n",
		},
		{
			name:            "add two entries",
			baseFileContent: baseFileContent1Mixed,
			entries: HostEntries{
				{IP: "1.1.1.1", Names: []string{"name1", "name2"}},
				{IP: "1.1.1.2", Names: []string{"name3", "name4"}},
			},
			expectedTargetFileContent: targetFileContent1 + "1.1.1.1\tname1 name2\n1.1.1.2\tname3 name4\n",
		},
		{
			name:                      "add entry to empty file",
			baseFileContent:           "",
			entries:                   HostEntries{{IP: "1.1.1.1", Names: []string{"name1", "name2"}}},
			expectedTargetFileContent: "1.1.1.1\tname1 name2\n",
		},
		{
			name:                      "add entry which already exists",
			baseFileContent:           baseFileContent2,
			entries:                   HostEntries{{IP: "1.1.1.1", Names: []string{"name1", "name2"}}},
			expectedTargetFileContent: targetFileContent2,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			hostFile := filepath.Join(t.TempDir(), "hosts")
			err := os.WriteFile(hostFile, []byte(tt.baseFileContent), 0o644)
			assert.NoError(t, err, "failed to create base host file: %v", err)

			var st unix.Stat_t
			err = unix.Stat(hostFile, &st)
			assert.NoError(t, err, "stat host file: %v", err)

			err = Add(hostFile, tt.entries)
			if tt.wantErrString != "" {
				assert.ErrorContains(t, err, tt.wantErrString)
				return
			}
			assert.NoError(t, err, "Add() failed")

			content, err := os.ReadFile(hostFile)
			assert.NoErrorf(t, err, "failed to read host file: %v", err)
			assert.Equal(t, tt.expectedTargetFileContent, string(content), "check hosts content")

			var st2 unix.Stat_t
			err = unix.Stat(hostFile, &st2)
			assert.NoError(t, err, "stat host file: %v", err)
			assert.Equal(t, st.Ino, st2.Ino, "inode before and after Add() must match")
		})
	}
}

func TestAddIfExists(t *testing.T) {
	tests := []struct {
		name                      string
		baseFileContent           string
		existsEntries             HostEntries
		newEntries                HostEntries
		expectedTargetFileContent string
		wantErrString             string
	}{
		{
			name:                      "add entry",
			baseFileContent:           baseFileContent1Mixed,
			newEntries:                HostEntries{{IP: "1.1.1.1", Names: []string{"name1", "name2"}}},
			expectedTargetFileContent: targetFileContent1 + "1.1.1.1\tname1 name2\n",
		},
		{
			name:                      "add entry with existing entries match",
			baseFileContent:           baseFileContent1Mixed,
			existsEntries:             HostEntries{{IP: "::1", Names: []string{"localhost"}}},
			newEntries:                HostEntries{{IP: "1.1.1.1", Names: []string{"name1", "name2"}}},
			expectedTargetFileContent: targetFileContent1 + "1.1.1.1\tname1 name2\n",
		},
		{
			name:                      "existing entries with no match should not add",
			baseFileContent:           baseFileContent1Mixed,
			existsEntries:             HostEntries{{IP: "::1", Names: []string{"name"}}},
			newEntries:                HostEntries{{IP: "1.1.1.1", Names: []string{"name1", "name2"}}},
			expectedTargetFileContent: targetFileContent1,
		},
		{
			name:            "add two entries",
			baseFileContent: baseFileContent1Mixed,
			newEntries: HostEntries{
				{IP: "1.1.1.1", Names: []string{"name1", "name2"}},
				{IP: "1.1.1.2", Names: []string{"name3", "name4"}},
			},
			expectedTargetFileContent: targetFileContent1 + "1.1.1.1\tname1 name2\n1.1.1.2\tname3 name4\n",
		},
		{
			name:            "add two entries with existing entries match",
			baseFileContent: baseFileContent1Mixed,
			existsEntries:   HostEntries{{IP: "127.0.0.1", Names: []string{"localhost"}}},
			newEntries: HostEntries{
				{IP: "1.1.1.1", Names: []string{"name1", "name2"}},
				{IP: "1.1.1.2", Names: []string{"name3", "name4"}},
			},
			expectedTargetFileContent: targetFileContent1 + "1.1.1.1\tname1 name2\n1.1.1.2\tname3 name4\n",
		},
		{
			name:                      "add entry to empty file",
			baseFileContent:           "",
			newEntries:                HostEntries{{IP: "1.1.1.1", Names: []string{"name1", "name2"}}},
			expectedTargetFileContent: "1.1.1.1\tname1 name2\n",
		},
		{
			name:                      "add entry to empty file with no existing match",
			baseFileContent:           "",
			existsEntries:             HostEntries{{IP: "127.0.0.1", Names: []string{"localhost"}}},
			newEntries:                HostEntries{{IP: "1.1.1.1", Names: []string{"name1", "name2"}}},
			expectedTargetFileContent: "",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			hostFile := filepath.Join(t.TempDir(), "hosts")
			err := os.WriteFile(hostFile, []byte(tt.baseFileContent), 0o644)
			assert.NoError(t, err, "failed to create base host file: %v", err)

			var st unix.Stat_t
			err = unix.Stat(hostFile, &st)
			assert.NoError(t, err, "stat host file: %v", err)

			err = AddIfExists(hostFile, tt.existsEntries, tt.newEntries)
			if tt.wantErrString != "" {
				assert.ErrorContains(t, err, tt.wantErrString)
				return
			}
			assert.NoError(t, err, "AddIfExists() failed")

			content, err := os.ReadFile(hostFile)
			assert.NoErrorf(t, err, "failed to read host file: %v", err)
			assert.Equal(t, tt.expectedTargetFileContent, string(content), "check hosts content")

			var st2 unix.Stat_t
			err = unix.Stat(hostFile, &st2)
			assert.NoError(t, err, "stat host file: %v", err)
			assert.Equal(t, st.Ino, st2.Ino, "inode before and after AddIfExists() must match")
		})
	}
}

func TestRemove(t *testing.T) {
	tests := []struct {
		name                      string
		baseFileContent           string
		entries                   HostEntries
		expectedTargetFileContent string
	}{
		{
			name:                      "remove entry which does not exists",
			baseFileContent:           baseFileContent1Spaces,
			entries:                   HostEntries{{IP: "1.1.1.1", Names: []string{"name1", "name2"}}},
			expectedTargetFileContent: targetFileContent1,
		},
		{
			name:                      "do not remove entry when only ip matches",
			baseFileContent:           baseFileContent2,
			entries:                   HostEntries{{IP: "1.1.1.1", Names: []string{"new1", "new2"}}},
			expectedTargetFileContent: targetFileContent2,
		},
		{
			name:            "remove two entries",
			baseFileContent: baseFileContent2,
			entries: HostEntries{
				{IP: "1.1.1.1", Names: []string{"name1"}},
				{IP: "2.2.2.2", Names: []string{"name2", "name4"}},
			},
			expectedTargetFileContent: targetFileContent3,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			hostFile := filepath.Join(t.TempDir(), "hosts")
			err := os.WriteFile(hostFile, []byte(tt.baseFileContent), 0o644)
			assert.NoError(t, err, "failed to create base host file: %v", err)

			var st unix.Stat_t
			err = unix.Stat(hostFile, &st)
			assert.NoError(t, err, "stat host file: %v", err)

			err = Remove(hostFile, tt.entries)
			assert.NoError(t, err, "Remove() failed")

			content, err := os.ReadFile(hostFile)
			assert.NoErrorf(t, err, "failed to read host file: %v", err)
			assert.Equal(t, tt.expectedTargetFileContent, string(content), "check hosts content")

			var st2 unix.Stat_t
			err = unix.Stat(hostFile, &st2)
			assert.NoError(t, err, "stat host file: %v", err)
			assert.Equal(t, st.Ino, st2.Ino, "inode before and after Remove() must match")
		})
	}
}
