// Copyright 2021 Flamego. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package session

import (
	"context"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"time"

	"github.com/pkg/errors"
)

var _ Store = (*fileStore)(nil)

// fileStore is a file implementation of the session store.
type fileStore struct {
	nowFunc  func() time.Time // The function to return the current time
	lifetime time.Duration    // The duration to have no access to a session before being recycled
	rootDir  string           // The root directory of file session items stored on the local file system
	encoder  Encoder          // The encoder to encode the session data before saving
	decoder  Decoder          // The decoder to decode binary to session data after reading
}

// newFileStore returns a new file session store based on given configuration.
func newFileStore(cfg FileConfig) *fileStore {
	return &fileStore{
		nowFunc:  cfg.nowFunc,
		lifetime: cfg.Lifetime,
		rootDir:  cfg.RootDir,
		encoder:  cfg.Encoder,
		decoder:  cfg.Decoder,
	}
}

// filename returns the computed file name with given sid.
func (s *fileStore) filename(sid string) string {
	return filepath.Join(s.rootDir, string(sid[0]), string(sid[1]), sid)
}

// isFile returns true if given path exists as a file (i.e. not a directory).
func isFile(path string) bool {
	f, e := os.Stat(path)
	if e != nil {
		return false
	}
	return !f.IsDir()
}

func (s *fileStore) Exist(_ context.Context, sid string) bool {
	if len(sid) < minimumSIDLength {
		return false
	}
	return isFile(s.filename(sid))
}

func (s *fileStore) Read(ctx context.Context, sid string) (Session, error) {
	if len(sid) < minimumSIDLength {
		return nil, ErrMinimumSIDLength
	}

	filename := s.filename(sid)
	if !isFile(filename) {
		err := os.MkdirAll(filepath.Dir(filename), 0700)
		if err != nil {
			return nil, errors.Wrap(err, "create parent directory")
		}

		return NewBaseSession(sid, s.encoder), nil
	}

	// Discard existing data if it's expired
	fi, err := os.Stat(filename)
	if err != nil {
		return nil, errors.Wrap(err, "stat file")
	}
	if !fi.ModTime().Add(s.lifetime).After(s.nowFunc()) {
		return NewBaseSession(sid, s.encoder), nil
	}

	binary, err := os.ReadFile(filename)
	if err != nil {
		return nil, errors.Wrap(err, "read file")
	}

	data, err := s.decoder(binary)
	if err != nil {
		return nil, errors.Wrap(err, "decode")
	}

	sess := NewBaseSession(sid, s.encoder)
	sess.SetData(data)
	return sess, nil
}

func (s *fileStore) Destroy(_ context.Context, sid string) error {
	if len(sid) < minimumSIDLength {
		return nil
	}
	return os.Remove(s.filename(sid))
}

func (s *fileStore) Save(_ context.Context, sess Session) error {
	if len(sess.ID()) < minimumSIDLength {
		return ErrMinimumSIDLength
	}

	binary, err := sess.Encode()
	if err != nil {
		return errors.Wrap(err, "encode")
	}

	err = os.WriteFile(s.filename(sess.ID()), binary, 0600)
	if err != nil {
		return errors.Wrap(err, "write file")
	}
	return nil
}

func (s *fileStore) GC(ctx context.Context) error {
	err := filepath.WalkDir(s.rootDir, func(path string, d fs.DirEntry, err error) error {
		select {
		case <-ctx.Done():
			return ctx.Err()
		default:
		}

		if err != nil {
			return err
		}
		if d.IsDir() {
			return nil
		}

		fi, err := d.Info()
		if err != nil {
			return err
		}
		if fi.ModTime().Add(s.lifetime).After(s.nowFunc()) {
			return nil
		}
		return os.Remove(path)
	})
	if err != nil && err != ctx.Err() {
		return err
	}
	return nil
}

// FileConfig contains options for the file session store.
type FileConfig struct {
	// For tests only
	nowFunc func() time.Time

	// Lifetime is the duration to have no access to a session before being
	// recycled. Default is 3600 seconds.
	Lifetime time.Duration
	// RootDir is the root directory of file session items stored on the local file
	// system. Default is "sessions".
	RootDir string
	// Encoder is the encoder to encode session data. Default is GobEncoder.
	Encoder Encoder
	// Decoder is the decoder to decode session data. Default is GobDecoder.
	Decoder Decoder
}

// FileIniter returns the Initer for the file session store.
func FileIniter() Initer {
	return func(ctx context.Context, args ...interface{}) (Store, error) {
		var cfg *FileConfig
		for i := range args {
			switch v := args[i].(type) {
			case FileConfig:
				cfg = &v
			}
		}

		if cfg == nil {
			return nil, fmt.Errorf("config object with the type '%T' not found", FileConfig{})
		}
		if cfg.nowFunc == nil {
			cfg.nowFunc = time.Now
		}
		if cfg.Lifetime.Seconds() < 1 {
			cfg.Lifetime = 3600 * time.Second
		}
		if cfg.RootDir == "" {
			cfg.RootDir = "sessions"
		}
		if cfg.Encoder == nil {
			cfg.Encoder = GobEncoder
		}
		if cfg.Decoder == nil {
			cfg.Decoder = GobDecoder
		}

		return newFileStore(*cfg), nil
	}
}
