// Package database handles GeoIP database management and downloads.
package database

import (
	"context"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"os"
	"path/filepath"
	"time"

	"git.eeqj.de/sneak/ipapi/internal/config"
	"git.eeqj.de/sneak/ipapi/internal/state"
	"github.com/oschwald/geoip2-golang"
)

const (
	asnURL     = "https://git.io/GeoLite2-ASN.mmdb"
	cityURL    = "https://git.io/GeoLite2-City.mmdb"
	countryURL = "https://git.io/GeoLite2-Country.mmdb"

	asnFile     = "GeoLite2-ASN.mmdb"
	cityFile    = "GeoLite2-City.mmdb"
	countryFile = "GeoLite2-Country.mmdb"

	downloadTimeout = 30 * time.Second
	updateInterval  = 7 * 24 * time.Hour // 1 week

	defaultDirPermissions  = 0750
	defaultFilePermissions = 0640
)

// Manager handles GeoIP database operations.
type Manager struct {
	config     *config.Config
	logger     *slog.Logger
	state      *state.Manager
	dataDir    string
	asnDB      *geoip2.Reader
	cityDB     *geoip2.Reader
	countryDB  *geoip2.Reader
	httpClient *http.Client
}

// New creates a new database manager.
func New(cfg *config.Config, logger *slog.Logger, state *state.Manager) (*Manager, error) {
	dataDir := filepath.Join(cfg.StateDir, "databases")

	return &Manager{
		config:  cfg,
		logger:  logger,
		state:   state,
		dataDir: dataDir,
		httpClient: &http.Client{
			Timeout: downloadTimeout,
		},
	}, nil
}

// EnsureDatabases downloads missing or outdated databases.
func (m *Manager) EnsureDatabases(ctx context.Context) error {
	// Create data directory if it doesn't exist
	if err := os.MkdirAll(m.dataDir, defaultDirPermissions); err != nil {
		return fmt.Errorf("failed to create data directory: %w", err)
	}

	// Load current state
	currentState, err := m.state.Load()
	if err != nil {
		return fmt.Errorf("failed to load state: %w", err)
	}

	// Check and download ASN database
	asnPath := filepath.Join(m.dataDir, asnFile)
	if needsUpdate(asnPath, currentState.LastASNDownload) {
		m.logger.Info("Downloading ASN database")
		if err := m.downloadFile(ctx, asnURL, asnPath); err != nil {
			return fmt.Errorf("failed to download ASN database: %w", err)
		}
		if err := m.state.UpdateASNDownloadTime(); err != nil {
			return fmt.Errorf("failed to update ASN download time: %w", err)
		}
	}

	// Check and download City database
	cityPath := filepath.Join(m.dataDir, cityFile)
	if needsUpdate(cityPath, currentState.LastCityDownload) {
		m.logger.Info("Downloading City database")
		if err := m.downloadFile(ctx, cityURL, cityPath); err != nil {
			return fmt.Errorf("failed to download City database: %w", err)
		}
		if err := m.state.UpdateCityDownloadTime(); err != nil {
			return fmt.Errorf("failed to update City download time: %w", err)
		}
	}

	// Check and download Country database
	countryPath := filepath.Join(m.dataDir, countryFile)
	if needsUpdate(countryPath, currentState.LastCountryDownload) {
		m.logger.Info("Downloading Country database")
		if err := m.downloadFile(ctx, countryURL, countryPath); err != nil {
			return fmt.Errorf("failed to download Country database: %w", err)
		}
		if err := m.state.UpdateCountryDownloadTime(); err != nil {
			return fmt.Errorf("failed to update Country download time: %w", err)
		}
	}

	// Open databases
	if err := m.openDatabases(); err != nil {
		return fmt.Errorf("failed to open databases: %w", err)
	}

	m.logger.Info("All databases ready")

	return nil
}

func (m *Manager) downloadFile(ctx context.Context, url, destPath string) error {
	req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
	if err != nil {
		return fmt.Errorf("failed to create request: %w", err)
	}

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

	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
	}

	// Write to temporary file first
	tmpPath := destPath + ".tmp"
	tmpFile, err := os.Create(tmpPath) //nolint:gosec // temporary file with predictable name is ok
	if err != nil {
		return fmt.Errorf("failed to create temp file: %w", err)
	}
	defer func() { _ = os.Remove(tmpPath) }()

	_, err = io.Copy(tmpFile, resp.Body)
	if err2 := tmpFile.Close(); err2 != nil {
		return fmt.Errorf("failed to close temp file: %w", err2)
	}
	if err != nil {
		return fmt.Errorf("failed to write file: %w", err)
	}

	// Move to final location
	if err := os.Rename(tmpPath, destPath); err != nil {
		return fmt.Errorf("failed to move file: %w", err)
	}

	m.logger.Debug("Downloaded file", "url", url, "path", destPath)

	return nil
}

func (m *Manager) openDatabases() error {
	var err error

	// Open ASN database
	asnPath := filepath.Join(m.dataDir, asnFile)
	m.asnDB, err = geoip2.Open(asnPath)
	if err != nil {
		return fmt.Errorf("failed to open ASN database: %w", err)
	}

	// Open City database
	cityPath := filepath.Join(m.dataDir, cityFile)
	m.cityDB, err = geoip2.Open(cityPath)
	if err != nil {
		_ = m.asnDB.Close()

		return fmt.Errorf("failed to open City database: %w", err)
	}

	// Open Country database
	countryPath := filepath.Join(m.dataDir, countryFile)
	m.countryDB, err = geoip2.Open(countryPath)
	if err != nil {
		_ = m.asnDB.Close()
		_ = m.cityDB.Close()

		return fmt.Errorf("failed to open Country database: %w", err)
	}

	return nil
}

// Close closes all open databases.
func (m *Manager) Close() error {
	if m.asnDB != nil {
		_ = m.asnDB.Close()
	}
	if m.cityDB != nil {
		_ = m.cityDB.Close()
	}
	if m.countryDB != nil {
		_ = m.countryDB.Close()
	}

	return nil
}

// GetASNDB returns the ASN database reader.
func (m *Manager) GetASNDB() *geoip2.Reader {
	return m.asnDB
}

// GetCityDB returns the City database reader.
func (m *Manager) GetCityDB() *geoip2.Reader {
	return m.cityDB
}

// GetCountryDB returns the Country database reader.
func (m *Manager) GetCountryDB() *geoip2.Reader {
	return m.countryDB
}

func needsUpdate(filePath string, lastDownload time.Time) bool {
	// Check if file exists
	if _, err := os.Stat(filePath); os.IsNotExist(err) {
		return true
	}

	// Check if it's time to update
	if time.Since(lastDownload) > updateInterval {
		return true
	}

	return false
}
