package assets

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

// ManifestEntry describes a single asset file with its checksum.
type ManifestEntry struct {
	Filename string `json:"filename"`
	Size     int64  `json:"size"`
	SHA256   string `json:"sha256"`
}

// Manifest lists all available asset files with checksums for change detection.
type Manifest struct {
	Version   int             `json:"version"`
	Generated string          `json:"generated"`
	Files     []ManifestEntry `json:"files"`
}

// Manager handles serving app assets (logos, screenshots) from a PVC directory,
// with automatic seeding from a baked-in image directory on first run.
type Manager struct {
	assetsDir string // PVC path: /data/assets/
	seedDir   string // Image path: /usr/share/felhom/assets-seed/
	manifest  *Manifest
	mu        sync.RWMutex
	logger    *log.Logger
}

// New creates a Manager, syncs assets from the image seed directory,
// and builds the initial manifest.
func New(assetsDir, seedDir string, logger *log.Logger) *Manager {
	m := &Manager{
		assetsDir: assetsDir,
		seedDir:   seedDir,
		logger:    logger,
	}

	if err := os.MkdirAll(assetsDir, 0755); err != nil {
		logger.Printf("[ERROR] Failed to create assets dir %s: %v", assetsDir, err)
	}

	m.seedOrUpdate()

	if err := m.RebuildManifest(); err != nil {
		logger.Printf("[ERROR] Failed to build asset manifest: %v", err)
	}

	return m
}

// seedOrUpdate copies new or changed assets from the seed directory to the PVC directory.
// On first run (empty PVC) this seeds all files. On subsequent runs it updates only files
// whose SHA-256 checksums differ, ensuring redeployed images propagate asset changes.
func (m *Manager) seedOrUpdate() {
	seedEntries, err := os.ReadDir(m.seedDir)
	if err != nil {
		m.logger.Printf("[WARN] Cannot read seed dir %s: %v (no assets will be available until uploaded)", m.seedDir, err)
		return
	}

	// Build hash map of existing PVC assets for comparison
	existingHashes := make(map[string]string)
	pvcEntries, _ := os.ReadDir(m.assetsDir)
	for _, e := range pvcEntries {
		if e.IsDir() || !isAssetFile(e.Name()) {
			continue
		}
		h, err := fileSHA256(filepath.Join(m.assetsDir, e.Name()))
		if err == nil {
			existingHashes[e.Name()] = h
		}
	}

	copied, skipped := 0, 0
	for _, e := range seedEntries {
		if e.IsDir() || !isAssetFile(e.Name()) {
			continue
		}
		src := filepath.Join(m.seedDir, e.Name())

		// Compare checksums — skip if unchanged
		seedHash, err := fileSHA256(src)
		if err != nil {
			m.logger.Printf("[WARN] Cannot hash seed asset %s: %v", e.Name(), err)
			continue
		}
		if existingHashes[e.Name()] == seedHash {
			skipped++
			continue
		}

		dst := filepath.Join(m.assetsDir, e.Name())
		if err := copyFile(src, dst); err != nil {
			m.logger.Printf("[WARN] Failed to seed asset %s: %v", e.Name(), err)
			continue
		}
		copied++
	}

	if copied > 0 {
		m.logger.Printf("[INFO] Asset seed: %d updated, %d unchanged", copied, skipped)
	} else {
		m.logger.Printf("[INFO] Asset seed: all %d files up-to-date", skipped)
	}
}

// ReSeed re-runs the seed-or-update process and rebuilds the manifest.
// Used by the web UI "Refresh Assets" button.
func (m *Manager) ReSeed() error {
	m.mu.Lock()
	defer m.mu.Unlock()

	m.seedOrUpdate()
	return m.rebuildManifestLocked()
}

// RebuildManifest rescans the assets directory and recomputes SHA-256 hashes.
func (m *Manager) RebuildManifest() error {
	m.mu.Lock()
	defer m.mu.Unlock()
	return m.rebuildManifestLocked()
}

// rebuildManifestLocked does the actual manifest rebuild. Caller must hold m.mu.
func (m *Manager) rebuildManifestLocked() error {
	entries, err := os.ReadDir(m.assetsDir)
	if err != nil {
		return fmt.Errorf("reading assets dir: %w", err)
	}

	var files []ManifestEntry
	for _, e := range entries {
		if e.IsDir() || !isAssetFile(e.Name()) {
			continue
		}

		path := filepath.Join(m.assetsDir, e.Name())
		info, err := e.Info()
		if err != nil {
			m.logger.Printf("[WARN] Cannot stat asset %s: %v", e.Name(), err)
			continue
		}

		hash, err := fileSHA256(path)
		if err != nil {
			m.logger.Printf("[WARN] Cannot hash asset %s: %v", e.Name(), err)
			continue
		}

		files = append(files, ManifestEntry{
			Filename: e.Name(),
			Size:     info.Size(),
			SHA256:   hash,
		})
	}

	manifest := &Manifest{
		Version:   1,
		Generated: time.Now().UTC().Format(time.RFC3339),
		Files:     files,
	}

	m.manifest = manifest

	m.logger.Printf("[INFO] Asset manifest built: %d files", len(files))
	return nil
}

// GetManifest returns the cached manifest.
func (m *Manager) GetManifest() *Manifest {
	m.mu.RLock()
	defer m.mu.RUnlock()
	return m.manifest
}

// ServeFile serves a single asset file by name.
func (m *Manager) ServeFile(w http.ResponseWriter, r *http.Request, filename string) {
	// Sanitize: only base name, no path traversal
	filename = filepath.Base(filename)
	if filename == "." || filename == "/" {
		http.NotFound(w, r)
		return
	}

	path := filepath.Join(m.assetsDir, filename)
	if _, err := os.Stat(path); os.IsNotExist(err) {
		http.NotFound(w, r)
		return
	}

	// Set content type based on extension
	switch {
	case strings.HasSuffix(filename, ".svg"):
		w.Header().Set("Content-Type", "image/svg+xml")
	case strings.HasSuffix(filename, ".png"):
		w.Header().Set("Content-Type", "image/png")
	case strings.HasSuffix(filename, ".webp"):
		w.Header().Set("Content-Type", "image/webp")
	}

	w.Header().Set("Cache-Control", "public, max-age=86400")
	http.ServeFile(w, r, path)
}

// FileCount returns the number of asset files in the manifest.
func (m *Manager) FileCount() int {
	m.mu.RLock()
	defer m.mu.RUnlock()
	if m.manifest == nil {
		return 0
	}
	return len(m.manifest.Files)
}

// isAssetFile returns true if the filename matches the asset naming convention.
// Matches: *-logo.svg, *-logo.png, *-screenshot-*.webp, felhom-logo.svg, felhom-favicon.svg
func isAssetFile(name string) bool {
	if strings.Contains(name, "-logo.") {
		return strings.HasSuffix(name, ".svg") || strings.HasSuffix(name, ".png")
	}
	if strings.Contains(name, "-screenshot-") {
		return strings.HasSuffix(name, ".webp")
	}
	if strings.Contains(name, "-favicon.") {
		return strings.HasSuffix(name, ".svg") || strings.HasSuffix(name, ".ico")
	}
	return false
}

// fileSHA256 computes the SHA-256 hex digest of a file.
func fileSHA256(path string) (string, error) {
	f, err := os.Open(path)
	if err != nil {
		return "", err
	}
	defer f.Close()

	h := sha256.New()
	if _, err := io.Copy(h, f); err != nil {
		return "", err
	}
	return hex.EncodeToString(h.Sum(nil)), nil
}

// copyFile copies src to dst using atomic write (write to .tmp, rename).
func copyFile(src, dst string) error {
	in, err := os.Open(src)
	if err != nil {
		return err
	}
	defer in.Close()

	tmp := dst + ".tmp"
	out, err := os.Create(tmp)
	if err != nil {
		return err
	}

	if _, err := io.Copy(out, in); err != nil {
		out.Close()
		os.Remove(tmp)
		return err
	}
	if err := out.Close(); err != nil {
		os.Remove(tmp)
		return err
	}

	return os.Rename(tmp, dst)
}

// MarshalManifestJSON returns the manifest as indented JSON.
func (m *Manager) MarshalManifestJSON() ([]byte, error) {
	m.mu.RLock()
	manifest := m.manifest
	m.mu.RUnlock()

	if manifest == nil {
		manifest = &Manifest{Version: 1, Generated: time.Now().UTC().Format(time.RFC3339)}
	}
	return json.MarshalIndent(manifest, "", "  ")
}
