package main

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/netip"
	"net/url"
	"os"
	"strconv"
	"strings"
	"time"
)

const (
	defaultTimeout  = 10 * time.Second
	hetznerAPIBase  = "https://api.hetzner.cloud/v1"
	ipv4DiscoverURL = "https://api.ipify.org"
	ipv6DiscoverURL = "https://api6.ipify.org"
	version         = "1.0.0"
)

type logLevel int

const (
	logLevelDebug logLevel = iota
	logLevelInfo
	logLevelWarn
)

type config struct {
	Token    string
	Hostname string
	Domain   string
	TTL      int
	DryRun   bool
	LogLevel logLevel
	Logger   *logger
}

type zone struct {
	ID   int    `json:"id"`
	Name string `json:"name"`
	TTL  int    `json:"ttl"`
}

type zonesResponse struct {
	Zones []zone `json:"zones"`
}

type rrsetRecord struct {
	Value string `json:"value"`
}

type rrset struct {
	ID      string        `json:"id"`
	Name    string        `json:"name"`
	Type    string        `json:"type"`
	TTL     *int          `json:"ttl"`
	Records []rrsetRecord `json:"records"`
}

type rrsetsResponse struct {
	RRSets []rrset `json:"rrsets"`
}

func main() {
	switch {
	case showHelp(os.Args[1:]):
		printHelp()
		return
	case showVersion(os.Args[1:]):
		fmt.Println(version)
		return
	}

	cfg, err := loadConfig()
	if err != nil {
		fail("configuration error: %v", err)
	}

	client := &http.Client{Timeout: defaultTimeout}
	ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
	defer cancel()

	cfg.Logger.Infof("Starting az-dns (dry-run=%t, hostname=%q, domain=%q, ttl=%d)", cfg.DryRun, cfg.Hostname, cfg.Domain, cfg.TTL)

	ipv4, err := discoverIP(ctx, client, ipv4DiscoverURL)
	if err != nil {
		cfg.Logger.Warnf("IPv4 lookup failed: %v", err)
	} else {
		cfg.Logger.Debugf("Detected IPv4 %s", ipv4)
	}

	ipv6, err := discoverIP(ctx, client, ipv6DiscoverURL)
	if err != nil {
		cfg.Logger.Warnf("IPv6 lookup failed: %v", err)
	} else {
		cfg.Logger.Debugf("Detected IPv6 %s", ipv6)
	}

	if ipv4 == "" && ipv6 == "" {
		fail("unable to determine public IPv4 or IPv6 address")
	}

	z, err := lookupZone(ctx, client, cfg, cfg.Domain)
	if err != nil {
		fail("zone lookup failed: %v", err)
	}
	cfg.Logger.Infof("Using zone %s (id=%d)", z.Name, z.ID)

	if ipv4 != "" {
		if err := upsertRecord(ctx, client, cfg, z, cfg.Hostname, "A", ipv4); err != nil {
			fail("updating A record failed: %v", err)
		}
		cfg.Logger.Infof("A record for %s set to %s", cfg.fqdn(), ipv4)
	}

	if ipv6 != "" {
		if err := upsertRecord(ctx, client, cfg, z, cfg.Hostname, "AAAA", ipv6); err != nil {
			fail("updating AAAA record failed: %v", err)
		}
		cfg.Logger.Infof("AAAA record for %s set to %s", cfg.fqdn(), ipv6)
	}
}

func loadConfig() (config, error) {
	token := strings.TrimSpace(os.Getenv("HETZNER_TOKEN"))
	if token == "" {
		return config{}, errors.New("HETZNER_TOKEN is not set")
	}

	host := strings.TrimSpace(os.Getenv("HOSTNAME"))
	if host == "" {
		return config{}, errors.New("HOSTNAME is not set")
	}

	domain := strings.TrimSpace(os.Getenv("DOMAIN"))
	if domain == "" {
		return config{}, errors.New("DOMAIN is not set")
	}
	domain = normalizeDomain(domain)

	ttlStr := strings.TrimSpace(os.Getenv("TTL"))
	if ttlStr == "" {
		return config{}, errors.New("TTL is not set")
	}

	ttl, err := strconv.Atoi(ttlStr)
	if err != nil || ttl <= 0 {
		return config{}, fmt.Errorf("invalid TTL: %q", ttlStr)
	}

	host = normalizeHostname(host, domain)

	dryRun := parseBoolEnv(os.Getenv("DRY_RUN"))
	level, err := parseLogLevel(strings.TrimSpace(os.Getenv("LOG_LEVEL")))
	if err != nil {
		return config{}, err
	}

	return config{
		Token:    token,
		Hostname: host,
		Domain:   domain,
		TTL:      ttl,
		DryRun:   dryRun,
		LogLevel: level,
		Logger:   newLogger(level),
	}, nil
}

func normalizeHostname(host, domain string) string {
	host = strings.TrimSpace(host)
	host = strings.TrimSuffix(host, ".")
	domain = normalizeDomain(domain)

	if host == "" || host == "@" {
		return "@"
	}

	hostLower := strings.ToLower(host)
	if strings.HasSuffix(hostLower, domain) {
		hostLower = strings.TrimSuffix(hostLower, domain)
		hostLower = strings.TrimSuffix(hostLower, ".")
		if hostLower == "" {
			return "@"
		}
	}
	return hostLower
}

func discoverIP(ctx context.Context, client *http.Client, url string) (string, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return "", err
	}
	q := req.URL.Query()
	q.Set("format", "text")
	req.URL.RawQuery = q.Encode()

	resp, err := client.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
		return "", fmt.Errorf("unexpected status %s: %s", resp.Status, string(body))
	}

	data, err := io.ReadAll(io.LimitReader(resp.Body, 128))
	if err != nil {
		return "", err
	}
	ip := strings.TrimSpace(string(data))
	if ip == "" {
		return "", errors.New("empty response")
	}
	if _, err := netip.ParseAddr(ip); err != nil {
		return "", fmt.Errorf("invalid IP response: %q", ip)
	}
	return ip, nil
}

func lookupZone(ctx context.Context, client *http.Client, cfg config, domain string) (zone, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, hetznerAPIBase+"/zones", nil)
	if err != nil {
		return zone{}, err
	}
	req.Header.Set("Authorization", "Bearer "+cfg.Token)
	q := req.URL.Query()
	q.Set("name", normalizeDomain(domain))
	req.URL.RawQuery = q.Encode()

	resp, err := client.Do(req)
	if err != nil {
		return zone{}, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return zone{}, fmt.Errorf("zones query failed with %s: %s", resp.Status, readErrorBody(resp.Body))
	}

	var zr zonesResponse
	if err := json.NewDecoder(resp.Body).Decode(&zr); err != nil {
		return zone{}, err
	}
	for _, z := range zr.Zones {
		if strings.EqualFold(z.Name, normalizeDomain(domain)) {
			return z, nil
		}
	}
	return zone{}, fmt.Errorf("zone %q not found", domain)
}

func upsertRecord(ctx context.Context, client *http.Client, cfg config, z zone, host, recordType, value string) error {
	name := rrsetName(host)
	target := joinFQDN(name, cfg.Domain)

	current, err := getRRSet(ctx, client, cfg, z, name, recordType)
	switch {
	case errors.Is(err, errRRSetNotFound):
		if cfg.DryRun {
			cfg.Logger.Infof("Dry-run: would create RRSet %s type %s with value %s (ttl=%d)", target, recordType, value, cfg.TTL)
			return nil
		}
		cfg.Logger.Infof("Creating RRSet %s type %s with value %s (ttl=%d)", target, recordType, value, cfg.TTL)
		return createRRSet(ctx, client, cfg, z, name, recordType, value, cfg.TTL)
	case err != nil:
		return err
	}

	currentTTL := z.TTL
	if current.TTL != nil {
		currentTTL = *current.TTL
	}

	if cfg.DryRun {
		cfg.Logger.Infof("Dry-run: would update RRSet %s type %s to value %s (ttl=%d)", target, recordType, value, cfg.TTL)
		if !ttlMatches(current.TTL, cfg.TTL, z.TTL) {
			cfg.Logger.Infof("Dry-run: would change TTL for %s type %s from %d to %d", target, recordType, currentTTL, cfg.TTL)
		}
		return nil
	}

	cfg.Logger.Infof("Updating RRSet %s type %s to value %s", target, recordType, value)
	if err := setRRSetRecords(ctx, client, cfg, z, name, recordType, value); err != nil {
		return err
	}

	if !ttlMatches(current.TTL, cfg.TTL, z.TTL) {
		cfg.Logger.Infof("Changing TTL for %s type %s from %d to %d", target, recordType, currentTTL, cfg.TTL)
		if err := changeRRSetTTL(ctx, client, cfg, z, name, recordType, cfg.TTL); err != nil {
			return err
		}
	} else {
		cfg.Logger.Debugf("TTL for %s type %s unchanged (%d)", target, recordType, currentTTL)
	}
	return nil
}

var errRRSetNotFound = errors.New("rrset not found")

func getRRSet(ctx context.Context, client *http.Client, cfg config, z zone, name, recordType string) (rrset, error) {
	endpoint := fmt.Sprintf("%s/zones/%d/rrsets", hetznerAPIBase, z.ID)
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
	if err != nil {
		return rrset{}, err
	}
	req.Header.Set("Authorization", "Bearer "+cfg.Token)
	q := req.URL.Query()
	q.Set("name", name)
	q.Add("type", recordType)
	req.URL.RawQuery = q.Encode()

	resp, err := client.Do(req)
	if err != nil {
		return rrset{}, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return rrset{}, fmt.Errorf("rrset lookup failed with %s: %s", resp.Status, readErrorBody(resp.Body))
	}

	var result rrsetsResponse
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return rrset{}, err
	}
	if len(result.RRSets) == 0 {
		return rrset{}, errRRSetNotFound
	}
	return result.RRSets[0], nil
}

func createRRSet(ctx context.Context, client *http.Client, cfg config, z zone, name, recordType, value string, ttl int) error {
	payload := struct {
		Name    string        `json:"name"`
		Type    string        `json:"type"`
		TTL     int           `json:"ttl"`
		Records []rrsetRecord `json:"records"`
	}{
		Name:    name,
		Type:    recordType,
		TTL:     ttl,
		Records: []rrsetRecord{{Value: value}},
	}

	body, err := json.Marshal(payload)
	if err != nil {
		return err
	}

	endpoint := fmt.Sprintf("%s/zones/%d/rrsets", hetznerAPIBase, z.ID)
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
	if err != nil {
		return err
	}
	req.Header.Set("Authorization", "Bearer "+cfg.Token)
	req.Header.Set("Content-Type", "application/json")

	resp, err := client.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusCreated {
		return fmt.Errorf("create rrset failed with %s: %s", resp.Status, readErrorBody(resp.Body))
	}
	return nil
}

func setRRSetRecords(ctx context.Context, client *http.Client, cfg config, z zone, name, recordType, value string) error {
	payload := struct {
		Records []rrsetRecord `json:"records"`
	}{
		Records: []rrsetRecord{{Value: value}},
	}
	body, err := json.Marshal(payload)
	if err != nil {
		return err
	}

	endpoint := fmt.Sprintf("%s/zones/%d/rrsets/%s/%s/actions/set_records", hetznerAPIBase, z.ID, url.PathEscape(name), url.PathEscape(recordType))
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
	if err != nil {
		return err
	}
	req.Header.Set("Authorization", "Bearer "+cfg.Token)
	req.Header.Set("Content-Type", "application/json")

	resp, err := client.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return fmt.Errorf("set rrset records failed with %s: %s", resp.Status, readErrorBody(resp.Body))
	}
	return nil
}

func changeRRSetTTL(ctx context.Context, client *http.Client, cfg config, z zone, name, recordType string, ttl int) error {
	payload := struct {
		TTL int `json:"ttl"`
	}{
		TTL: ttl,
	}
	body, err := json.Marshal(payload)
	if err != nil {
		return err
	}

	endpoint := fmt.Sprintf("%s/zones/%d/rrsets/%s/%s/actions/change_ttl", hetznerAPIBase, z.ID, url.PathEscape(name), url.PathEscape(recordType))
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
	if err != nil {
		return err
	}
	req.Header.Set("Authorization", "Bearer "+cfg.Token)
	req.Header.Set("Content-Type", "application/json")

	resp, err := client.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return fmt.Errorf("change rrset ttl failed with %s: %s", resp.Status, readErrorBody(resp.Body))
	}
	return nil
}

func fail(format string, args ...interface{}) {
	fmt.Fprintf(os.Stderr, format+"\n", args...)
	os.Exit(1)
}

func (c config) fqdn() string {
	switch c.Hostname {
	case "@":
		return c.Domain
	default:
		return fmt.Sprintf("%s.%s", c.Hostname, c.Domain)
	}
}

func normalizeDomain(domain string) string {
	d := strings.TrimSuffix(domain, ".")
	return strings.ToLower(d)
}

func rrsetName(host string) string {
	if host == "@" {
		return host
	}
	return strings.ToLower(host)
}

func joinFQDN(host, domain string) string {
	if host == "" || host == "@" {
		return domain
	}
	return fmt.Sprintf("%s.%s", host, domain)
}

func ttlMatches(current *int, desired, zoneDefault int) bool {
	if current == nil {
		return desired == zoneDefault
	}
	return *current == desired
}

func readErrorBody(r io.Reader) string {
	data, err := io.ReadAll(io.LimitReader(r, 2048))
	if err != nil {
		return fmt.Sprintf("unable to read error body: %v", err)
	}
	return strings.TrimSpace(string(data))
}

func parseBoolEnv(value string) bool {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case "1", "true", "yes", "on":
		return true
	default:
		return false
	}
}

func parseLogLevel(value string) (logLevel, error) {
	if value == "" {
		return logLevelWarn, nil
	}

	switch strings.ToLower(value) {
	case "debug":
		return logLevelDebug, nil
	case "info":
		return logLevelInfo, nil
	case "warn", "warning":
		return logLevelWarn, nil
	default:
		return logLevelWarn, fmt.Errorf("invalid LOG_LEVEL %q (allowed: debug, info, warn)", value)
	}
}

type logger struct {
	level logLevel
}

func newLogger(level logLevel) *logger {
	return &logger{level: level}
}

func (l *logger) logf(level logLevel, prefix string, format string, args ...interface{}) {
	if l == nil || level < l.level {
		return
	}
	msg := fmt.Sprintf(format, args...)
	switch level {
	case logLevelWarn:
		fmt.Fprintf(os.Stderr, "[%s] %s\n", prefix, msg)
	default:
		fmt.Fprintf(os.Stdout, "[%s] %s\n", prefix, msg)
	}
}

func (l *logger) Debugf(format string, args ...interface{}) {
	l.logf(logLevelDebug, "DEBUG", format, args...)
}

func (l *logger) Infof(format string, args ...interface{}) {
	l.logf(logLevelInfo, "INFO", format, args...)
}

func (l *logger) Warnf(format string, args ...interface{}) {
	l.logf(logLevelWarn, "WARN", format, args...)
}

func showHelp(args []string) bool {
	for _, a := range args {
		if a == "-h" || a == "--help" {
			return true
		}
	}
	return false
}

func showVersion(args []string) bool {
	for _, a := range args {
		if a == "-v" || a == "--version" {
			return true
		}
	}
	return false
}

func printHelp() {
	fmt.Println(`Usage: az-dns [options]

Environment variables (required):
  HETZNER_TOKEN   Hetzner Cloud API Token mit DNS-Schreibrechten
  HOSTNAME        Hostname innerhalb der Zone (z. B. "@", "vpn")
  DOMAIN          Zonendomain (z. B. "example.com")
  TTL             Time-to-live der Records in Sekunden

Optionale Environment-Variablen:
  LOG_LEVEL       "debug", "info" oder "warn" (Standard: "warn")
  DRY_RUN         "true"/"1" für Trockenlauf ohne tatsächliche API-Calls

Optionen:
  -h, --help      Diese Hilfe anzeigen
  -v, --version   Version ausgeben
`)
}
