package service

import (
	"archive/tar"
	"bytes"
	"compress/gzip"
	"context"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net"
	"net/http"
	"net/netip"
	"os"
	"path/filepath"
	"sync"
	"time"

	"github.com/oschwald/maxminddb-golang/v2"
	"github.com/pocket-id/pocket-id/backend/internal/utils"

	"github.com/pocket-id/pocket-id/backend/internal/common"
)

const maxTotalSize = 300 * 1024 * 1024 // 300 MB limit for total decompressed size

type GeoLiteService struct {
	httpClient     *http.Client
	disableUpdater bool
	mutex          sync.RWMutex
}

// NewGeoLiteService initializes a new GeoLiteService instance and starts a goroutine to update the GeoLite2 City database.
func NewGeoLiteService(httpClient *http.Client) *GeoLiteService {
	service := &GeoLiteService{
		httpClient: httpClient,
	}

	if common.EnvConfig.MaxMindLicenseKey == "" && common.EnvConfig.GeoLiteDBUrl == common.MaxMindGeoLiteCityUrl {
		// Warn the user, and disable the periodic updater
		slog.Warn("MAXMIND_LICENSE_KEY environment variable is empty: the GeoLite2 City database won't be updated")
		service.disableUpdater = true
	}

	return service
}

func (s *GeoLiteService) DisableUpdater() bool {
	return s.disableUpdater
}

// GetLocationByIP returns the country and city of the given IP address.
func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string, err error) {
	if ipAddress == "" {
		return "", "", nil
	}

	// Check the IP address against known private IP ranges
	if ip := net.ParseIP(ipAddress); ip != nil {
		if utils.IsLocalIPv6(ip) {
			return "Internal Network", "LAN", nil
		}
		if utils.IsTailscaleIP(ip) {
			return "Internal Network", "Tailscale", nil
		}
		if utils.IsPrivateIP(ip) {
			return "Internal Network", "LAN", nil
		}
		if utils.IsLocalhostIP(ip) {
			return "Internal Network", "localhost", nil
		}
	}

	addr, err := netip.ParseAddr(ipAddress)
	if err != nil {
		return "", "", fmt.Errorf("failed to parse IP address: %w", err)
	}

	// Race condition between reading and writing the database.
	s.mutex.RLock()
	defer s.mutex.RUnlock()

	db, err := maxminddb.Open(common.EnvConfig.GeoLiteDBPath)
	if err != nil {
		return "", "", err
	}
	defer db.Close()

	var record struct {
		City struct {
			Names map[string]string `maxminddb:"names"`
		} `maxminddb:"city"`
		Country struct {
			Names map[string]string `maxminddb:"names"`
		} `maxminddb:"country"`
	}

	err = db.Lookup(addr).Decode(&record)
	if err != nil {
		return "", "", err
	}

	return record.Country.Names["en"], record.City.Names["en"], nil
}

// UpdateDatabase checks the age of the database and updates it if it's older than 14 days.
func (s *GeoLiteService) UpdateDatabase(parentCtx context.Context) error {
	if s.isDatabaseUpToDate() {
		slog.Info("GeoLite2 City database is up-to-date")
		return nil
	}

	slog.Info("Updating GeoLite2 City database")
	downloadUrl := fmt.Sprintf(common.EnvConfig.GeoLiteDBUrl, common.EnvConfig.MaxMindLicenseKey)

	ctx, cancel := context.WithTimeout(parentCtx, 10*time.Minute)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadUrl, nil)
	if err != nil {
		return fmt.Errorf("failed to create request: %w", err)
	}

	resp, err := s.httpClient.Do(req)
	if err != nil {
		return fmt.Errorf("failed to download database: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("failed to download database, received HTTP %d", resp.StatusCode)
	}

	// Extract the database file directly to the target path
	err = s.extractDatabase(resp.Body)
	if err != nil {
		return fmt.Errorf("failed to extract database: %w", err)
	}

	slog.Info("GeoLite2 City database successfully updated.")
	return nil
}

// isDatabaseUpToDate checks if the database file is older than 14 days.
func (s *GeoLiteService) isDatabaseUpToDate() bool {
	info, err := os.Stat(common.EnvConfig.GeoLiteDBPath)
	if err != nil {
		// If the file doesn't exist, treat it as not up-to-date
		return false
	}
	return time.Since(info.ModTime()) < 14*24*time.Hour
}

// extractDatabase extracts the database file from the tar.gz archive directly to the target location.
func (s *GeoLiteService) extractDatabase(reader io.Reader) error {
	// Check for gzip magic number
	buf := make([]byte, 2)
	_, err := io.ReadFull(reader, buf)
	if err != nil {
		return fmt.Errorf("failed to read magic number: %w", err)
	}

	// Check if the file starts with the gzip magic number
	// Gosec returns false positive for "G602: slice index out of range"
	//nolint:gosec
	isGzip := buf[0] == 0x1f && buf[1] == 0x8b

	if !isGzip {
		// If not gzip, assume it's a regular database file
		return s.writeDatabaseFile(io.MultiReader(bytes.NewReader(buf), reader))
	}

	gzr, err := gzip.NewReader(io.MultiReader(bytes.NewReader(buf), reader))
	if err != nil {
		return fmt.Errorf("failed to create gzip reader: %w", err)
	}
	defer gzr.Close()

	tarReader := tar.NewReader(gzr)

	var totalSize int64

	// Iterate over the files in the tar archive
	for {
		header, err := tarReader.Next()
		if errors.Is(err, io.EOF) {
			break
		} else if err != nil {
			return fmt.Errorf("failed to read tar archive: %w", err)
		}

		// Check if the file is the GeoLite2-City.mmdb file
		if header.Typeflag == tar.TypeReg && filepath.Base(header.Name) == "GeoLite2-City.mmdb" {
			totalSize += header.Size
			if totalSize > maxTotalSize {
				return errors.New("total decompressed size exceeds maximum allowed limit")
			}

			// extract to a temporary file to avoid having a corrupted db in case of write failure.
			baseDir := filepath.Dir(common.EnvConfig.GeoLiteDBPath)
			tmpFile, err := os.CreateTemp(baseDir, "geolite.*.mmdb.tmp")
			if err != nil {
				return fmt.Errorf("failed to create temporary database file: %w", err)
			}
			tempName := tmpFile.Name()

			// Write the file contents directly to the target location
			if _, err := io.Copy(tmpFile, tarReader); err != nil { //nolint:gosec
				// if fails to write, then cleanup and throw an error
				tmpFile.Close()
				os.Remove(tempName)
				return fmt.Errorf("failed to write database file: %w", err)
			}
			tmpFile.Close()

			// ensure the database is not corrupted
			db, err := maxminddb.Open(tempName)
			if err != nil {
				// if fails to write, then cleanup and throw an error
				os.Remove(tempName)
				return fmt.Errorf("failed to open downloaded database file: %w", err)
			}
			db.Close()

			// ensure we lock the structure before we overwrite the database
			// to prevent race conditions between reading and writing the mmdb.
			s.mutex.Lock()
			// replace the old file with the new file
			err = os.Rename(tempName, common.EnvConfig.GeoLiteDBPath)
			s.mutex.Unlock()

			if err != nil {
				// if cannot overwrite via rename, then cleanup and throw an error
				os.Remove(tempName)
				return fmt.Errorf("failed to replace database file: %w", err)
			}
			return nil
		}
	}

	return errors.New("GeoLite2-City.mmdb not found in archive")
}

func (s *GeoLiteService) writeDatabaseFile(reader io.Reader) error {
	baseDir := filepath.Dir(common.EnvConfig.GeoLiteDBPath)
	tmpFile, err := os.CreateTemp(baseDir, "geolite.*.mmdb.tmp")
	if err != nil {
		return fmt.Errorf("failed to create temporary database file: %w", err)
	}
	defer tmpFile.Close()

	// Limit the amount we read to maxTotalSize.
	// We read one extra byte to detect if the source is larger than the limit.
	limitReader := io.LimitReader(reader, maxTotalSize+1)

	// Write the file contents directly to the temporary file
	written, err := io.Copy(tmpFile, limitReader)
	if err != nil {
		os.Remove(tmpFile.Name())
		return fmt.Errorf("failed to write database file: %w", err)
	}

	if written > maxTotalSize {
		os.Remove(tmpFile.Name())
		return errors.New("total database size exceeds maximum allowed limit")
	}

	// Validate the downloaded database file
	if db, err := maxminddb.Open(tmpFile.Name()); err == nil {
		db.Close()
	} else {
		os.Remove(tmpFile.Name())
		return fmt.Errorf("failed to open downloaded database file: %w", err)
	}

	// Ensure atomic replacement of the old database file
	s.mutex.Lock()
	err = os.Rename(tmpFile.Name(), common.EnvConfig.GeoLiteDBPath)
	s.mutex.Unlock()

	if err != nil {
		os.Remove(tmpFile.Name())
		return fmt.Errorf("failed to replace database file: %w", err)
	}
	return nil
}
