package clientinfo

import (
	"bufio"
	"bytes"
	"io"
	"net/netip"
	"os"
	"strings"
	"sync"

	"github.com/fsnotify/fsnotify"
	"github.com/jaytaylor/go-hostsfile"

	"github.com/Control-D-Inc/ctrld"
)

const (
	ipv4LocalhostName   = "localhost"
	ipv6LocalhostName   = "ip6-localhost"
	ipv6LoopbackName    = "ip6-loopback"
	hostEntriesConfPath = "/var/unbound/host_entries.conf"
)

// hostsFile provides client discovery functionality using system hosts file.
type hostsFile struct {
	watcher *fsnotify.Watcher
	mu      sync.Mutex
	m       map[string][]string
	logger  *ctrld.Logger
}

// init performs initialization works, which is necessary before hostsFile can be fully operated.
func (hf *hostsFile) init() error {
	watcher, err := fsnotify.NewWatcher()
	if err != nil {
		return err
	}
	hf.watcher = watcher
	if err := hf.watcher.Add(hostsfile.HostsPath); err != nil {
		return err
	}
	// Conservatively adding hostEntriesConfPath, since it is not available everywhere.
	_ = hf.watcher.Add(hostEntriesConfPath)
	return hf.refresh()
}

// refresh reloads hosts file entries.
func (hf *hostsFile) refresh() error {
	m, err := hostsfile.ParseHosts(hostsfile.ReadHostsFile())
	if err != nil {
		return err
	}
	hf.mu.Lock()
	hf.m = m
	// override hosts file with host_entries.conf content if present.
	hem, err := parseHostEntriesConf(hostEntriesConfPath)
	if err != nil && !os.IsNotExist(err) {
		hf.logger.Debug().Err(err).Msg("Could not read host_entries.conf file")
	}
	for k, v := range hem {
		hf.m[k] = v
	}
	hf.mu.Unlock()
	return nil
}

// watchChanges watches and updates hosts file data if any changes happens.
func (hf *hostsFile) watchChanges() {
	if hf.watcher == nil {
		return
	}
	for {
		select {
		case event, ok := <-hf.watcher.Events:
			if !ok {
				return
			}
			if event.Has(fsnotify.Write) || event.Has(fsnotify.Rename) || event.Has(fsnotify.Chmod) || event.Has(fsnotify.Remove) {
				if err := hf.refresh(); err != nil && !os.IsNotExist(err) {
					hf.logger.Err(err).Msg("Hosts file changed but Failed to update client info")
				}
			}
		case err, ok := <-hf.watcher.Errors:
			if !ok {
				return
			}
			hf.logger.Err(err).Msg("Could not watch client info file")
		}
	}

}

// LookupHostnameByIP returns hostname for given IP from current hosts file entries.
func (hf *hostsFile) LookupHostnameByIP(ip string) string {
	hf.mu.Lock()
	defer hf.mu.Unlock()
	if names := hf.m[ip]; len(names) > 0 {
		isLoopback := ip == ipV4Loopback || ip == ipv6Loopback
		for _, hostname := range names {
			name := normalizeHostname(hostname)
			// Ignoring ipv4/ipv6 loopback entry.
			if isLoopback && isLocalhostName(name) {
				continue
			}
			return name
		}
	}
	return ""
}

// LookupHostnameByMac returns hostname for given Mac from current hosts file entries.
func (hf *hostsFile) LookupHostnameByMac(mac string) string {
	return ""
}

// String returns human-readable format of hostsFile.
func (hf *hostsFile) String() string {
	return "hosts"
}

func (hf *hostsFile) lookupIPByHostname(name string, v6 bool) string {
	if hf == nil {
		return ""
	}
	hf.mu.Lock()
	defer hf.mu.Unlock()
	for addr, names := range hf.m {
		if ip, err := netip.ParseAddr(addr); err == nil && !ip.IsLoopback() {
			for _, n := range names {
				if n == name && ip.Is6() == v6 {
					return ip.String()
				}
			}
		}
	}
	return ""
}

// isLocalhostName reports whether the given hostname represents localhost.
func isLocalhostName(hostname string) bool {
	switch hostname {
	case ipv4LocalhostName, ipv6LocalhostName, ipv6LoopbackName:
		return true
	default:
		return false
	}
}

// parseHostEntriesConf parses host_entries.conf file and returns parsed result.
func parseHostEntriesConf(path string) (map[string][]string, error) {
	b, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	return parseHostEntriesConfFromReader(bytes.NewReader(b)), nil
}

// parseHostEntriesConfFromReader is like parseHostEntriesConf, but read from an io.Reader instead of file.
func parseHostEntriesConfFromReader(r io.Reader) map[string][]string {
	hostsMap := map[string][]string{}
	scanner := bufio.NewScanner(r)

	localZone := ""
	for scanner.Scan() {
		line := scanner.Text()
		if after, found := strings.CutPrefix(line, "local-zone:"); found {
			// Extract local zone name for domain suffix removal
			// This is needed because unbound appends the local zone to hostnames
			after = strings.TrimSpace(after)
			fields := strings.Fields(after)
			if len(fields) > 1 {
				localZone = strings.Trim(fields[0], `"`)
			}
			continue
		}
		// Only read "local-data-ptr: ..." line, it has all necessary information.
		after, found := strings.CutPrefix(line, "local-data-ptr:")
		if !found {
			continue
		}
		// Clean up the parsed data by removing whitespace and quotes
		// This ensures consistent formatting for hostname processing
		after = strings.TrimSpace(after)
		after = strings.Trim(after, `"`)
		fields := strings.Fields(after)
		if len(fields) != 2 {
			continue
		}
		ip := fields[0]
		// Remove local zone suffix from hostname for cleaner lookups
		// Unbound adds the local zone to hostnames, but we want just the base name
		name := strings.TrimSuffix(fields[1], "."+localZone)
		hostsMap[ip] = append(hostsMap[ip], name)
	}
	return hostsMap
}
