// SPDX-FileCopyrightText: 2022 Free Mobile
// SPDX-License-Identifier: AGPL-3.0-only

// Package geoip provides ASN and country for GeoIP addresses.
package geoip

import (
	"errors"
	"fmt"
	"path/filepath"
	"sync"
	"time"

	"github.com/fsnotify/fsnotify"
	"gopkg.in/tomb.v2"

	"akvorado/common/daemon"
	"akvorado/common/reporter"
)

// Component represents the GeoIP component.
type Component struct {
	r      *reporter.Reporter
	d      *Dependencies
	t      tomb.Tomb
	config Configuration

	db struct {
		geo  map[string]geoDatabase
		asn  map[string]geoDatabase
		lock sync.RWMutex
	}

	metrics struct {
		databaseRefresh *reporter.CounterVec
	}

	onOpenChan        chan struct{}   // input notification channel
	onOpenSubscribers []chan struct{} // output notification channels
	notifyDone        sync.WaitGroup  // do not close notification channel during fanout
	notifyLock        sync.RWMutex
}

// Dependencies define the dependencies of the GeoIP component.
type Dependencies struct {
	Daemon daemon.Component
}

// New creates a new GeoIP component.
func New(r *reporter.Reporter, configuration Configuration, dependencies Dependencies) (*Component, error) {
	c := Component{
		r:                 r,
		d:                 &dependencies,
		config:            configuration,
		onOpenChan:        make(chan struct{}),
		onOpenSubscribers: []chan struct{}{},
	}
	c.db.geo = make(map[string]geoDatabase)
	c.db.asn = make(map[string]geoDatabase)

	for i, path := range c.config.GeoDatabase {
		c.config.GeoDatabase[i] = filepath.Clean(path)
	}
	for i, path := range c.config.ASNDatabase {
		c.config.ASNDatabase[i] = filepath.Clean(path)
	}
	c.d.Daemon.Track(&c.t, "orchestrator/geoip")
	c.metrics.databaseRefresh = c.r.CounterVec(
		reporter.CounterOpts{
			Name: "db_refresh_total",
			Help: "Refresh event for a GeoIP database.",
		},
		[]string{"database"},
	)
	return &c, nil
}

// notifySubscribers notify all subscribers.
func (c *Component) notifySubscribers() {
	c.notifyLock.RLock()
	defer c.notifyLock.RUnlock()
	for _, subChan := range c.onOpenSubscribers {
		select {
		case <-c.t.Dying():
			return
		case subChan <- struct{}{}:
		default:
		}
	}
}

// Start starts the GeoIP component.
func (c *Component) Start() error {
	if len(c.config.GeoDatabase) == 0 && len(c.config.ASNDatabase) == 0 {
		c.r.Warn().Msg("skipping GeoIP component: no database specified")
	}
	c.r.Info().Msg("starting GeoIP component")

	c.t.Go(func() error {
		for range c.onOpenChan {
			c.notifySubscribers()
		}
		for _, c := range c.onOpenSubscribers {
			close(c)
		}
		return nil
	})

	for _, path := range c.config.GeoDatabase {
		if err := c.openDatabase("geo", path, false); err != nil && !c.config.Optional {
			return err
		}
	}
	for _, path := range c.config.ASNDatabase {
		if err := c.openDatabase("asn", path, false); err != nil && !c.config.Optional {
			return err
		}
	}

	// Watch for modifications
	watcher, err := fsnotify.NewWatcher()
	if err != nil {
		c.r.Err(err).Msg("cannot setup watcher for GeoIP databases")
		return fmt.Errorf("cannot setup watcher: %w", err)
	}
	dirs := map[string]bool{}
	for _, path := range c.config.GeoDatabase {
		dirs[filepath.Dir(path)] = true
	}
	for _, path := range c.config.ASNDatabase {
		dirs[filepath.Dir(path)] = true
	}
	for k := range dirs {
		if err := watcher.Add(k); err != nil {
			c.r.Err(err).Msg("cannot watch database directory")
			return fmt.Errorf("cannot watch database directory: %w", err)
		}
	}
	c.t.Go(func() error {
		errLogger := c.r.Sample(reporter.BurstSampler(10*time.Second, 1))
		defer watcher.Close()

		for {
			// Watch both for errors and events in the
			// same goroutine. fsnotify's FAQ says this is
			// not a good idea.
			select {
			case <-c.t.Dying():
				return nil
			case err, ok := <-watcher.Errors:
				if !ok {
					return errors.New("file watcher died")
				}
				errLogger.Err(err).Msg("error from watcher")
			case event, ok := <-watcher.Events:
				if !ok {
					return errors.New("file watcher died")
				}
				if !event.Has(fsnotify.Write) && !event.Has(fsnotify.Create) {
					continue
				}
				c.r.Debug().Msgf("event %s on file %s", event, event.Name)
				for _, path := range c.config.GeoDatabase {
					if filepath.Clean(event.Name) == path {
						c.openDatabase("geo", path, true)
						break
					}
				}
				for _, path := range c.config.ASNDatabase {
					if filepath.Clean(event.Name) == path {
						c.openDatabase("asn", path, true)
						break
					}
				}
			}
		}
	})

	return nil
}

// Stop stops the GeoIP component.
func (c *Component) Stop() error {
	c.r.Info().Msg("stopping GeoIP component")
	c.db.lock.RLock()
	c.r.Debug().Msg("closing database files")

	for _, db := range c.db.geo {
		if db != nil {
			db.Close()
		}
	}
	for _, db := range c.db.asn {
		if db != nil {
			db.Close()
		}
	}
	c.db.lock.RUnlock()
	c.r.Debug().Msg("stopping child threads")
	c.t.Kill(nil)
	c.r.Debug().Msg("waiting for notification to be sent")
	c.notifyDone.Wait()
	close(c.onOpenChan)
	defer c.r.Info().Msg("GeoIP component stopped")
	return c.t.Wait()
}

// Notify returns a notification channel to be used to receive notification on
// updates.
func (c *Component) Notify() chan struct{} {
	notifyChan := make(chan struct{}, 1)
	c.notifyLock.Lock()
	c.onOpenSubscribers = append(c.onOpenSubscribers, notifyChan)
	c.notifyLock.Unlock()
	// Initial notification send on subscription
	c.notifyDone.Add(1)
	defer c.notifyDone.Done()
	select {
	case <-c.t.Dying():
		return nil
	case notifyChan <- struct{}{}:
	default:
	}
	return notifyChan
}
