// Package handler — sme_tenant_dns.go: DNS provisioner for the SME
// tenant pipeline (issue #804).
//
// Two flows:
//
//   - Free-subdomain mode (default): the orchestrator calls PowerDNS
//     directly via the otech's in-cluster PowerDNS service to add an
//     A record for `console.<subdomain>.<otech-fqdn>` (and optional
//     wildcard CNAME for sister hosts). Idempotent — PowerDNS PATCH
//     is naturally re-runnable.
//
//   - BYO mode: the orchestrator does NO writes; instead it resolves
//     `console.<byo_domain>` and confirms the CNAME target is the
//     otech ingress hostname. A failed lookup or mismatched target is
//     surfaced as a structured error so the wizard UI can render
//     "your CNAME doesn't point here yet" without a chat-with-support
//     loop.
//
// Per docs/INVIOLABLE-PRINCIPLES.md #4 the PowerDNS endpoint + API
// key are env-configured (CATALYST_POWERDNS_URL,
// CATALYST_POWERDNS_API_KEY). A nil writer (env unset) returns a
// graceful error rather than a panic; the orchestrator surfaces that
// as a transient `dns:transient:powerdns not wired` until the
// catalyst-api is restarted with the env populated.
package handler

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

// PowerDNSWriter is a minimal PowerDNS API client matching the subset
// the SME-tenant pipeline needs: PATCH zones/<zone> with one or more
// RRsets. Kept narrow on purpose — the orchestrator never lists,
// creates, or deletes whole zones (the otech zone already exists).
type PowerDNSWriter struct {
	BaseURL    string
	ServerID   string // PowerDNS server identifier; "localhost" by default
	APIKey     string
	HTTPClient *http.Client
}

// NewPowerDNSWriter returns a configured client. Empty baseURL or
// apiKey returns nil so the orchestrator can fall back to the no-op
// writer below.
func NewPowerDNSWriter(baseURL, apiKey string) *PowerDNSWriter {
	if strings.TrimSpace(baseURL) == "" || strings.TrimSpace(apiKey) == "" {
		return nil
	}
	return &PowerDNSWriter{
		BaseURL:    strings.TrimRight(baseURL, "/"),
		ServerID:   "localhost",
		APIKey:     apiKey,
		HTTPClient: &http.Client{Timeout: 30 * time.Second},
	}
}

// pdnsRRSet is the API shape the orchestrator emits.
type pdnsRRSet struct {
	Name       string       `json:"name"`
	Type       string       `json:"type"`
	TTL        int          `json:"ttl"`
	ChangeType string       `json:"changetype"`
	Records    []pdnsRecord `json:"records"`
}

type pdnsRecord struct {
	Content  string `json:"content"`
	Disabled bool   `json:"disabled"`
}

// PatchRRSets PATCHes a list of RRsets. PowerDNS REPLACE is the only
// changetype the orchestrator uses — idempotent, re-runnable, and the
// canonical "create or update" shape per the PowerDNS HTTP API.
func (w *PowerDNSWriter) PatchRRSets(ctx context.Context, zone string, rrsets []pdnsRRSet) error {
	body := struct {
		RRSets []pdnsRRSet `json:"rrsets"`
	}{RRSets: rrsets}
	raw, err := json.Marshal(body)
	if err != nil {
		return fmt.Errorf("marshal: %w", err)
	}
	srv := w.ServerID
	if srv == "" {
		srv = "localhost"
	}
	url := fmt.Sprintf("%s/api/v1/servers/%s/zones/%s",
		w.BaseURL, srv, zoneCanonical(zone))
	req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, bytes.NewReader(raw))
	if err != nil {
		return err
	}
	req.Header.Set("X-API-Key", w.APIKey)
	req.Header.Set("Content-Type", "application/json")
	resp, err := w.HTTPClient.Do(req)
	if err != nil {
		return fmt.Errorf("powerdns PATCH: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode/100 != 2 {
		respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
		return fmt.Errorf("powerdns PATCH HTTP %d: %s", resp.StatusCode, string(respBody))
	}
	return nil
}

// zoneCanonical ensures the zone name ends with a single trailing
// dot (PowerDNS API convention).
func zoneCanonical(zone string) string {
	zone = strings.TrimRight(zone, ".")
	return zone + "."
}

// DefaultSMETenantDNSProvisioner is the production
// SMETenantDNSProvisioner. Wraps a PowerDNSWriter for free-subdomain
// writes and net.LookupCNAME for BYO validation.
type DefaultSMETenantDNSProvisioner struct {
	Writer *PowerDNSWriter
	// Resolver — net resolver used for BYO CNAME lookups. Defaults to
	// net.DefaultResolver; tests inject a stub that returns canned
	// responses without hitting the live DNS root.
	Resolver Resolver
}

// Resolver is the narrow interface the BYO validator needs.
// net.DefaultResolver satisfies it.
type Resolver interface {
	LookupCNAME(ctx context.Context, host string) (string, error)
}

// ProvisionFreeSubdomain implements SMETenantDNSProvisioner. Writes
// A record for `console.<subdomain>.<otech-fqdn>` plus the standard
// app-host A records (wordpress, openclaw, mail) so the per-tenant
// overlay's ingress hostnames resolve as soon as Flux finishes
// reconciling.
func (p DefaultSMETenantDNSProvisioner) ProvisionFreeSubdomain(ctx context.Context, subdomain, otechFQDN, ingressIPv4 string) error {
	if p.Writer == nil {
		return errors.New("powerdns writer not wired (CATALYST_POWERDNS_URL / CATALYST_POWERDNS_API_KEY)")
	}
	if strings.TrimSpace(ingressIPv4) == "" {
		return errors.New("otech ingress IPv4 unconfigured")
	}
	zone := otechFQDN
	rrsets := []pdnsRRSet{}
	for _, prefix := range []string{"console", "wordpress", "openclaw", "mail", "keycloak"} {
		fqdn := fmt.Sprintf("%s.%s.%s.", prefix, subdomain, otechFQDN)
		rrsets = append(rrsets, pdnsRRSet{
			Name:       fqdn,
			Type:       "A",
			TTL:        300,
			ChangeType: "REPLACE",
			Records: []pdnsRecord{
				{Content: ingressIPv4, Disabled: false},
			},
		})
	}
	return p.Writer.PatchRRSets(ctx, zone, rrsets)
}

// ValidateBYOCNAME implements SMETenantDNSProvisioner. Resolves
// `console.<byo_domain>` and confirms its CNAME target ends with the
// otech FQDN.
func (p DefaultSMETenantDNSProvisioner) ValidateBYOCNAME(ctx context.Context, byoDomain, otechFQDN string) error {
	resolver := p.Resolver
	if resolver == nil {
		resolver = net.DefaultResolver
	}
	host := "console." + strings.Trim(byoDomain, ".")
	target, err := resolver.LookupCNAME(ctx, host)
	if err != nil {
		return fmt.Errorf("resolve CNAME for %s: %w", host, err)
	}
	target = strings.Trim(target, ".")
	expected := strings.Trim(otechFQDN, ".")
	if !strings.HasSuffix(strings.ToLower(target), strings.ToLower(expected)) {
		return fmt.Errorf("%w (got %q, expected suffix %q)", errBYOCNAMEMismatch, target, expected)
	}
	return nil
}

// NoopSMETenantDNSProvisioner satisfies SMETenantDNSProvisioner with
// success-on-call no-ops. Used in CI / test environments without
// PowerDNS or a public DNS resolver. Production main.go wires this
// only when both env knobs (CATALYST_POWERDNS_URL,
// CATALYST_POWERDNS_API_KEY) are absent — that's an explicit signal
// from the operator that they're not running the DNS step here.
type NoopSMETenantDNSProvisioner struct{}

// ProvisionFreeSubdomain is a no-op.
func (NoopSMETenantDNSProvisioner) ProvisionFreeSubdomain(_ context.Context, _, _, _ string) error {
	return nil
}

// ValidateBYOCNAME is a no-op (returns nil — accepts any domain).
func (NoopSMETenantDNSProvisioner) ValidateBYOCNAME(_ context.Context, _, _ string) error {
	return nil
}
