package hedns

import (
	"crypto/sha1"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"net/http/cookiejar"
	"net/url"
	"os"
	"path"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/DNSControl/dnscontrol/v4/models"
	"github.com/DNSControl/dnscontrol/v4/pkg/diff2"
	"github.com/DNSControl/dnscontrol/v4/pkg/domaintags"
	"github.com/DNSControl/dnscontrol/v4/pkg/providers"
	"github.com/DNSControl/dnscontrol/v4/pkg/rtypecontrol"
	"github.com/DNSControl/dnscontrol/v4/pkg/rtypeinfo"
	"github.com/DNSControl/dnscontrol/v4/pkg/txtutil"
	"github.com/DNSControl/dnscontrol/v4/pkg/zonecache"
	"github.com/PuerkitoBio/goquery"
	"github.com/pquerna/otp/totp"
)

/*
Hurricane Electric DNS provider (dns.he.net)

Info required in `creds.json`:
	- username
	- password

Either of the following settings is required when two factor authentication is enabled:
	- totp      (TOTP code if 2FA is enabled; best specified as an env variable)
	- totp-key  (shared TOTP secret used to generate a valid TOTP code; not recommended since
	             this effectively defeats the purpose of two factor authentication by storing
	             both factors at the same place)

Additionally
	- session-file-path  (Path where a '.hedns-session' file will be created to allow a
                         session to persist between executions)

*/

var features = providers.DocumentationNotes{
	// The default for unlisted capabilities is 'Cannot'.
	// See providers/capabilities.go for the entire list of capabilities.
	providers.CanAutoDNSSEC:          providers.Cannot(),
	providers.CanGetZones:            providers.Can(),
	providers.CanConcur:              providers.Can(),
	providers.CanUseAlias:            providers.Can(),
	providers.CanUseCAA:              providers.Can(),
	providers.CanUseDHCID:            providers.Cannot(),
	providers.CanUseDNAME:            providers.Cannot(),
	providers.CanUseDNSKEY:           providers.Cannot(),
	providers.CanUseDS:               providers.Cannot(),
	providers.CanUseDSForChildren:    providers.Cannot(),
	providers.CanUseHTTPS:            providers.Can(),
	providers.CanUseLOC:              providers.Can(),
	providers.CanUseNAPTR:            providers.Can(),
	providers.CanUseOPENPGPKEY:       providers.Cannot(),
	providers.CanUsePTR:              providers.Can(),
	providers.CanUseRP:               providers.Can(),
	providers.CanUseSMIMEA:           providers.Cannot(),
	providers.CanUseSOA:              providers.Cannot(),
	providers.CanUseSRV:              providers.Can(),
	providers.CanUseSSHFP:            providers.Can(),
	providers.CanUseSVCB:             providers.Can(),
	providers.CanUseTLSA:             providers.Cannot(),
	providers.DocCreateDomains:       providers.Can(),
	providers.DocDualHost:            providers.Can(),
	providers.DocOfficiallySupported: providers.Cannot(),
}

func init() {
	const providerName = "HEDNS"
	const providerMaintainer = "@rblenkinsopp"
	fns := providers.DspFuncs{
		Initializer:   newHEDNSProvider,
		RecordAuditor: AuditRecords,
	}
	providers.RegisterDomainServiceProviderType(providerName, fns, features)
	providers.RegisterMaintainer(providerName, providerMaintainer)
	providers.RegisterCredsMetadata(providerName, providers.CredsMetadata{
		DisplayName: "Hurricane Electric DNS",
		Kind:        providers.KindDNS,
		DocsURL:     "https://docs.dnscontrol.org/provider/hedns",
		PortalURL:   "https://dns.he.net/",
		Fields: []providers.CredsField{
			{
				Key:      "username",
				Label:    "Username",
				Help:     "Your Hurricane Electric account username.",
				Required: true,
			},
			{
				Key:      "password",
				Label:    "Password",
				Help:     "Your Hurricane Electric account password.",
				Secret:   true,
				Required: true,
			},
			{
				Key:    "totp-key",
				Label:  "TOTP shared secret (optional)",
				Help:   "Shared TOTP secret used to generate a valid TOTP code. Leave blank if you do not use TOTP.",
				Secret: true,
			},
			{
				Key:     "session-file-path",
				Label:   "Session file path (optional)",
				Help:    "Path to a directory where the .hedns-session file is stored to reuse the authenticated session. Leave blank to log in each run.",
				Default: ".",
			},
		},
	})
}

var defaultNameservers = []string{
	"ns1.he.net",
	"ns2.he.net",
	"ns3.he.net",
	"ns4.he.net",
	"ns5.he.net",
}

const (
	apiEndpoint     = "https://dns.he.net/"
	sessionFileName = ".hedns-session"

	errorInvalidCredentials = "Incorrect"
	errorInvalidTotpToken   = "The token supplied is invalid."
	errorTotpTokenRequired  = "You must enter the token generated by your authenticator."
	errorTotpTokenReused    = "This token has already been used.  You may not reuse tokens."
	errorImproperDelegation = "This zone does not appear to be properly delegated to our nameservers."

	// Metadata keys used in dnsconfig.js to control HEDNS-specific features.
	// Usage:  A("dyn", "1.2.3.4", HEDNS_DYNAMIC_ON)
	//         A("dyn", "1.2.3.4", HEDNS_DDNS_KEY("mytoken"))
	metaDynamic = "hedns_dynamic"  // "on" or "off" – enables/disables Dynamic DNS on the record
	metaDDNSKey = "hedns_ddns_key" // The DDNS key (token) assigned to a dynamic record
)

// hednsProvider stores login credentials and represents and API connection.
type hednsProvider struct {
	Username        string
	Password        string
	TfaSecret       string
	TfaValue        string
	SessionFilePath string

	httpClient http.Client
	zoneCache  zonecache.ZoneCache[uint64]
}

// Record stores the HEDNS specific zone and record IDs.
type Record struct {
	ZoneID      uint64
	ZoneName    string
	ID          uint64
	Type        string
	Name        string
	Data        string
	TTL         uint32
	Priority    uint16
	DDNSEnabled bool
}

func newHEDNSProvider(cfg map[string]string, _ json.RawMessage) (providers.DNSServiceProvider, error) {
	username, password := cfg["username"], cfg["password"]
	totpSecret, totpValue := cfg["totp-key"], cfg["totp"]
	sessionFilePath := cfg["session-file-path"]

	if username == "" {
		return nil, errors.New("username must be provided")
	}
	if password == "" {
		return nil, errors.New("password must be provided")
	}
	if totpSecret != "" && totpValue != "" {
		return nil, errors.New("totp and totp-key must not be specified at the same time")
	}

	client := &hednsProvider{
		Username:        username,
		Password:        password,
		TfaSecret:       totpSecret,
		TfaValue:        totpValue,
		SessionFilePath: sessionFilePath,
	}

	// Create storage for the cookies.
	cookieJar, _ := cookiejar.New(nil)
	client.httpClient = http.Client{Jar: cookieJar}
	client.zoneCache = zonecache.New(client.listDomains)

	// Reuse cached session file if one is set.
	if client.SessionFilePath != "" {
		_ = client.loadSessionFile()
	}

	return client, nil
}

// ListZones list all zones on this provider.
func (c *hednsProvider) ListZones() ([]string, error) {
	domains, err := c.zoneCache.GetZoneNames()
	if err != nil {
		return nil, err
	}
	sort.Strings(domains)
	return domains, nil
}

// EnsureZoneExists creates a zone if it does not exist.
func (c *hednsProvider) EnsureZoneExists(dc *models.DomainConfig) error {
	domain := dc.Name
	ok, err := c.zoneCache.HasZone(domain)
	if err != nil {
		return err
	}
	if ok {
		return nil
	}
	return c.createDomain(domain)
}

// GetNameservers returns the default HEDNS nameservers.
func (c *hednsProvider) GetNameservers(_ string) ([]*models.Nameserver, error) {
	return models.ToNameservers(defaultNameservers)
}

// preprocessConfig normalises HEDNS-specific metadata on desired records.
func (c *hednsProvider) preprocessConfig(dc *models.DomainConfig) error {
	for _, rec := range dc.Records {
		if rec.Metadata == nil {
			rec.Metadata = map[string]string{}
		}

		// Normalise hedns_dynamic to lowercase.
		if v := rec.Metadata[metaDynamic]; v != "" {
			v = strings.ToLower(v)
			switch v {
			case "on", "off":
				rec.Metadata[metaDynamic] = v
			default:
				return fmt.Errorf("bad metadata value for %s: %q (use on/off)", metaDynamic, v)
			}
		}

		// hedns_ddns_key implies hedns_dynamic=on.
		if rec.Metadata[metaDDNSKey] != "" && rec.Metadata[metaDynamic] == "" {
			rec.Metadata[metaDynamic] = "on"
		}

		// Default: keep the existing state (will be resolved during diff).
	}
	return nil
}

// GetZoneRecordsCorrections returns a list of corrections that will turn existing records into dc.Records.
func (c *hednsProvider) GetZoneRecordsCorrections(dc *models.DomainConfig, records models.Records) ([]*models.Correction, int, error) {
	if err := c.preprocessConfig(dc); err != nil {
		return nil, 0, err
	}

	prunedRecords := make(models.Records, 0, len(records))
	for _, r := range records {
		if r.Type != "SOA" {
			prunedRecords = append(prunedRecords, r)
		}
	}

	// Synchronize hedns_dynamic metadata between existing and desired records.
	// Existing records always have hedns_dynamic ("on" or "off") set by GetZoneRecords.
	// Desired records that don't specify it inherit from their existing counterpart;
	// new records default to "off".
	// We use a null byte as separator because it cannot appear in DNS labels
	// or record type names, avoiding ambiguity in the composite key.
	existingDynamic := map[string]string{} // "label\x00type" → "on"|"off"
	for _, r := range prunedRecords {
		if r.Metadata != nil {
			key := r.GetLabel() + "\x00" + r.Type
			existingDynamic[key] = r.Metadata[metaDynamic]
		}
	}
	for _, rec := range dc.Records {
		if rec.Metadata[metaDynamic] == "" {
			key := rec.GetLabel() + "\x00" + rec.Type
			if dyn, ok := existingDynamic[key]; ok {
				rec.Metadata[metaDynamic] = dyn
			} else {
				rec.Metadata[metaDynamic] = "off"
			}
		}
	}

	return c.getDiff2DomainCorrections(dc, prunedRecords)
}

// resolveDynamic determines the desired dynamic flag for a record.
// Priority: explicit metadata > inherited from old record > false.
func resolveDynamic(newRec *models.RecordConfig, oldRec *Record) bool {
	if newRec.Metadata != nil {
		switch newRec.Metadata[metaDynamic] {
		case "on":
			return true
		case "off":
			return false
		}
	}
	// Not explicitly set – inherit from old record if present.
	if oldRec != nil {
		return oldRec.DDNSEnabled
	}
	return false
}

func (c *hednsProvider) getDiff2DomainCorrections(dc *models.DomainConfig, records models.Records) ([]*models.Correction, int, error) {
	zoneID, err := c.zoneCache.GetZone(dc.Name)
	if err != nil {
		return nil, 0, err
	}

	// DDNS keys are write-only: HE DNS does not expose them for reading.
	// Strip hedns_ddns_key from desired metadata before the diff so that it
	// does not cause perpetual spurious changes, but save the intents so we
	// can set keys during CREATE/CHANGE.
	ddnsKeys := map[*models.RecordConfig]string{}
	for _, rec := range dc.Records {
		if rec.Metadata != nil {
			if key := rec.Metadata[metaDDNSKey]; key != "" {
				ddnsKeys[rec] = key
				delete(rec.Metadata, metaDDNSKey)
			}
		}
	}

	// Include hedns_dynamic in the comparison so that toggling dynamic
	// on/off (even without a target change) is detected as a change.
	// Only emit for "on" — "off" is the default and omitting it keeps
	// diff output clean for records that don't use Dynamic DNS.
	comparableFunc := func(rec *models.RecordConfig) string {
		if rec.Metadata != nil && rec.Metadata[metaDynamic] == "on" {
			return metaDynamic + "=on"
		}
		return ""
	}

	changes, actualChangeCount, err := diff2.ByRecord(records, dc, comparableFunc)
	if err != nil {
		return nil, 0, err
	}

	var corrections []*models.Correction
	for _, change := range changes {
		switch change.Type {
		case diff2.REPORT:
			corrections = append(corrections, &models.Correction{Msg: change.MsgsJoined})
		case diff2.CREATE:
			record := change.New[0]
			ddnsKey := ddnsKeys[record]
			corrections = append(corrections, &models.Correction{
				Msg: change.MsgsJoined,
				F: func() error {
					if err := c.createZoneRecord(zoneID, record); err != nil {
						return err
					}
					if ddnsKey != "" {
						return c.setDDNSKeyForNewRecord(zoneID, dc.Name, record, ddnsKey)
					}
					return nil
				},
			})
		case diff2.CHANGE:
			record := change.New[0]
			oldRecord := change.Old[0].Original.(Record)
			ddnsKey := ddnsKeys[record]
			corrections = append(corrections, &models.Correction{
				Msg: change.MsgsJoined,
				F: func() error {
					if err := c.changeZoneRecord(zoneID, oldRecord.ID, record, &oldRecord); err != nil {
						return err
					}
					if ddnsKey != "" {
						return c.setRecordDDNSKey(zoneID, oldRecord.Name, ddnsKey)
					}
					return nil
				},
			})
		case diff2.DELETE:
			recordID := change.Old[0].Original.(Record).ID
			corrections = append(corrections, &models.Correction{
				Msg: change.MsgsJoined,
				F: func() error {
					return c.deleteZoneRecord(zoneID, recordID)
				},
			})
		default:
			panic(fmt.Sprintf("unhandled change.Type %s", change.Type))
		}
	}

	return corrections, actualChangeCount, nil
}

// setDDNSKeyForNewRecord sets the DDNS key on a newly created record.
func (c *hednsProvider) setDDNSKeyForNewRecord(zoneID uint64, _ string, record *models.RecordConfig, key string) error {
	return c.setRecordDDNSKey(zoneID, record.GetLabelFQDN(), key)
}

// GetZoneRecords returns all the records for the given domain.
func (c *hednsProvider) GetZoneRecords(dc *models.DomainConfig) (models.Records, error) {
	domain := dc.Name

	var zoneRecords []*models.RecordConfig

	// Get Domain ID
	domainID, err := c.zoneCache.GetZone(domain)
	if err != nil {
		if errors.Is(err, zonecache.ErrZoneNotFound) {
			return nil, fmt.Errorf("domain %s does not exist", domain)
		}
		return nil, err
	}

	queryURL, _ := url.Parse(apiEndpoint)
	q := queryURL.Query()
	q.Add("hosted_dns_zoneid", strconv.FormatUint(domainID, 10))
	q.Add("menu", "edit_zone")
	q.Add("hosted_dns_editzone", "")
	queryURL.RawQuery = q.Encode()

	response, err := c.httpClient.Get(queryURL.String())
	if err != nil {
		return nil, err
	}
	defer response.Body.Close()

	// Parse the HTML response
	document, err := goquery.NewDocumentFromReader(response.Body)
	if err != nil {
		return nil, err
	}

	// Check we can find the zone records
	if document.Find("#dns_main_content").Size() == 0 {
		return nil, errors.New("zone records listing failed")
	}

	// Load all the domain records
	recordSelector := "tr.dns_tr, tr.dns_tr_dynamic, tr.dns_tr_locked"
	document.Find(recordSelector).EachWithBreak(func(index int, element *goquery.Selection) bool {
		// HEDNS renders each record as a table row with the following columns:
		//  1. Zone ID
		//  2. Record ID
		//  3. Name (.dns_view)
		//  4. Type (.rrlabel)
		//  5. TTL
		//  6. Priority
		//  7. Data
		//  8+. Hidden/UI Controls
		parser := elementParser{}
		rec := Record{
			ZoneID:      domainID,
			ZoneName:    domain,
			ID:          parser.parseIntAttr(element, "id"),
			Type:        parser.parseStringAttr(element.Find(".rrlabel"), "data"),
			Name:        parser.parseStringElement(element.Find(".dns_view")),
			Data:        parser.parseStringAttr(element.Find("td:nth-child(7)"), "data"),
			TTL:         parser.parseIntElementUint32(element.Find("td:nth-child(5)")),
			Priority:    parser.parseIntElementUint16(element.Find("td:nth-child(6)")),
			DDNSEnabled: element.HasClass("dns_tr_dynamic"),
		}
		if parser.err != nil {
			err = parser.err
			return false
		}

		// Ignore record types that dnscontrol does not support
		if rec.Type == "HINFO" || rec.Type == "AFSDB" {
			return true
		}

		var rc *models.RecordConfig
		if rtypeinfo.IsModernType(rec.Type) {
			// FQDNs for NewRecordConfigFromString require trailing "."
			rc, err = rtypecontrol.NewRecordConfigFromString(
				rec.Name+".", rec.TTL, rec.Type, rec.Data,
				domaintags.MakeDomainNameVarieties(domain),
			)
		} else {
			rc = &models.RecordConfig{Type: rec.Type, TTL: rec.TTL}
			rc.SetLabelFromFQDN(rec.Name, domain)
			switch rec.Type {
			case "ALIAS":
				err = rc.SetTarget(rec.Data)
			case "MX":
				// HEDNS omits the trailing "." on the hostnames for MX records
				err = rc.SetTargetMX(rec.Priority, rec.Data+".")
			case "SRV":
				err = rc.SetTargetSRVPriorityString(rec.Priority, rec.Data)
			case "SPF":
				// Convert to TXT record as SPF is deprecated
				rc.ChangeType("TXT", domain)
				fallthrough
			default:
				err = rc.PopulateFromStringFunc(rec.Type, rec.Data, domain, txtutil.ParseQuoted)
			}
		}
		if err != nil {
			return false
		}

		rc.Original = rec
		rc.Metadata = map[string]string{
			metaDynamic: "off",
		}

		if rec.DDNSEnabled {
			rc.Metadata[metaDynamic] = "on"
		}

		zoneRecords = append(zoneRecords, rc)
		return true
	})

	return zoneRecords, err
}

func (c *hednsProvider) authUsernameAndPassword() (document *goquery.Document, requiresTfa bool, err error) {
	// Login with username and password
	response, err := c.httpClient.PostForm(apiEndpoint, url.Values{
		"email":  {c.Username},
		"pass":   {c.Password},
		"submit": {"Login!"},
	})
	if err != nil {
		return nil, false, err
	}
	defer response.Body.Close()

	document, err = c.parseResponseForDocumentAndErrors(response)
	if err != nil {
		if err.Error() == errorInvalidCredentials {
			err = errors.New("authentication failed with incorrect username or password")
		}
		if err.Error() == errorTotpTokenRequired {
			return nil, true, nil
		}
		return nil, false, err
	}

	requiresTfa = document.Find("input#tfacode").Size() > 0
	return document, requiresTfa, nil
}

func (c *hednsProvider) auth2FA() (document *goquery.Document, err error) {
	if c.TfaValue == "" && c.TfaSecret == "" {
		return nil, errors.New("account requires two-factor authentication but neither totp or totp-key were provided")
	}

	if c.TfaValue == "" && c.TfaSecret != "" {
		c.TfaValue, err = totp.GenerateCode(c.TfaSecret, time.Now())
		if err != nil {
			return nil, err
		}
	}

	response, err := c.httpClient.PostForm(apiEndpoint, url.Values{
		"tfacode": {c.TfaValue},
		"submit":  {"Submit"},
	})
	if err != nil {
		return nil, err
	}
	defer response.Body.Close()

	document, err = c.parseResponseForDocumentAndErrors(response)
	if err != nil {
		switch err.Error() {
		case errorInvalidTotpToken:
			err = errors.New("invalid TOTP token value")
		case errorTotpTokenReused:
			err = errors.New("TOTP token was reused within its period (30 seconds)")
		}
		return nil, err
	}

	return document, nil
}

func (c *hednsProvider) authResumeSession() (document *goquery.Document, err error) {
	response, err := c.httpClient.Get(apiEndpoint)
	if err != nil {
		return nil, err
	}
	defer response.Body.Close()

	document, err = goquery.NewDocumentFromReader(response.Body)
	if err != nil {
		return nil, err
	}
	return document, nil
}

func (c *hednsProvider) authenticate() (*goquery.Document, error) {
	// #_tlogout is only present once the session is authenticated.
	isLoggedIn := func(document *goquery.Document) bool {
		return document.Find("#_tlogout").Size() > 0
	}

	if c.hasSession() {
		document, err := c.authResumeSession()
		if err != nil {
			return nil, err
		}

		if isLoggedIn(document) {
			return document, nil
		}
	}

	document, requiresTfa, err := c.authUsernameAndPassword()
	if err != nil {
		return nil, err
	}

	if requiresTfa {
		if document, err = c.auth2FA(); err != nil {
			return nil, err
		}
	}

	if !isLoggedIn(document) {
		return nil, errors.New("unknown authentication failure")
	}

	if c.SessionFilePath != "" {
		if err := c.saveSessionFile(); err != nil {
			return nil, err
		}
	}

	return document, nil
}

func (c *hednsProvider) listDomains() (map[string]uint64, error) {
	document, err := c.authenticate()
	if err != nil {
		return nil, err
	}
	return parseDomainsTable(document)
}

func (c *hednsProvider) hasSession() bool {
	u, err := url.Parse(apiEndpoint)
	if err != nil {
		return false
	}
	return len(c.httpClient.Jar.Cookies(u)) > 0
}

func parseDomainsTable(document *goquery.Document) (map[string]uint64, error) {
	domains := make(map[string]uint64)
	if document.Find("#domains_table").Size() == 0 {
		return domains, nil
	}

	// Find all the forward & reverse domains
	recordsSelector := strings.Join([]string{
		"#domains_table > tbody > tr > td:last-child > img",                // Forward records
		"#tabs-advanced .generic_table > tbody > tr > td:last-child > img", // Reverse records
	}, ", ")

	var err error
	document.Find(recordsSelector).EachWithBreak(func(index int, element *goquery.Selection) bool {
		domainID, idExists := element.Attr("value")
		domainName, nameExists := element.Attr("name")
		if idExists && nameExists {
			domains[domainName], err = strconv.ParseUint(domainID, 10, 64)
			return err == nil
		}
		return true
	})

	return domains, err
}

func (c *hednsProvider) createDomain(domain string) error {
	values := url.Values{
		"action":     {"add_zone"},
		"retmain":    {"0"},
		"add_domain": {domain},
		"submit":     {"Add Domain!"},
	}

	response, err := c.httpClient.PostForm(apiEndpoint, values)
	if err != nil {
		return err
	}
	defer response.Body.Close()

	document, err := c.parseResponseForDocumentAndErrors(response)
	if err != nil {
		return err
	}
	domains, err := parseDomainsTable(document)
	if err != nil {
		return err
	}
	if id, ok := domains[domain]; ok {
		c.zoneCache.SetZone(domain, id)
	}
	return nil
}

func (c *hednsProvider) editZoneRecord(zoneID uint64, recordID uint64, rc *models.RecordConfig, create bool, dynamic bool) error {
	// NOTE: the `dynamic` parameter is the resolved flag – callers must compute it
	// using resolveDynamic() before calling this method.
	values := url.Values{
		"account":             {},
		"menu":                {"edit_zone"},
		"hosted_dns_zoneid":   {strconv.FormatUint(zoneID, 10)},
		"hosted_dns_editzone": {"1"},
		"TTL":                 {strconv.FormatUint(uint64(rc.TTL), 10)},
		"Name":                {rc.Name},
	}

	// Preserve the dynamic DNS flag so the associated DDNS key is not lost
	if dynamic {
		values.Set("dynamic", "1")
	}

	// Select the correct mode and deal with the quirks
	if create {
		values.Set("Type", rc.Type)
		values.Set("hosted_dns_editrecord", "Submit")
		values.Set("hosted_dns_recordid", "")
		values.Set("Priority", "")
	} else {
		values.Set("Type", strings.ToLower(rc.Type)) // Lowercase on update
		values.Set("hosted_dns_editrecord", "Update")
		values.Set("hosted_dns_recordid", strconv.FormatUint(recordID, 10))
		values.Set("Priority", "-")
	}

	// Work out the content
	switch rc.Type {
	case "MX":
		values.Set("Priority", strconv.FormatUint(uint64(rc.MxPreference), 10))
		values.Set("Content", rc.GetTargetField())
	case "SRV":
		values.Del("Content")
		values.Set("Target", rc.GetTargetField())
		values.Set("Priority", strconv.FormatUint(uint64(rc.SrvPriority), 10))
		values.Set("Weight", strconv.FormatUint(uint64(rc.SrvWeight), 10))
		values.Set("Port", strconv.FormatUint(uint64(rc.SrvPort), 10))
	default:
		values.Set("Content", rc.GetTargetCombinedFunc(txtutil.EncodeQuoted))
	}

	response, err := c.httpClient.PostForm(apiEndpoint, values)
	if err != nil {
		return err
	}
	defer response.Body.Close()

	_, err = c.parseResponseForDocumentAndErrors(response)
	return err
}

func (c *hednsProvider) createZoneRecord(zoneID uint64, rc *models.RecordConfig) error {
	dynamic := resolveDynamic(rc, nil)
	return c.editZoneRecord(zoneID, 0, rc, true, dynamic)
}

func (c *hednsProvider) changeZoneRecord(zoneID uint64, recordID uint64, rc *models.RecordConfig, oldRecord *Record) error {
	dynamic := resolveDynamic(rc, oldRecord)
	return c.editZoneRecord(zoneID, recordID, rc, false, dynamic)
}

func (c *hednsProvider) deleteZoneRecord(zoneID uint64, recordID uint64) error {
	values := url.Values{
		"menu":                  {"edit_zone"},
		"hosted_dns_zoneid":     {strconv.FormatUint(zoneID, 10)},
		"hosted_dns_recordid":   {strconv.FormatUint(recordID, 10)},
		"hosted_dns_editzone":   {"1"},
		"hosted_dns_delrecord":  {"1"},
		"hosted_dns_delconfirm": {"delete"},
	}

	response, err := c.httpClient.PostForm(apiEndpoint, values)
	if err != nil {
		return err
	}
	defer response.Body.Close()

	_, err = c.parseResponseForDocumentAndErrors(response)
	return err
}

// setRecordDDNSKey sets the DDNS key for a dynamic record.
// The record is identified by its FQDN.
func (c *hednsProvider) setRecordDDNSKey(zoneID uint64, recordFQDN string, key string) error {
	values := url.Values{
		"account":             {},
		"menu":                {"edit_zone"},
		"hosted_dns_zoneid":   {strconv.FormatUint(zoneID, 10)},
		"hosted_dns_recordid": {},
		"hosted_dns_editzone": {"1"},
		"Name":                {recordFQDN},
		"Key":                 {key},
		"Key2":                {key},
		"generate_key":        {"Submit"},
	}

	response, err := c.httpClient.PostForm(apiEndpoint, values)
	if err != nil {
		return err
	}
	defer response.Body.Close()

	_, err = c.parseResponseForDocumentAndErrors(response)
	return err
}

func (c *hednsProvider) generateCredentialHash() string {
	hash := sha1.New()
	hash.Write([]byte(c.Username))
	hash.Write([]byte(c.Password))
	hash.Write([]byte(c.TfaSecret))
	return hex.EncodeToString(hash.Sum(nil))
}

func (c *hednsProvider) saveSessionFile() error {
	cookieDomain, err := url.Parse(apiEndpoint)
	if err != nil {
		return err
	}

	// Put the credential hash on the first lines
	entries := []string{
		c.generateCredentialHash(),
	}

	for _, cookie := range c.httpClient.Jar.Cookies(cookieDomain) {
		entries = append(entries, strings.Join([]string{cookie.Name, cookie.Value}, "="))
	}

	fileName := path.Join(c.SessionFilePath, sessionFileName)
	err = os.WriteFile(fileName, []byte(strings.Join(entries, "\n")), 0o600)
	return err
}

func (c *hednsProvider) loadSessionFile() error {
	cookieDomain, err := url.Parse(apiEndpoint)
	if err != nil {
		return err
	}

	fileName := path.Join(c.SessionFilePath, sessionFileName)
	bytes, err := os.ReadFile(fileName)
	if err != nil {
		if os.IsNotExist(err) {
			// Skip loading the session.
			return nil
		}
		return err
	}

	var cookies []*http.Cookie
	for i, entry := range strings.Split(string(bytes), "\n") {
		if i == 0 {
			if entry != c.generateCredentialHash() {
				return errors.New("invalid credential hash in session file")
			}
		} else {
			kv := strings.Split(entry, "=")
			if len(kv) == 2 {
				cookies = append(cookies, &http.Cookie{
					Name:  kv[0],
					Value: kv[1],
				})
			}
		}
	}
	c.httpClient.Jar.SetCookies(cookieDomain, cookies)

	return err
}

func (c *hednsProvider) parseResponseForDocumentAndErrors(response *http.Response) (document *goquery.Document, err error) {
	ignoredErrorMessages := [...]string{
		errorImproperDelegation,
	}

	document, err = goquery.NewDocumentFromReader(response.Body)
	if err != nil {
		return nil, err
	}

	// Check for any errors ignoring irrelevant errors
	document.Find("div#dns_err").EachWithBreak(func(index int, element *goquery.Selection) bool {
		errorMessage := element.Text()
		for _, ignoredMessage := range ignoredErrorMessages {
			if strings.Contains(errorMessage, ignoredMessage) {
				return true
			}
		}
		err = errors.New(element.Text())
		return false
	})

	return document, err
}

type elementParser struct {
	err error
}

func (p *elementParser) parseStringAttr(element *goquery.Selection, attr string) (result string) {
	if p.err != nil {
		return
	}
	result, exists := element.Attr(attr)
	if !exists {
		p.err = fmt.Errorf("could not locate attribute %s", attr)
	}
	return result
}

func (p *elementParser) parseIntAttr(element *goquery.Selection, attr string) (result uint64) {
	if p.err != nil {
		return
	}
	if value, exists := element.Attr(attr); exists {
		result, p.err = strconv.ParseUint(value, 10, 64)
	} else {
		p.err = fmt.Errorf("could not locate attribute %s", attr)
	}
	return result
}

func (p *elementParser) parseStringElement(element *goquery.Selection) (result string) {
	if p.err != nil {
		return
	}
	return element.Text()
}

func (p *elementParser) parseIntElementUint16(element *goquery.Selection) uint16 {
	if p.err != nil {
		return 0
	}

	// Special case to deal with Priority
	if element.Text() == "-" {
		return 0
	}

	var result64 uint64
	result64, p.err = strconv.ParseUint(element.Text(), 10, 16)
	return uint16(result64)
}

func (p *elementParser) parseIntElementUint32(element *goquery.Selection) uint32 {
	if p.err != nil {
		return 0
	}

	// Special case to deal with Priority
	if element.Text() == "-" {
		return 0
	}

	var result64 uint64
	result64, p.err = strconv.ParseUint(element.Text(), 10, 32)
	return uint32(result64)
}
