// Package gcloud implements a DNS provider for solving the DNS-01 challenge using Google Cloud DNS.
package gcloud

import (
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"os"
	"strconv"
	"time"

	"cloud.google.com/go/compute/metadata"
	"github.com/go-acme/lego/v4/challenge"
	"github.com/go-acme/lego/v4/challenge/dns01"
	"github.com/go-acme/lego/v4/log"
	"github.com/go-acme/lego/v4/platform/config/env"
	"github.com/go-acme/lego/v4/platform/wait"
	"golang.org/x/net/context"
	"golang.org/x/oauth2/google"
	"google.golang.org/api/dns/v1"
	"google.golang.org/api/googleapi"
	"google.golang.org/api/option"
)

// Environment variables names.
const (
	envNamespace = "GCE_"

	EnvServiceAccount   = envNamespace + "SERVICE_ACCOUNT"
	EnvProject          = envNamespace + "PROJECT"
	EnvZoneID           = envNamespace + "ZONE_ID"
	EnvAllowPrivateZone = envNamespace + "ALLOW_PRIVATE_ZONE"
	EnvDebug            = envNamespace + "DEBUG"

	EnvTTL                = envNamespace + "TTL"
	EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
	EnvPollingInterval    = envNamespace + "POLLING_INTERVAL"
)

const changeStatusDone = "done"

var _ challenge.ProviderTimeout = (*DNSProvider)(nil)

// Config is used to configure the creation of the DNSProvider.
type Config struct {
	Debug              bool
	Project            string
	ZoneID             string
	AllowPrivateZone   bool
	PropagationTimeout time.Duration
	PollingInterval    time.Duration
	TTL                int
	HTTPClient         *http.Client
}

// NewDefaultConfig returns a default configuration for the DNSProvider.
func NewDefaultConfig() *Config {
	return &Config{
		Debug:              env.GetOrDefaultBool(EnvDebug, false),
		ZoneID:             env.GetOrDefaultString(EnvZoneID, ""),
		AllowPrivateZone:   env.GetOrDefaultBool(EnvAllowPrivateZone, false),
		TTL:                env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 180*time.Second),
		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, 5*time.Second),
	}
}

// DNSProvider implements the challenge.Provider interface.
type DNSProvider struct {
	config *Config
	client *dns.Service
}

// NewDNSProvider returns a DNSProvider instance configured for Google Cloud DNS.
// By default, the project name is auto-detected by using the metadata service,
// it can be overridden using the GCE_PROJECT environment variable.
// A Service Account can be passed in the environment variable: GCE_SERVICE_ACCOUNT
// or by specifying the keyfile location: GCE_SERVICE_ACCOUNT_FILE.
func NewDNSProvider() (*DNSProvider, error) {
	// Use a service account file if specified via environment variable.
	if saKey := env.GetOrFile(EnvServiceAccount); saKey != "" {
		return NewDNSProviderServiceAccountKey([]byte(saKey))
	}

	// Use default credentials.
	project := env.GetOrDefaultString(EnvProject, autodetectProjectID(context.Background()))
	return NewDNSProviderCredentials(project)
}

// NewDNSProviderCredentials uses the supplied credentials
// to return a DNSProvider instance configured for Google Cloud DNS.
func NewDNSProviderCredentials(project string) (*DNSProvider, error) {
	if project == "" {
		return nil, errors.New("googlecloud: project name missing")
	}

	client, err := google.DefaultClient(context.Background(), dns.NdevClouddnsReadwriteScope)
	if err != nil {
		return nil, fmt.Errorf("googlecloud: unable to get Google Cloud client: %w", err)
	}

	config := NewDefaultConfig()
	config.Project = project
	config.HTTPClient = client

	return NewDNSProviderConfig(config)
}

// NewDNSProviderServiceAccountKey uses the supplied service account JSON
// to return a DNSProvider instance configured for Google Cloud DNS.
func NewDNSProviderServiceAccountKey(saKey []byte) (*DNSProvider, error) {
	if len(saKey) == 0 {
		return nil, errors.New("googlecloud: Service Account is missing")
	}

	// If GCE_PROJECT is non-empty it overrides the project in the service
	// account file.
	project := env.GetOrDefaultString(EnvProject, "")
	if project == "" {
		// read project id from service account file
		var datJSON struct {
			ProjectID string `json:"project_id"`
		}
		err := json.Unmarshal(saKey, &datJSON)
		if err != nil || datJSON.ProjectID == "" {
			return nil, errors.New("googlecloud: project ID not found in Google Cloud Service Account file")
		}
		project = datJSON.ProjectID
	}

	conf, err := google.JWTConfigFromJSON(saKey, dns.NdevClouddnsReadwriteScope)
	if err != nil {
		return nil, fmt.Errorf("googlecloud: unable to acquire config: %w", err)
	}
	client := conf.Client(context.Background())

	config := NewDefaultConfig()
	config.Project = project
	config.HTTPClient = client

	return NewDNSProviderConfig(config)
}

// NewDNSProviderServiceAccount uses the supplied service account JSON file
// to return a DNSProvider instance configured for Google Cloud DNS.
func NewDNSProviderServiceAccount(saFile string) (*DNSProvider, error) {
	if saFile == "" {
		return nil, errors.New("googlecloud: Service Account file missing")
	}

	saKey, err := os.ReadFile(saFile)
	if err != nil {
		return nil, fmt.Errorf("googlecloud: unable to read Service Account file: %w", err)
	}

	return NewDNSProviderServiceAccountKey(saKey)
}

// NewDNSProviderConfig return a DNSProvider instance configured for Google Cloud DNS.
func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
	if config == nil {
		return nil, errors.New("googlecloud: the configuration of the DNS provider is nil")
	}
	if config.HTTPClient == nil {
		return nil, errors.New("googlecloud: unable to create Google Cloud DNS service: client is nil")
	}

	svc, err := dns.NewService(context.Background(), option.WithHTTPClient(config.HTTPClient))
	if err != nil {
		return nil, fmt.Errorf("googlecloud: unable to create Google Cloud DNS service: %w", err)
	}

	return &DNSProvider{config: config, client: svc}, nil
}

// Present creates a TXT record to fulfill the dns-01 challenge.
func (d *DNSProvider) Present(domain, token, keyAuth string) error {
	info := dns01.GetChallengeInfo(domain, keyAuth)

	zone, err := d.getHostedZone(info.EffectiveFQDN)
	if err != nil {
		return fmt.Errorf("googlecloud: %w", err)
	}

	// Look for existing records.
	existingRrSet, err := d.findTxtRecords(zone, info.EffectiveFQDN)
	if err != nil {
		return fmt.Errorf("googlecloud: %w", err)
	}

	for _, rrSet := range existingRrSet {
		var rrd []string
		for _, rr := range rrSet.Rrdatas {
			data := mustUnquote(rr)
			rrd = append(rrd, data)

			if data == info.Value {
				log.Printf("skip: the record already exists: %s", info.Value)
				return nil
			}
		}
		rrSet.Rrdatas = rrd
	}

	// Attempt to delete the existing records before adding the new one.
	if len(existingRrSet) > 0 {
		if err = d.applyChanges(zone, &dns.Change{Deletions: existingRrSet}); err != nil {
			return fmt.Errorf("googlecloud: %w", err)
		}
	}

	rec := &dns.ResourceRecordSet{
		Name:    info.EffectiveFQDN,
		Rrdatas: []string{info.Value},
		Ttl:     int64(d.config.TTL),
		Type:    "TXT",
	}

	// Append existing TXT record data to the new TXT record data
	for _, rrSet := range existingRrSet {
		for _, rr := range rrSet.Rrdatas {
			if rr != info.Value {
				rec.Rrdatas = append(rec.Rrdatas, rr)
			}
		}
	}

	change := &dns.Change{
		Additions: []*dns.ResourceRecordSet{rec},
	}

	if err = d.applyChanges(zone, change); err != nil {
		return fmt.Errorf("googlecloud: %w", err)
	}

	return nil
}

func (d *DNSProvider) applyChanges(zone string, change *dns.Change) error {
	if d.config.Debug {
		data, _ := json.Marshal(change)
		log.Printf("change (Create): %s", string(data))
	}

	chg, err := d.client.Changes.Create(d.config.Project, zone, change).Do()
	if err != nil {
		var v *googleapi.Error
		if errors.As(err, &v) && v.Code == http.StatusNotFound {
			return nil
		}

		data, _ := json.Marshal(change)
		return fmt.Errorf("failed to perform changes [zone %s, change %s]: %w", zone, string(data), err)
	}

	if chg.Status == changeStatusDone {
		return nil
	}

	chgID := chg.Id

	// wait for change to be acknowledged
	return wait.For("apply change", 30*time.Second, 3*time.Second, func() (bool, error) {
		if d.config.Debug {
			data, _ := json.Marshal(change)
			log.Printf("change (Get): %s", string(data))
		}

		chg, err = d.client.Changes.Get(d.config.Project, zone, chgID).Do()
		if err != nil {
			data, _ := json.Marshal(change)
			return false, fmt.Errorf("failed to get changes [zone %s, change %s]: %w", zone, string(data), err)
		}

		if chg.Status == changeStatusDone {
			return true, nil
		}

		return false, fmt.Errorf("status: %s", chg.Status)
	})
}

// CleanUp removes the TXT record matching the specified parameters.
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
	info := dns01.GetChallengeInfo(domain, keyAuth)

	zone, err := d.getHostedZone(info.EffectiveFQDN)
	if err != nil {
		return fmt.Errorf("googlecloud: %w", err)
	}

	records, err := d.findTxtRecords(zone, info.EffectiveFQDN)
	if err != nil {
		return fmt.Errorf("googlecloud: %w", err)
	}

	if len(records) == 0 {
		return nil
	}

	_, err = d.client.Changes.Create(d.config.Project, zone, &dns.Change{Deletions: records}).Do()
	if err != nil {
		return fmt.Errorf("googlecloud: %w", err)
	}
	return nil
}

// Timeout customizes the timeout values used by the ACME package for checking
// DNS record validity.
func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
	return d.config.PropagationTimeout, d.config.PollingInterval
}

// getHostedZone returns the managed-zone.
func (d *DNSProvider) getHostedZone(domain string) (string, error) {
	authZone, zones, err := d.lookupHostedZoneID(domain)
	if err != nil {
		return "", err
	}

	if len(zones) == 0 {
		return "", fmt.Errorf("no matching domain found for domain %s", authZone)
	}

	for _, z := range zones {
		if z.Visibility == "public" || z.Visibility == "" || (z.Visibility == "private" && d.config.AllowPrivateZone) {
			return z.Name, nil
		}
	}

	if d.config.AllowPrivateZone {
		return "", fmt.Errorf("no public or private zone found for domain %s", authZone)
	}

	return "", fmt.Errorf("no public zone found for domain %s", authZone)
}

// lookupHostedZoneID finds the managed zone ID in Google.
//
// Be careful here.
// An automated system might run in a GCloud Service Account, with access to edit the zone
//
//	(gcloud dns managed-zones get-iam-policy $zone_id) (role roles/dns.admin)
//
// but not with project-wide access to list all zones
//
//	(gcloud projects get-iam-policy $project_id) (a role with permission dns.managedZones.list)
//
// If we force a zone list to succeed, we demand more permissions than needed.
func (d *DNSProvider) lookupHostedZoneID(domain string) (string, []*dns.ManagedZone, error) {
	// GCE_ZONE_ID override for service accounts to avoid needing zones-list permission
	if d.config.ZoneID != "" {
		zone, err := d.client.ManagedZones.Get(d.config.Project, d.config.ZoneID).Do()
		if err != nil {
			return "", nil, fmt.Errorf("API call ManagedZones.Get for explicit zone ID %q in project %q failed: %w", d.config.ZoneID, d.config.Project, err)
		}

		return zone.DnsName, []*dns.ManagedZone{zone}, nil
	}

	authZone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(domain))
	if err != nil {
		return "", nil, fmt.Errorf("could not find zone: %w", err)
	}

	zones, err := d.client.ManagedZones.
		List(d.config.Project).
		DnsName(authZone).
		Do()
	if err != nil {
		return "", nil, fmt.Errorf("API call ManagedZones.List failed: %w", err)
	}

	return authZone, zones.ManagedZones, nil
}

func (d *DNSProvider) findTxtRecords(zone, fqdn string) ([]*dns.ResourceRecordSet, error) {
	recs, err := d.client.ResourceRecordSets.List(d.config.Project, zone).Name(fqdn).Type("TXT").Do()
	if err != nil {
		return nil, err
	}

	return recs.Rrsets, nil
}

func mustUnquote(raw string) string {
	clean, err := strconv.Unquote(raw)
	if err != nil {
		return raw
	}
	return clean
}

func autodetectProjectID(ctx context.Context) string {
	if pid, err := metadata.ProjectIDWithContext(ctx); err == nil {
		return pid
	}

	return ""
}
