// Package mirror publishes a signed distribution document to ≥3 independent
// mirrors (Cloudflare Pages / GitHub <separate account> / object storage) and
// verifies that every mirror serves byte-identical content (doc/05 §1: the
// announcement/endpoint channel has the most mirrors and the highest priority).
//
// Mirrors are modelled filesystem-first so publishing and consistency checks are
// fully offline-testable: each Target has a local content root (Dir) that the
// real deploy syncs to its platform via an out-of-band SyncCmd (NOT executed by
// this package). For read-back/verify and client-style failover fetch, a Target
// may also expose a FetchURL (file://, http:// or https://).
package mirror

import (
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"time"
)

// Target is one mirror destination.
type Target struct {
	Name string `json:"name"`
	// Dir is the local content root that Publish writes the object into.
	Dir string `json:"dir,omitempty"`
	// FetchURL is the base location used for read-back verification and failover
	// fetch. If empty, Dir is used. Supports file://, http://, https://.
	FetchURL string `json:"fetch_url,omitempty"`
	// SyncCmd documents how the deploy ships Dir to the platform. Recorded for
	// the operator; this package never runs it.
	SyncCmd []string `json:"sync_cmd,omitempty"`
}

// Config is the publish-mirrors config file.
type Config struct {
	Mirrors []Target `json:"mirrors"`
}

// Result is the outcome of publishing to a single mirror.
type Result struct {
	Name   string
	Path   string
	SHA256 string
	Err    error
}

// SHA256Hex returns the hex sha256 of content.
func SHA256Hex(content []byte) string {
	sum := sha256.Sum256(content)
	return hex.EncodeToString(sum[:])
}

// Publish writes content as objectName into every target's Dir and returns a
// per-target Result. It returns an error if any mirror failed, but still
// reports results for all of them.
func Publish(content []byte, objectName string, targets []Target) ([]Result, error) {
	if len(targets) == 0 {
		return nil, errors.New("mirror: no targets configured")
	}
	want := SHA256Hex(content)
	results := make([]Result, 0, len(targets))
	var firstErr error
	for _, t := range targets {
		r := Result{Name: t.Name, SHA256: want}
		if t.Dir == "" {
			r.Err = fmt.Errorf("mirror %q: dir is empty, cannot publish", t.Name)
		} else if err := os.MkdirAll(t.Dir, 0o755); err != nil {
			r.Err = fmt.Errorf("mirror %q: mkdir: %w", t.Name, err)
		} else {
			r.Path = filepath.Join(t.Dir, objectName)
			if err := os.WriteFile(r.Path, content, 0o644); err != nil {
				r.Err = fmt.Errorf("mirror %q: write: %w", t.Name, err)
			}
		}
		if r.Err != nil && firstErr == nil {
			firstErr = r.Err
		}
		results = append(results, r)
	}
	return results, firstErr
}

// VerifyConsistency fetches objectName from every target and confirms each
// matches want (hex sha256). A nil error means every mirror is byte-identical.
func VerifyConsistency(objectName string, targets []Target, want string) error {
	if len(targets) == 0 {
		return errors.New("mirror: no targets configured")
	}
	var problems []string
	for _, t := range targets {
		content, err := fetchOne(t, objectName)
		if err != nil {
			problems = append(problems, fmt.Sprintf("%s: %v", t.Name, err))
			continue
		}
		got := SHA256Hex(content)
		if got != want {
			problems = append(problems, fmt.Sprintf("%s: sha256 mismatch (got %s want %s)", t.Name, got, want))
		}
	}
	if len(problems) > 0 {
		return fmt.Errorf("mirror: consistency check failed:\n  %s", strings.Join(problems, "\n  "))
	}
	return nil
}

// Fetch tries each target in order and returns the content from the first one
// that both downloads and passes validate. This is the client-side failover:
// any single mirror being down/poisoned still lets us fetch from the rest.
// validate may be nil. It returns the winning target name.
func Fetch(objectName string, targets []Target, validate func([]byte) error) ([]byte, string, error) {
	if len(targets) == 0 {
		return nil, "", errors.New("mirror: no targets configured")
	}
	var attempts []string
	for _, t := range targets {
		content, err := fetchOne(t, objectName)
		if err != nil {
			attempts = append(attempts, fmt.Sprintf("%s: %v", t.Name, err))
			continue
		}
		if validate != nil {
			if err := validate(content); err != nil {
				attempts = append(attempts, fmt.Sprintf("%s: %v", t.Name, err))
				continue
			}
		}
		return content, t.Name, nil
	}
	return nil, "", fmt.Errorf("mirror: all mirrors failed:\n  %s", strings.Join(attempts, "\n  "))
}

func fetchOne(t Target, objectName string) ([]byte, error) {
	base := t.FetchURL
	if base == "" {
		// Fall back to the local Dir.
		if t.Dir == "" {
			return nil, errors.New("no fetch_url or dir configured")
		}
		return os.ReadFile(filepath.Join(t.Dir, objectName))
	}
	switch {
	case strings.HasPrefix(base, "file://"):
		root := strings.TrimPrefix(base, "file://")
		return os.ReadFile(filepath.Join(root, objectName))
	case strings.HasPrefix(base, "http://"), strings.HasPrefix(base, "https://"):
		return httpGet(joinURL(base, objectName))
	default:
		// Treat as a bare filesystem path.
		return os.ReadFile(filepath.Join(base, objectName))
	}
}

func joinURL(base, name string) string {
	return strings.TrimRight(base, "/") + "/" + strings.TrimLeft(name, "/")
}

func httpGet(u string) ([]byte, error) {
	client := &http.Client{Timeout: 15 * time.Second}
	resp, err := client.Get(u)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("http %d", resp.StatusCode)
	}
	return io.ReadAll(io.LimitReader(resp.Body, 4<<20)) // 4 MiB cap
}
