// Package traefik implements the Traefik file-provider target
// connector. Bundle 4 of the 2026-05-02 deployment-target audit:
// upgraded from two separate deploy.AtomicWriteFile calls (cert,
// key) to a single deploy.Apply Plan with all-files atomicity.
// Traefik has no PreCommit (no `nginx -t` equivalent) and no
// PostCommit (file watcher auto-reloads on rename). Post-deploy
// TLS verify (optional) confirms the watcher picked up the new
// cert; on verify failure, restoreFromBackups rewrites every File
// path from its backup so the watcher reloads to the prior state.
package traefik

import (
	"context"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/certctl-io/certctl/internal/connector/target"
	"github.com/certctl-io/certctl/internal/deploy"
	"github.com/certctl-io/certctl/internal/tlsprobe"
)

type Config struct {
	CertDir  string `json:"cert_dir"`
	CertFile string `json:"cert_file"`
	KeyFile  string `json:"key_file"`

	// Phase 7: per-file mode/owner overrides + post-deploy verify
	// + backup retention.
	CertFileMode               os.FileMode             `json:"cert_file_mode,omitempty"`
	KeyFileMode                os.FileMode             `json:"key_file_mode,omitempty"`
	CertFileOwner              string                  `json:"cert_file_owner,omitempty"`
	CertFileGroup              string                  `json:"cert_file_group,omitempty"`
	KeyFileOwner               string                  `json:"key_file_owner,omitempty"`
	KeyFileGroup               string                  `json:"key_file_group,omitempty"`
	PostDeployVerify           *PostDeployVerifyConfig `json:"post_deploy_verify,omitempty"`
	PostDeployVerifyAttempts   int                     `json:"post_deploy_verify_attempts,omitempty"`
	PostDeployVerifyBackoff    time.Duration           `json:"post_deploy_verify_backoff,omitempty"`
	PostDeployVerifyMaxBackoff time.Duration           `json:"post_deploy_verify_max_backoff,omitempty"`
	BackupRetention            int                     `json:"backup_retention,omitempty"`
}

type PostDeployVerifyConfig struct {
	Enabled  bool          `json:"enabled"`
	Endpoint string        `json:"endpoint,omitempty"`
	Timeout  time.Duration `json:"timeout,omitempty"`
}

type Connector struct {
	config *Config
	logger *slog.Logger
	probe  func(ctx context.Context, address string, timeout time.Duration) tlsprobe.ProbeResult
}

func New(config *Config, logger *slog.Logger) *Connector {
	return &Connector{config: config, logger: logger, probe: tlsprobe.ProbeTLS}
}

func (c *Connector) SetTestProbe(fn func(ctx context.Context, address string, timeout time.Duration) tlsprobe.ProbeResult) {
	c.probe = fn
}

func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
	var cfg Config
	if err := json.Unmarshal(rawConfig, &cfg); err != nil {
		return fmt.Errorf("invalid Traefik config: %w", err)
	}
	if cfg.CertDir == "" {
		return fmt.Errorf("Traefik cert_dir is required")
	}
	if cfg.CertFile == "" {
		cfg.CertFile = "cert.pem"
	}
	if cfg.KeyFile == "" {
		cfg.KeyFile = "key.pem"
	}
	c.logger.Info("validating Traefik configuration", "cert_dir", cfg.CertDir,
		"cert_file", cfg.CertFile, "key_file", cfg.KeyFile)
	if _, err := os.Stat(cfg.CertDir); os.IsNotExist(err) {
		return fmt.Errorf("Traefik cert directory does not exist: %s", cfg.CertDir)
	}
	testFile := filepath.Join(cfg.CertDir, ".certctl-write-test")
	if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
		return fmt.Errorf("Traefik cert directory is not writable: %s (%w)", cfg.CertDir, err)
	}
	os.Remove(testFile)
	c.config = &cfg
	c.logger.Info("Traefik configuration validated")
	return nil
}

// DeployCertificate writes cert + chain (combined) and key via a
// single deploy.Apply Plan. Bundle 4 of the 2026-05-02 deployment-
// target audit replaced the prior two-AtomicWriteFile approach,
// which broke all-files atomicity (a key-write failure after a
// successful cert write left an orphaned cert and the dedicated
// rollback helper only restored the cert).
//
// Traefik has no PreCommit (no validate-with-target command) and
// no PostCommit (inotify watcher auto-reloads on rename). Apply
// gives us SHA-256 idempotency over both files and rollback
// semantics if the rename loop ever fails mid-stream.
//
// Post-deploy TLS verify (optional via PostDeployVerify) confirms
// the watcher picked up the new bytes; on mismatch, restoreFromBackups
// rewrites both files from their backups and the watcher will
// auto-reload to the prior state on its next tick.
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
	c.logger.Info("deploying certificate to Traefik",
		"cert_dir", c.config.CertDir, "cert_file", c.config.CertFile, "key_file", c.config.KeyFile)
	startTime := time.Now()

	certPath := filepath.Join(c.config.CertDir, c.config.CertFile)
	keyPath := filepath.Join(c.config.CertDir, c.config.KeyFile)

	plan := c.buildPlan(request, certPath, keyPath)

	res, err := deploy.Apply(ctx, plan)
	if err != nil {
		return c.failureResult(certPath, "deploy.Apply", err, startTime), err
	}

	// Post-deploy TLS verify (skip when nothing changed).
	if !res.SkippedAsIdempotent {
		if vErr := c.runPostDeployVerify(ctx, request.CertPEM); vErr != nil {
			c.logger.Error("post-deploy TLS verify failed; rolling back", "error", vErr)
			rbErr := c.restoreFromBackups(ctx, res.BackupPaths)
			if rbErr != nil {
				return c.failureResult(certPath, "verify+rollback both failed",
					fmt.Errorf("verify: %w; rollback: %v", vErr, rbErr), startTime), rbErr
			}
			return c.failureResult(certPath, "post-deploy verify failed; rolled back", vErr, startTime), vErr
		}
	}

	dur := time.Since(startTime)
	idemNote := ""
	if res.SkippedAsIdempotent {
		idemNote = " (idempotent skip — bytes unchanged)"
	}
	c.logger.Info("certificate deployed to Traefik successfully",
		"duration", dur.String(), "cert_path", certPath, "idempotent", res.SkippedAsIdempotent)
	return &target.DeploymentResult{
		Success:       true,
		TargetAddress: certPath,
		DeploymentID:  fmt.Sprintf("traefik-%d", time.Now().Unix()),
		Message:       "Certificate deployed to Traefik (file watcher will auto-reload)" + idemNote,
		DeployedAt:    time.Now(),
		Metadata: map[string]string{
			"cert_path": certPath, "key_path": keyPath,
			"duration_ms": fmt.Sprintf("%d", dur.Milliseconds()),
			"idempotent":  fmt.Sprintf("%t", res.SkippedAsIdempotent),
		},
	}, nil
}

// buildPlan assembles the deploy.Plan for one cert+(optional)key
// deployment. Cert and key are separate Files in the same Plan so
// Apply runs SHA-256 idempotency all-files and produces all-or-
// nothing atomicity. The combined-PEM (cert + "\n" + chain + "\n")
// shape is preserved for byte-equal compatibility with pre-Bundle-4
// deploys.
func (c *Connector) buildPlan(request target.DeploymentRequest, certPath, keyPath string) deploy.Plan {
	combined := request.CertPEM + "\n"
	if request.ChainPEM != "" {
		combined = combined + request.ChainPEM + "\n"
	}
	certMode := c.config.CertFileMode
	if certMode == 0 {
		certMode = 0644
	}
	keyMode := c.config.KeyFileMode
	if keyMode == 0 {
		keyMode = 0600
	}

	files := []deploy.File{{
		Path:  certPath,
		Bytes: []byte(combined),
		Mode:  certMode,
		Owner: c.config.CertFileOwner,
		Group: c.config.CertFileGroup,
	}}
	if request.KeyPEM != "" {
		files = append(files, deploy.File{
			Path:  keyPath,
			Bytes: []byte(request.KeyPEM),
			Mode:  keyMode,
			Owner: c.config.KeyFileOwner,
			Group: c.config.KeyFileGroup,
		})
	}
	return deploy.Plan{
		Files:           files,
		BackupRetention: c.config.BackupRetention,
	}
}

// ValidateOnly returns ErrValidateOnlyNotSupported. Traefik has no
// validate-with-the-target command (the file watcher just picks up
// changes); there is no way to dry-run a cert deploy without
// touching the live files.
func (c *Connector) ValidateOnly(ctx context.Context, request target.DeploymentRequest) error {
	return target.ErrValidateOnlyNotSupported
}

func (c *Connector) runPostDeployVerify(ctx context.Context, deployedCertPEM string) error {
	verify := c.config.PostDeployVerify
	if verify == nil || !verify.Enabled || verify.Endpoint == "" {
		return nil
	}
	timeout := verify.Timeout
	if timeout <= 0 {
		timeout = 10 * time.Second
	}
	want, err := certPEMToFingerprint(deployedCertPEM)
	if err != nil {
		return fmt.Errorf("compute fingerprint: %w", err)
	}

	retryCfg := tlsprobe.RetryConfig{
		Attempts:       c.config.PostDeployVerifyAttempts,
		InitialBackoff: c.config.PostDeployVerifyBackoff,
		MaxBackoff:     c.config.PostDeployVerifyMaxBackoff,
	}

	probe := func(probectx context.Context) error {
		res := c.probe(probectx, verify.Endpoint, timeout)
		if !res.Success {
			return fmt.Errorf("TLS probe failed: %s", res.Error)
		}
		if !strings.EqualFold(res.Fingerprint, want) {
			return fmt.Errorf("post-deploy TLS verify SHA-256 mismatch: got %s, want %s", res.Fingerprint, want)
		}
		return nil
	}

	return tlsprobe.VerifyWithExponentialBackoff(ctx, retryCfg, probe)
}

// restoreFromBackups iterates the BackupPaths returned by deploy.Apply
// and rewrites every destination from its backup via AtomicWriteFile
// {SkipIdempotent:true, BackupRetention:-1}. The -1 prevents
// backup-of-the-backup pollution when a rollback fires.
//
// For files that did not exist before the deploy (BackupPath == ""),
// restore = remove. Mirrors nginx.go::rollbackToBackups (L487-515)
// with the reload step elided — Traefik's inotify watcher will
// pick up the restored bytes on its next tick.
//
// Bundle 4 of the 2026-05-02 deployment-target audit. Replaces the
// pre-fix rollbackCertAndKey helper which only restored the cert
// (key was orphaned on verify failure).
func (c *Connector) restoreFromBackups(ctx context.Context, backupPaths map[string]string) error {
	for finalPath, backupPath := range backupPaths {
		if backupPath == "" {
			// File didn't exist pre-deploy → restore = remove.
			if err := os.Remove(finalPath); err != nil && !errors.Is(err, os.ErrNotExist) {
				return fmt.Errorf("rollback remove %s: %w", finalPath, err)
			}
			continue
		}
		bytes, err := os.ReadFile(backupPath)
		if err != nil {
			return fmt.Errorf("rollback read backup %s: %w", backupPath, err)
		}
		if _, err := deploy.AtomicWriteFile(ctx, finalPath, bytes, deploy.WriteOptions{
			SkipIdempotent:  true,
			BackupRetention: -1, // don't backup the rollback
		}); err != nil {
			return fmt.Errorf("rollback write %s: %w", finalPath, err)
		}
	}
	return nil
}

func (c *Connector) failureResult(addr, stage string, err error, startTime time.Time) *target.DeploymentResult {
	return &target.DeploymentResult{
		Success: false, TargetAddress: addr,
		Message: fmt.Sprintf("%s: %v", stage, err), DeployedAt: time.Now(),
		Metadata: map[string]string{
			"stage": stage, "duration_ms": fmt.Sprintf("%d", time.Since(startTime).Milliseconds()),
		},
	}
}

func certPEMToFingerprint(pemBytes string) (string, error) {
	begin := "-----BEGIN CERTIFICATE-----"
	end := "-----END CERTIFICATE-----"
	beginIdx := strings.Index(pemBytes, begin)
	if beginIdx < 0 {
		return "", fmt.Errorf("no CERTIFICATE PEM block")
	}
	rest := pemBytes[beginIdx+len(begin):]
	endIdx := strings.Index(rest, end)
	if endIdx < 0 {
		return "", fmt.Errorf("PEM not terminated")
	}
	body := strings.TrimSpace(rest[:endIdx])
	body = strings.ReplaceAll(body, "\n", "")
	body = strings.ReplaceAll(body, "\r", "")
	body = strings.ReplaceAll(body, " ", "")
	der, err := base64.StdEncoding.DecodeString(body)
	if err != nil {
		return "", fmt.Errorf("base64: %w", err)
	}
	h := sha256.Sum256(der)
	return hex.EncodeToString(h[:]), nil
}

func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
	c.logger.Info("validating Traefik deployment", "certificate_id", request.CertificateID, "serial", request.Serial)
	startTime := time.Now()
	certPath := filepath.Join(c.config.CertDir, c.config.CertFile)
	keyPath := filepath.Join(c.config.CertDir, c.config.KeyFile)
	if _, err := os.Stat(certPath); os.IsNotExist(err) {
		return &target.ValidationResult{
			Valid: false, Serial: request.Serial, TargetAddress: certPath,
			Message: fmt.Sprintf("certificate file not found: %s", certPath), ValidatedAt: time.Now(),
		}, fmt.Errorf("certificate file not found: %s", certPath)
	}
	if _, err := os.Stat(keyPath); os.IsNotExist(err) {
		return &target.ValidationResult{
			Valid: false, Serial: request.Serial, TargetAddress: keyPath,
			Message: fmt.Sprintf("private key file not found: %s", keyPath), ValidatedAt: time.Now(),
		}, fmt.Errorf("private key file not found: %s", keyPath)
	}
	dur := time.Since(startTime)
	return &target.ValidationResult{
		Valid: true, Serial: request.Serial, TargetAddress: certPath,
		Message: "Certificate and key files accessible", ValidatedAt: time.Now(),
		Metadata: map[string]string{
			"cert_path": certPath, "key_path": keyPath,
			"duration_ms": fmt.Sprintf("%d", dur.Milliseconds()),
		},
	}, nil
}
