package hotnews

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/url"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"time"

	"golang.org/x/sync/singleflight"

	"github.com/tidbcloud/AgentTeam/internal/extension/runner"
)

const (
	feedCacheTTL        = 5 * time.Minute
	feedCacheTempPrefix = ".hotnews-cache-"
)

var productionFeedCache = newProductionFeedCache()

// feedCache is a disk-only, best-effort cache. Its singleflight group only
// coalesces active misses; completed payloads live exclusively in fixed files.
type feedCache struct {
	root             string
	ownedDirectories []string
	ttl              time.Duration
	now              func() time.Time
	sweep            sync.Once
	flights          singleflight.Group
}

func newProductionFeedCache() *feedCache {
	cacheBase := filepath.Join(os.TempDir(), "agentteam-extension-cache")
	providerDirectory := filepath.Join(cacheBase, "hotnews")
	root := filepath.Join(providerDirectory, "v1")
	return newFeedCache(
		root,
		[]string{cacheBase, providerDirectory, root},
		feedCacheTTL,
		time.Now,
	)
}

func newFeedCache(
	root string,
	ownedDirectories []string,
	ttl time.Duration,
	now func() time.Time,
) *feedCache {
	if now == nil {
		now = time.Now
	}
	return &feedCache{
		root:             root,
		ownedDirectories: append([]string(nil), ownedDirectories...),
		ttl:              ttl,
		now:              now,
	}
}

// get returns a fully validated feed from disk or the fixed upstream. Cache
// failures are deliberately converted to misses so availability still follows
// the upstream client rather than the local filesystem.
func (cache *feedCache) get(
	ctx context.Context,
	dispatched *runner.RESTClient,
	credentials runner.Credentials,
	catalog platform,
) (json.RawMessage, error) {
	if err := ctx.Err(); err != nil {
		return nil, err
	}
	if cached, ok := cache.read(catalog); ok {
		if err := ctx.Err(); err != nil {
			return nil, err
		}
		return append(json.RawMessage(nil), cached...), nil
	}
	if err := ctx.Err(); err != nil {
		return nil, err
	}

	sharedContext := context.WithoutCancel(ctx)
	resultChannel := cache.flights.DoChan(catalog.id, func() (any, error) {
		if cached, ok := cache.read(catalog); ok {
			return cached, nil
		}
		raw, err := dispatched.Call(sharedContext, credentials, runner.RESTRequest{
			Path:  "/s",
			Query: url.Values{"id": []string{catalog.id}, "latest": []string{""}},
		})
		if err != nil {
			return nil, err
		}
		validated, err := passthroughFeed(raw, catalog)
		if err != nil {
			return nil, err
		}
		_ = cache.write(catalog, validated)
		return validated, nil
	})
	var result singleflight.Result
	select {
	case <-ctx.Done():
		return nil, ctx.Err()
	case result = <-resultChannel:
		if err := ctx.Err(); err != nil {
			return nil, err
		}
	}
	if result.Err != nil {
		return nil, result.Err
	}
	feed, ok := result.Val.(json.RawMessage)
	if !ok {
		return nil, runner.Errorf(
			runner.ErrorUpstreamFailure, "platform feed was unavailable", nil)
	}
	return append(json.RawMessage(nil), feed...), nil
}

func (cache *feedCache) read(catalog platform) (json.RawMessage, bool) {
	if cache == nil {
		return nil, false
	}
	var cached json.RawMessage
	var hit bool
	if err := cache.withRootLock(func() error {
		cache.sweep.Do(cache.sweepExpiredLocked)
		cached, hit = cache.readLocked(catalog)
		return nil
	}); err != nil {
		return nil, false
	}
	return cached, hit
}

func (cache *feedCache) readLocked(catalog platform) (json.RawMessage, bool) {
	rootInfo, err := os.Lstat(cache.root)
	if err != nil || !rootInfo.IsDir() ||
		rootInfo.Mode()&os.ModeSymlink != 0 {
		return nil, false
	}
	path := filepath.Join(cache.root, catalog.id+".json")
	initialInfo, err := os.Lstat(path)
	if err != nil {
		return nil, false
	}
	if !initialInfo.Mode().IsRegular() {
		removeCachePath(path)
		return nil, false
	}
	if cache.expired(initialInfo.ModTime()) ||
		initialInfo.Size() > runner.MaxResponseBytes {
		removeCachePath(path)
		return nil, false
	}

	file, err := os.Open(path)
	if err != nil {
		return nil, false
	}
	openedInfo, statErr := file.Stat()
	if statErr != nil || !openedInfo.Mode().IsRegular() ||
		!os.SameFile(initialInfo, openedInfo) {
		_ = file.Close()
		removeCachePath(path)
		return nil, false
	}
	if err := file.Chmod(0o600); err != nil {
		_ = file.Close()
		removeCachePath(path)
		return nil, false
	}
	securedInfo, statErr := file.Stat()
	if statErr != nil || !securedInfo.Mode().IsRegular() ||
		!os.SameFile(openedInfo, securedInfo) ||
		securedInfo.Mode().Perm() != 0o600 {
		_ = file.Close()
		removeCachePath(path)
		return nil, false
	}
	payload, readErr := io.ReadAll(io.LimitReader(file, runner.MaxResponseBytes+1))
	closeErr := file.Close()
	if readErr != nil || closeErr != nil ||
		len(payload) > runner.MaxResponseBytes {
		removeCachePath(path)
		return nil, false
	}

	finalInfo, err := os.Lstat(path)
	if err != nil || !finalInfo.Mode().IsRegular() ||
		!os.SameFile(securedInfo, finalInfo) ||
		finalInfo.Mode().Perm() != 0o600 ||
		cache.expired(finalInfo.ModTime()) {
		removeCachePath(path)
		return nil, false
	}
	validated, err := passthroughFeed(json.RawMessage(payload), catalog)
	if err != nil {
		removeCachePath(path)
		return nil, false
	}
	return validated, true
}

func (cache *feedCache) write(catalog platform, payload json.RawMessage) error {
	if cache == nil {
		return fmt.Errorf("feed cache is unavailable")
	}
	if len(payload) > runner.MaxResponseBytes {
		return fmt.Errorf("feed cache payload exceeded the size budget")
	}
	return cache.withRootLock(func() error {
		return cache.writeLocked(catalog, payload)
	})
}

func (cache *feedCache) writeLocked(
	catalog platform,
	payload json.RawMessage,
) error {
	temporary, err := os.CreateTemp(cache.root, feedCacheTempPrefix)
	if err != nil {
		return err
	}
	temporaryPath := temporary.Name()
	closed := false
	defer func() {
		if !closed {
			_ = temporary.Close()
		}
		_ = os.Remove(temporaryPath)
	}()

	if err := temporary.Chmod(0o600); err != nil {
		return err
	}
	written, err := io.Copy(
		temporary,
		io.LimitReader(bytes.NewReader(payload), runner.MaxResponseBytes+1),
	)
	if err != nil {
		return err
	}
	if written != int64(len(payload)) {
		return fmt.Errorf("feed cache payload was only partially written")
	}
	if err := temporary.Sync(); err != nil {
		return err
	}
	now := cache.now()
	if err := os.Chtimes(temporaryPath, now, now); err != nil {
		return err
	}
	if err := temporary.Close(); err != nil {
		closed = true
		return err
	}
	closed = true
	if err := os.Rename(
		temporaryPath,
		filepath.Join(cache.root, catalog.id+".json"),
	); err != nil {
		return err
	}
	return nil
}

func (cache *feedCache) withRootLock(action func() error) (err error) {
	if cache == nil {
		return fmt.Errorf("feed cache is unavailable")
	}
	if cacheRootLockUnsupported != nil {
		return cacheRootLockUnsupported
	}
	if err := cache.ensureOwnedDirectories(); err != nil {
		return err
	}
	initialInfo, err := os.Lstat(cache.root)
	if err != nil || !initialInfo.IsDir() ||
		initialInfo.Mode()&os.ModeSymlink != 0 ||
		initialInfo.Mode().Perm() != 0o700 {
		return fmt.Errorf("feed cache root is not a secured directory")
	}
	root, err := os.Open(cache.root)
	if err != nil {
		return err
	}
	openedInfo, statErr := root.Stat()
	if statErr != nil || !openedInfo.IsDir() ||
		!os.SameFile(initialInfo, openedInfo) ||
		openedInfo.Mode().Perm() != 0o700 {
		_ = root.Close()
		return fmt.Errorf("feed cache root changed while opening")
	}
	unlock, err := acquireCacheRootLock(root)
	if err != nil {
		_ = root.Close()
		return err
	}
	defer func() {
		unlockErr := unlock()
		closeErr := root.Close()
		if err == nil && unlockErr != nil {
			err = unlockErr
		}
		if err == nil && closeErr != nil {
			err = closeErr
		}
	}()

	lockedInfo, statErr := os.Lstat(cache.root)
	if statErr != nil || !lockedInfo.IsDir() ||
		lockedInfo.Mode()&os.ModeSymlink != 0 ||
		!os.SameFile(openedInfo, lockedInfo) ||
		lockedInfo.Mode().Perm() != 0o700 {
		return fmt.Errorf("feed cache root changed while locking")
	}
	return action()
}

func (cache *feedCache) ensureOwnedDirectories() error {
	if cache == nil {
		return fmt.Errorf("feed cache is unavailable")
	}
	if len(cache.ownedDirectories) == 0 ||
		filepath.Clean(cache.ownedDirectories[len(cache.ownedDirectories)-1]) !=
			filepath.Clean(cache.root) {
		return fmt.Errorf("feed cache owned directories do not end at the root")
	}
	for _, directory := range cache.ownedDirectories {
		if err := ensureOwnedDirectory(directory); err != nil {
			return err
		}
	}
	return nil
}

func ensureOwnedDirectory(path string) error {
	if err := os.Mkdir(path, 0o700); err != nil && !os.IsExist(err) {
		return err
	}
	initialInfo, err := os.Lstat(path)
	if err != nil {
		return err
	}
	if !initialInfo.IsDir() || initialInfo.Mode()&os.ModeSymlink != 0 {
		return fmt.Errorf("feed cache owned path %q is not a directory", path)
	}

	directory, err := os.Open(path)
	if err != nil {
		return err
	}
	openedInfo, statErr := directory.Stat()
	if statErr != nil || !openedInfo.IsDir() ||
		!os.SameFile(initialInfo, openedInfo) {
		_ = directory.Close()
		return fmt.Errorf("feed cache owned directory %q changed while opening", path)
	}
	if err := directory.Chmod(0o700); err != nil {
		_ = directory.Close()
		return err
	}
	securedInfo, statErr := directory.Stat()
	if statErr != nil || !securedInfo.IsDir() ||
		!os.SameFile(openedInfo, securedInfo) ||
		securedInfo.Mode().Perm() != 0o700 {
		_ = directory.Close()
		return fmt.Errorf("feed cache owned directory %q was not secured", path)
	}
	if err := directory.Close(); err != nil {
		return err
	}

	finalInfo, err := os.Lstat(path)
	if err != nil || !finalInfo.IsDir() ||
		finalInfo.Mode()&os.ModeSymlink != 0 ||
		!os.SameFile(securedInfo, finalInfo) ||
		finalInfo.Mode().Perm() != 0o700 {
		return fmt.Errorf("feed cache owned directory %q changed while securing", path)
	}
	return nil
}

// sweepExpiredLocked is intentionally closed over catalog filenames and the
// cache's own temporary prefix. The caller holds the cache-root lock, and
// unrecognized files remain untouched.
func (cache *feedCache) sweepExpiredLocked() {
	if cache == nil {
		return
	}
	rootInfo, err := os.Lstat(cache.root)
	if err != nil || !rootInfo.IsDir() ||
		rootInfo.Mode()&os.ModeSymlink != 0 {
		return
	}
	entries, err := os.ReadDir(cache.root)
	if err != nil {
		return
	}
	for _, entry := range entries {
		name := entry.Name()
		if !knownFeedCacheFile(name) &&
			!strings.HasPrefix(name, feedCacheTempPrefix) {
			continue
		}
		path := filepath.Join(cache.root, name)
		info, err := os.Lstat(path)
		if err != nil || !cache.expired(info.ModTime()) {
			continue
		}
		removeCachePath(path)
	}
}

func (cache *feedCache) expired(modTime time.Time) bool {
	return !cache.now().Before(modTime.Add(cache.ttl))
}

func knownFeedCacheFile(name string) bool {
	for _, entry := range platforms {
		if name == entry.id+".json" {
			return true
		}
	}
	return false
}

func removeCachePath(path string) {
	_ = os.Remove(path)
}
