package main

/// # Setup code
///
/// ```
/// go get github.com/fsnotify/fsnotify
/// go get github.com/prometheus/client_golang/prometheus
/// go get github.com/prometheus/client_golang/prometheus/promauto
/// go get github.com/prometheus/client_golang/prometheus/promhttp
///
/// go build
/// ```
///
/// # Features
///
/// - Processing inclomplete log file.
/// - Tail log file while rsyncd extends it.
/// - Continue processing logs if the log file gets reset/trimmed to zero.
///
/// # Knowns issues
///
/// When processing multi giga bytes of log files containing many millions of
/// events there is a off by one error in the counting the queued entries.
///
///# LICENSE
///
/// Copyright (c) 2018 Stefan Schindler
///
/// This software was written by me as a demonstration and must not be used in
/// production.
///
/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
/// SOFTWARE.

import (
	"bufio"
	"flag"
	//"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"

	"github.com/fsnotify/fsnotify"
	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/promauto"
	"github.com/prometheus/client_golang/prometheus/promhttp"
)

var (
	agentUptime = promauto.NewCounter(prometheus.CounterOpts{
		Namespace: "estada",
		Subsystem: "rsyncd_log_streamer",
		Name:      "agent_uptime",
		Help:      "The total amount of minutes this agent was running.",
	})
	invalidEvents = promauto.NewCounter(prometheus.CounterOpts{
		Namespace: "estada",
		Subsystem: "rsyncd_log_streamer",
		Name:      "invalid_events",
		Help:      "The total amount of events this agent was unable to understand.",
	})
	logsQueued = promauto.NewGauge(prometheus.GaugeOpts{
		Namespace: "estada",
		Subsystem: "rsyncd_log_streamer",
		Name:      "logs_queued",
		Help:      "Number of log operations queued for processing.",
	})
	openConnections = promauto.NewGauge(prometheus.GaugeOpts{
		Namespace: "estada",
		Subsystem: "rsyncd_log_streamer",
		Name:      "open_connections",
		Help:      "Number of open transmissions.",
	})
	abortedConnections = promauto.NewGauge(prometheus.GaugeOpts{
		Namespace: "estada",
		Subsystem: "rsyncd_log_streamer",
		Name:      "aborted_connections",
		Help:      "Number of aborted transmissions.",
	})
	// processed message: connect from 192.0.2.199
	connectionsPerClientIP = promauto.NewGaugeVec(
		prometheus.GaugeOpts{
			Namespace: "estada",
			Subsystem: "rsyncd_log_streamer",
			Name:      "connections_per_client_ip",
			Help:      "Number of connections seen per ip.",
		},
		[]string{
			// Which user has requested the operation?
			"ip",
		},
	)
	// processed message: rsync on sources/ from 192.0.2.199
	downloadedPathsPerClientIP = promauto.NewCounterVec(
		prometheus.CounterOpts{
			Namespace: "estada",
			Subsystem: "rsyncd_log_streamer",
			Name:      "downloaded_paths_per_client_ip",
			Help:      "Paths per client ip.",
		},
		[]string{
			// Which user has requested the operation?
			"ip",
			"path",
		},
	)
	// processed message: sent 6092862 bytes  received 55750 bytes  total size 302215840
	transmittedDataPerTSClientIP = promauto.NewGaugeVec(
		prometheus.GaugeOpts{
			Namespace: "estada",
			Subsystem: "rsyncd_log_streamer",
			Name:      "transmitted_data_per_timestamp_and_client",
			Help:      "Transmission statistics per TimeStamp, client and direction.",
		},
		[]string{
			"timestamp",
			// Which user has requested the operation?
			"ip",
			"direction",
		},
	)
	transmittedDataPerClientIP = promauto.NewGaugeVec(
		prometheus.GaugeOpts{
			Namespace: "estada",
			Subsystem: "rsyncd_log_streamer_per_client",
			Name:      "transmitted_data",
			Help:      "Transmission statistics per client and direction.",
		},
		[]string{
			// Which user has requested the operation?
			"ip",
			"direction",
		},
	)
	transmittedDataTotal = promauto.NewGaugeVec(
		prometheus.GaugeOpts{
			Namespace: "estada",
			Subsystem: "rsyncd_log_streamer_total",
			Name:      "transmitted_data",
			Help:      "Total transmission statistics in all directions.",
		},
		[]string{
			"direction",
		},
	)
)

func main() {
	log.SetOutput(os.Stdout)

	log_file_ptr := flag.String("log_file", "rsync_example.log", "file path to read from")
	flag.Parse()
	log.Println("log_file:", *log_file_ptr)

	lines_channel := make(chan string, 1000)

	// Data Source
	go listen_for_log_entries(*log_file_ptr, lines_channel)

	events_channel := make(chan LogEntry)

	go stream_into_log_entry(lines_channel, events_channel)

	go record_metrics(events_channel)

	go agent_uptime()

	// https://prometheus.io/docs/guides/go-application/
	// Version 0 of the api
	http.Handle("/metrics", promhttp.Handler())
	log.Fatal(http.ListenAndServe(":2112", nil))
}

type LogEntry struct {
	TimeStamp string
	Pid       int
	Event     string
}

func agent_uptime() {
	for {
		time.Sleep(60 * time.Second)
		agentUptime.Inc()
	}
}

// process events
func record_metrics(events_channel <-chan LogEntry) {
	pid_to_ip := map[int]string{}

	for {
		entry := <-events_channel
		logsQueued.Dec()
		log.Println("LogEntry:", entry)

		parts := strings.Split(entry.Event, " ")

		if strings.HasPrefix(entry.Event, "rsyncd version ") {
			// service restarted drop old pid_to_ip table
			pid_to_ip = map[int]string{}

		} else if strings.HasPrefix(entry.Event, "connect from ") {
			ip := strings.Join(parts[2:], " ")
			log.Println("    ip:", ip)
			//openConnectionsPerClientIP.WithLabelValues(ip).Inc()
			connectionsPerClientIP.WithLabelValues(ip).Inc()
			pid_to_ip[entry.Pid] = ip

		} else if strings.HasPrefix(entry.Event, "rsync on ") {
			ip, ok := pid_to_ip[entry.Pid]
			if !ok {
				invalidEvents.Inc()
				continue
			}
			ip_split := strings.Split(ip, " ")

			path := strings.Join(parts[2:len(parts)-1-len(ip_split)], " ")
			log.Println("    ip:", ip)
			log.Println("    path:", path)

			downloadedPathsPerClientIP.WithLabelValues(ip, path).Inc()

		} else if strings.HasPrefix(entry.Event, "sent ") {
			ip, ok := pid_to_ip[entry.Pid]
			if !ok {
				invalidEvents.Inc()
				continue
			}

			sent, err := strconv.ParseFloat(parts[1], 64)
			if err != nil {
				log.Println("parse error sent bytes:", err)
				continue
			}
			received, err := strconv.ParseFloat(parts[5], 64)
			if err != nil {
				log.Println("parse error received bytes:", err)
				continue
			}
			total, err := strconv.ParseFloat(parts[10], 64)
			if err != nil {
				log.Println("parse error total bytes:", err)
				continue
			}

			log.Println("    sent", sent, "received", received, "total", total)

			transmittedDataPerTSClientIP.WithLabelValues(entry.TimeStamp, ip, "sent").Add(sent)
			transmittedDataPerTSClientIP.WithLabelValues(entry.TimeStamp, ip, "received").Add(received)
			transmittedDataPerTSClientIP.WithLabelValues(entry.TimeStamp, ip, "total").Add(received)

			transmittedDataPerClientIP.WithLabelValues(ip, "sent").Add(sent)
			transmittedDataPerClientIP.WithLabelValues(ip, "received").Add(received)
			transmittedDataPerClientIP.WithLabelValues(ip, "total").Add(received)

			transmittedDataTotal.WithLabelValues("sent").Add(sent)
			transmittedDataTotal.WithLabelValues("received").Add(received)
			transmittedDataTotal.WithLabelValues("total").Add(received)

			delete(pid_to_ip, entry.Pid)

		} else if strings.HasPrefix(entry.Event, "rsync: [sender] write error: Connection reset by peer (104)") {
			_, ok := pid_to_ip[entry.Pid]
			if !ok {
				invalidEvents.Inc()
				continue
			}

			abortedConnections.Inc()

			delete(pid_to_ip, entry.Pid)

		} else if strings.HasPrefix(entry.Event, "rsync error: received SIGINT, SIGTERM, or SIGHUP") {
			// ignore maybe delete pid_to_ip entry
			_, ok := pid_to_ip[entry.Pid]
			if ok {
				abortedConnections.Inc()
			}
			delete(pid_to_ip, entry.Pid)

		} else if strings.HasPrefix(entry.Event, "building file list") {
			// ignore

		} else {

			log.Println("unknown event:", entry.Event)
			invalidEvents.Inc()
			continue
		}

		openConnections.Set(float64(len(pid_to_ip)))
	}
}

// Assumes that only full lines get appended
func listen_for_log_entries(file_name string, lines_channel chan<- string) {
	f, err := os.Open(file_name)
	if err != nil {
		log.Fatal(err)
	}
	defer func() {
		if err = f.Close(); err != nil {
			log.Fatal(err)
		}
	}()

	log_reader := bufio.NewScanner(f)
	// start listening after we open the file ourselfs
	watcher, err := fsnotify.NewWatcher()
	if err != nil {
		log.Fatal(err)
	}
	defer watcher.Close()

	err = watcher.Add(file_name)
	if err != nil {
		log.Fatal(err)
	}

	log.Println("scanning existing entries")
	for log_reader.Scan() {
		line := log_reader.Text()
		logsQueued.Inc()
		lines_channel <- line
	}

	err = log_reader.Err()
	if err != nil {
		log.Fatal(err)
	}

	log.Println("waiting for changes")
	// https://godoc.org/github.com/fsnotify/fsnotify
	info, err := f.Stat()
	if err != nil {
		panic(err)
	}
	old_size := info.Size()
	for {
		select {
		case event, ok := <-watcher.Events:
			if !ok {
				return
			}
			log.Println("event:", event)
			if event.Op&fsnotify.Write == fsnotify.Write {
				log.Println("modified file:", event.Name)

				new_info, err := f.Stat()
				if err != nil {
					panic(err)
				}
				new_size := new_info.Size()
				if new_size < old_size {
					f.Seek(0, 0)
				} else {
					pos, err := f.Seek(0, io.SeekCurrent)
					if err != nil {
						panic(err)
					}
					f.Seek(pos, io.SeekStart)
				}
				log_reader = bufio.NewScanner(f)
				old_size = new_size

				log_reader.Scan()
				line := log_reader.Text()
				logsQueued.Inc()
				lines_channel <- line
			}
		case err, ok := <-watcher.Errors:
			if !ok {
				return
			}
			log.Println("error:", err)
		}
	}
}

// Parse string -> LogEntry
func stream_into_log_entry(input <-chan string, output chan<- LogEntry) {
	for {
		raw := <-input

		parts := strings.SplitN(raw, " ", 4)

		if len(parts) < 4 {
			log.Println("(EE) invalid log linee @stream_into_log_entry.raw:", raw)
			continue
		}

		output <- LogEntry{
			TimeStamp: parts[0] + " " + parts[1],
			Pid:       parse_pid(parts[2]),
			Event:     parts[3],
		}
	}
}

// strip brackets and parse int
func parse_pid(raw string) int {
	s := raw[1 : len(raw)-1]
	i, err := strconv.Atoi(s)
	if err != nil {
		log.Println("parse_pid error:", err)
	}
	return i
}
