package certificate

import (
	"bytes"
	"context"
	"crypto/x509"
	"io/fs"
	"os"
	"path/filepath"
	"strings"
	"sync"

	"github.com/sagernet/fswatch"
	"github.com/sagernet/sing-box/adapter"
	C "github.com/sagernet/sing-box/constant"
	"github.com/sagernet/sing-box/option"
	E "github.com/sagernet/sing/common/exceptions"
	"github.com/sagernet/sing/common/logger"
	"github.com/sagernet/sing/service/filemanager"
)

var _ adapter.CertificateStore = (*Store)(nil)

type Store struct {
	ctx                       context.Context
	access                    sync.RWMutex
	storeType                 string
	systemPool                *x509.CertPool
	currentPool               *x509.CertPool
	certificate               string
	certificatePaths          []string
	certificateDirectoryPaths []string
	watcher                   *fswatch.Watcher
	//nolint:unused // populated only on darwin && cgo via the storePlatform embed.
	platform storePlatform
}

func NewStore(ctx context.Context, logger logger.Logger, options option.CertificateOptions) (*Store, error) {
	storeType := options.Store
	if storeType == "" {
		storeType = C.CertificateStoreSystem
	}
	var systemPool *x509.CertPool
	switch storeType {
	case C.CertificateStoreSystem:
		systemPool = x509.NewCertPool()
		var systemValid bool
		for _, certificate := range systemCertificates() {
			systemPool.AddCert(certificate)
			systemValid = true
		}
		if !systemValid {
			certPool, err := x509.SystemCertPool()
			if err != nil {
				return nil, err
			}
			systemPool = certPool
		}
	case C.CertificateStoreMozilla, C.CertificateStoreChrome:
	case C.CertificateStoreNone:
	default:
		return nil, E.New("unknown certificate store: ", options.Store)
	}
	store := &Store{
		ctx:                       ctx,
		storeType:                 storeType,
		systemPool:                systemPool,
		certificate:               strings.Join(options.Certificate, "\n"),
		certificatePaths:          options.CertificatePath,
		certificateDirectoryPaths: options.CertificateDirectoryPath,
	}
	var watchPaths []string
	for _, target := range options.CertificatePath {
		watchPaths = append(watchPaths, target)
	}
	for _, target := range options.CertificateDirectoryPath {
		watchPaths = append(watchPaths, target)
	}
	if len(watchPaths) > 0 {
		watcher, err := fswatch.NewWatcher(fswatch.Options{
			Path:   watchPaths,
			Logger: logger,
			Callback: func(_ string) {
				err := store.update()
				if err != nil {
					logger.Error(E.Cause(err, "reload certificates"))
				}
			},
		})
		if err != nil {
			return nil, E.Cause(err, "fswatch: create fsnotify watcher")
		}
		store.watcher = watcher
	}
	err := store.update()
	if err != nil {
		return nil, E.Cause(err, "initializing certificate store")
	}
	return store, nil
}

func (s *Store) Name() string {
	return "certificate"
}

func (s *Store) Start(stage adapter.StartStage) error {
	if stage != adapter.StartStateStart {
		return nil
	}
	if s.watcher != nil {
		return s.watcher.Start()
	}
	return nil
}

func (s *Store) Close() error {
	watcher := s.watcher
	s.watcher = nil

	var closeErr error
	if watcher != nil {
		closeErr = watcher.Close()
	}
	platformErr := s.closePlatform()
	if platformErr != nil {
		closeErr = platformErr
	}
	return closeErr
}

func (s *Store) Pool() *x509.CertPool {
	s.access.RLock()
	defer s.access.RUnlock()
	return s.currentPool
}

func (s *Store) StoreKind() string {
	return s.storeType
}

func (s *Store) ExclusiveAnchors() bool {
	return s.storeType != C.CertificateStoreSystem
}

func (s *Store) update() error {
	currentPool, err := s.newBasePool()
	if err != nil {
		return err
	}
	pemBuffer := new(bytes.Buffer)
	switch s.storeType {
	case C.CertificateStoreMozilla:
		pemContent := mozillaIncludedPEM()
		if !currentPool.AppendCertsFromPEM([]byte(pemContent)) {
			return E.New("invalid Mozilla included certificate PEM")
		}
		appendPEMBlock(pemBuffer, string(pemContent))
	case C.CertificateStoreChrome:
		pemContent := chromeIncludedPEM()
		if !currentPool.AppendCertsFromPEM([]byte(pemContent)) {
			return E.New("invalid Chrome included certificate PEM")
		}
		appendPEMBlock(pemBuffer, string(pemContent))
	}
	if s.certificate != "" {
		if !currentPool.AppendCertsFromPEM([]byte(s.certificate)) {
			return E.New("invalid certificate PEM strings")
		}
		appendPEMBlock(pemBuffer, s.certificate)
	}
	for _, path := range s.certificatePaths {
		pemContent, err := filemanager.ReadFile(s.ctx, path)
		if err != nil {
			return err
		}
		if !currentPool.AppendCertsFromPEM(pemContent) {
			return E.New("invalid certificate PEM file: ", path)
		}
		appendPEMBlock(pemBuffer, string(pemContent))
	}
	var firstErr error
	for _, directoryPath := range s.certificateDirectoryPaths {
		directoryEntries, err := readUniqueDirectoryEntries(s.ctx, directoryPath)
		if err != nil {
			if firstErr == nil && !os.IsNotExist(err) {
				firstErr = E.Cause(err, "invalid certificate directory: ", directoryPath)
			}
			continue
		}
		for _, directoryEntry := range directoryEntries {
			pemContent, err := filemanager.ReadFile(s.ctx, filepath.Join(directoryPath, directoryEntry.Name()))
			if err == nil && currentPool.AppendCertsFromPEM(pemContent) {
				appendPEMBlock(pemBuffer, string(pemContent))
			}
		}
	}
	if firstErr != nil {
		return firstErr
	}
	s.access.Lock()
	defer s.access.Unlock()
	s.currentPool = currentPool
	return s.updatePlatformLocked(pemBuffer.Bytes())
}

func appendPEMBlock(buffer *bytes.Buffer, block string) {
	existing := buffer.Bytes()
	if len(existing) > 0 && existing[len(existing)-1] != '\n' {
		buffer.WriteByte('\n')
	}
	buffer.WriteString(block)
}

func (s *Store) newBasePool() (*x509.CertPool, error) {
	switch s.storeType {
	case C.CertificateStoreSystem:
		if s.systemPool == nil {
			return x509.NewCertPool(), nil
		}
		return s.systemPool.Clone(), nil
	case C.CertificateStoreMozilla, C.CertificateStoreChrome:
		return x509.NewCertPool(), nil
	case C.CertificateStoreNone:
		return x509.NewCertPool(), nil
	default:
		return nil, E.New("unknown certificate store: ", s.storeType)
	}
}

func readUniqueDirectoryEntries(ctx context.Context, dir string) ([]fs.DirEntry, error) {
	files, err := filemanager.ReadDir(ctx, dir)
	if err != nil {
		return nil, err
	}
	uniq := files[:0]
	for _, f := range files {
		if !isSameDirSymlink(f, dir) {
			uniq = append(uniq, f)
		}
	}
	return uniq, nil
}

func isSameDirSymlink(f fs.DirEntry, dir string) bool {
	if f.Type()&fs.ModeSymlink == 0 {
		return false
	}
	target, err := os.Readlink(filepath.Join(dir, f.Name()))
	return err == nil && !strings.Contains(target, "/")
}
