package assets

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

// ManifestEntry mirrors the Hub's manifest entry.
type ManifestEntry struct {
	Filename string `json:"filename"`
	Size     int64  `json:"size"`
	SHA256   string `json:"sha256"`
}

// HubManifest is the response from GET /api/v1/assets/manifest.
type HubManifest struct {
	Version   int             `json:"version"`
	Generated string          `json:"generated"`
	Files     []ManifestEntry `json:"files"`
}

// SyncStatus reports the state of the last sync.
type SyncStatus struct {
	LastSync   string `json:"last_sync"`   // RFC3339
	LastStatus string `json:"last_status"` // "ok", "error", "never"
	LastError  string `json:"last_error,omitempty"`
	FileCount  int    `json:"file_count"`
	TotalBytes int64  `json:"total_bytes"`
}

// Syncer downloads and caches app assets from the Hub API.
type Syncer struct {
	hubURL      string
	apiKey      string
	assetsDir   string // <dataDir>/assets — downloaded cache
	fallbackDir string // /usr/share/felhom/assets — baked-in fallback
	httpClient  *http.Client
	logger      *log.Logger
	debug       bool
	mu          sync.Mutex
	running     bool
	status      SyncStatus
}

// New creates a Syncer that downloads assets from the Hub.
func New(hubURL, apiKey, assetsDir, fallbackDir string, logger *log.Logger, debug bool) *Syncer {
	return &Syncer{
		hubURL:      strings.TrimSuffix(hubURL, "/"),
		apiKey:      apiKey,
		assetsDir:   assetsDir,
		fallbackDir: fallbackDir,
		httpClient:  &http.Client{Timeout: 60 * time.Second},
		logger:      logger,
		debug:       debug,
		status:      SyncStatus{LastStatus: "never"},
	}
}

func (s *Syncer) dbg(format string, args ...interface{}) {
	if s.debug {
		s.logger.Printf("[DEBUG] [assets] "+format, args...)
	}
}

// Sync fetches the manifest from the Hub, compares checksums, and downloads
// changed/new files. It also removes local files not in the Hub manifest.
func (s *Syncer) Sync(ctx context.Context) error {
	s.mu.Lock()
	if s.running {
		s.mu.Unlock()
		return fmt.Errorf("asset sync already in progress")
	}
	s.running = true
	s.mu.Unlock()

	defer func() {
		s.mu.Lock()
		s.running = false
		s.mu.Unlock()
	}()

	s.logger.Println("[INFO] [assets] Asset sync starting...")
	syncStart := time.Now()

	if err := os.MkdirAll(s.assetsDir, 0755); err != nil {
		s.setError(fmt.Errorf("create assets dir: %w", err))
		return err
	}

	// 1. Fetch Hub manifest
	s.dbg("fetching manifest from %s/api/v1/assets/manifest", s.hubURL)
	manifestStart := time.Now()
	manifest, err := s.fetchManifest(ctx)
	if err != nil {
		s.setError(fmt.Errorf("fetch manifest: %w", err))
		return err
	}
	s.dbg("manifest fetched in %s: %d files, generated=%s",
		time.Since(manifestStart).Round(time.Millisecond), len(manifest.Files), manifest.Generated)

	// 2. Build local hash map
	hashStart := time.Now()
	localHashes, err := s.buildLocalHashes()
	if err != nil {
		s.setError(fmt.Errorf("scan local assets: %w", err))
		return err
	}
	s.dbg("local hash scan: %d files in %s", len(localHashes), time.Since(hashStart).Round(time.Millisecond))

	// 3. Download changed/new files
	hubFiles := make(map[string]bool, len(manifest.Files))
	var downloaded, skipped int
	var totalBytes int64

	for _, entry := range manifest.Files {
		hubFiles[entry.Filename] = true
		totalBytes += entry.Size

		if localHash, ok := localHashes[entry.Filename]; ok && localHash == entry.SHA256 {
			s.dbg("file %s: hash match (%s), skipping", entry.Filename, entry.SHA256[:12]+"...")
			skipped++
			continue
		}

		reason := "new"
		if localHash, ok := localHashes[entry.Filename]; ok {
			reason = fmt.Sprintf("hash mismatch (local=%s remote=%s)", localHash[:12]+"...", entry.SHA256[:12]+"...")
		}
		s.dbg("file %s: downloading (%s, %d bytes)", entry.Filename, reason, entry.Size)
		dlStart := time.Now()
		if err := s.downloadFile(ctx, entry.Filename); err != nil {
			s.logger.Printf("[WARN] [assets] Failed to download asset %s: %v", entry.Filename, err)
			continue
		}
		s.dbg("file %s: downloaded in %s", entry.Filename, time.Since(dlStart).Round(time.Millisecond))
		downloaded++
	}

	// 4. Remove local files not in Hub manifest
	var removed int
	for name := range localHashes {
		if !hubFiles[name] {
			path := filepath.Join(s.assetsDir, name)
			s.dbg("removing stale file %s", name)
			if err := os.Remove(path); err != nil {
				s.logger.Printf("[WARN] [assets] Failed to remove stale asset %s: %v", name, err)
			} else {
				removed++
			}
		}
	}

	// 5. Save local manifest copy
	s.saveLocalManifest(manifest)

	// 6. Update status (under lock)
	s.mu.Lock()
	s.status = SyncStatus{
		LastSync:   time.Now().UTC().Format(time.RFC3339),
		LastStatus: "ok",
		FileCount:  len(manifest.Files),
		TotalBytes: totalBytes,
	}
	s.mu.Unlock()

	s.logger.Printf("[INFO] [assets] Asset sync complete: %d downloaded, %d unchanged, %d removed (%d total files)",
		downloaded, skipped, removed, len(manifest.Files))
	s.dbg("sync completed in %s", time.Since(syncStart).Round(time.Millisecond))

	return nil
}

// Resolve returns the full path for an asset: checks assetsDir first, fallbackDir second.
func (s *Syncer) Resolve(filename string) string {
	filename = filepath.Base(filename) // sanitize

	// Check synced cache first
	cached := filepath.Join(s.assetsDir, filename)
	if _, err := os.Stat(cached); err == nil {
		return cached
	}

	// Fallback to baked-in assets
	fallback := filepath.Join(s.fallbackDir, filename)
	if _, err := os.Stat(fallback); err == nil {
		return fallback
	}

	// Not found — return cached path so caller gets a clean 404
	return cached
}

// Status returns the current sync status.
func (s *Syncer) Status() SyncStatus {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.status
}

func (s *Syncer) setError(err error) {
	s.mu.Lock()
	s.status = SyncStatus{
		LastSync:   time.Now().UTC().Format(time.RFC3339),
		LastStatus: "error",
		LastError:  err.Error(),
		FileCount:  s.status.FileCount,
		TotalBytes: s.status.TotalBytes,
	}
	s.mu.Unlock()
	s.logger.Printf("[ERROR] [assets] Asset sync failed: %v", err)
}

func (s *Syncer) fetchManifest(ctx context.Context) (*HubManifest, error) {
	url := s.hubURL + "/api/v1/assets/manifest"
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("Authorization", "Bearer "+s.apiKey)

	resp, err := s.httpClient.Do(req)
	if err != nil {
		s.dbg("fetchManifest: HTTP request failed: %v", err)
		return nil, fmt.Errorf("HTTP request: %w", err)
	}
	defer resp.Body.Close()
	s.dbg("fetchManifest: HTTP %d, content-length=%d", resp.StatusCode, resp.ContentLength)

	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
		return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
	}

	var manifest HubManifest
	if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil {
		return nil, fmt.Errorf("decode manifest: %w", err)
	}
	return &manifest, nil
}

func (s *Syncer) buildLocalHashes() (map[string]string, error) {
	entries, err := os.ReadDir(s.assetsDir)
	if err != nil {
		return nil, err
	}

	hashes := make(map[string]string)
	for _, e := range entries {
		if e.IsDir() || e.Name() == "manifest.json" {
			continue
		}
		path := filepath.Join(s.assetsDir, e.Name())
		h, err := fileSHA256(path)
		if err != nil {
			continue
		}
		hashes[e.Name()] = h
	}
	return hashes, nil
}

func (s *Syncer) downloadFile(ctx context.Context, filename string) error {
	url := s.hubURL + "/api/v1/assets/file/" + filename
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return err
	}
	req.Header.Set("Authorization", "Bearer "+s.apiKey)

	resp, err := s.httpClient.Do(req)
	if err != nil {
		s.dbg("downloadFile %s: HTTP request failed: %v", filename, err)
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		s.dbg("downloadFile %s: unexpected HTTP %d", filename, resp.StatusCode)
		return fmt.Errorf("HTTP %d for %s", resp.StatusCode, filename)
	}
	s.dbg("downloadFile %s: HTTP %d, content-length=%d", filename, resp.StatusCode, resp.ContentLength)

	// Atomic write: write to .tmp, rename
	dst := filepath.Join(s.assetsDir, filepath.Base(filename))
	tmp := dst + ".tmp"

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

	if _, err := io.Copy(out, resp.Body); 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)
}

func (s *Syncer) saveLocalManifest(manifest *HubManifest) {
	data, err := json.MarshalIndent(manifest, "", "  ")
	if err != nil {
		s.logger.Printf("[ERROR] [assets] Failed to save local manifest: %v", err)
		return
	}
	path := filepath.Join(s.assetsDir, "manifest.json")
	tmp := path + ".tmp"
	if err := os.WriteFile(tmp, data, 0644); err != nil {
		s.logger.Printf("[ERROR] [assets] Failed to save local manifest: %v", err)
		return
	}
	if err := os.Rename(tmp, path); err != nil {
		s.logger.Printf("[ERROR] [assets] Failed to save local manifest: %v", err)
	}
}

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
}
