package main

import (
	"bufio"
	"embed"
	"fmt"
	"io"
	"io/fs"
	"log"
	"net"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"strings"
	"time"
)

var buildtime = "unset"
var gitversion = "unset"

//go:embed completion.bash web/app
var embedfs embed.FS
var embedfsWebRoot = "web/app"

func main() {
	cmd, err := parseOptions(os.Args[1:])
	if err != nil {
		log.Fatal(err)
	}

	switch cmd.Name {
	case "help":
		fmt.Print(usage)
		os.Exit(1)
	case "version":
		notesiumVersion(cmd.Options.(versionOptions), os.Stdout)
		return
	}

	notesiumDir, err := getNotesiumDir()
	if err != nil {
		log.Fatal(err)
	}

	switch cmd.Name {
	case "home":
		fmt.Println(notesiumDir)
	case "new":
		notesiumNew(notesiumDir, cmd.Options.(newOptions), os.Stdout)
	case "list":
		notesiumList(notesiumDir, cmd.Options.(listOptions), os.Stdout)
	case "links":
		notesiumLinks(notesiumDir, cmd.Options.(linksOptions), os.Stdout)
	case "lines":
		notesiumLines(notesiumDir, cmd.Options.(linesOptions), os.Stdout)
	case "stats":
		notesiumStats(notesiumDir, cmd.Options.(statsOptions), os.Stdout)
	case "finder":
		notesiumFinder(notesiumDir, cmd.Options.(finderOptions))
	case "cat":
		notesiumCat(notesiumDir, cmd.Options.(catOptions))
	case "web":
		notesiumWeb(notesiumDir, cmd.Options.(webOptions))
	case "extract":
		notesiumExtract(cmd.Options.(extractOptions))
	}
}

func notesiumNew(dir string, opts newOptions, w io.Writer) {
	ctime := time.Now()
	if opts.ctime != "" {
		var err error
		ctime, err = time.ParseInLocation("2006-01-02T15:04:05", opts.ctime, time.Local)
		if err != nil {
			log.Fatalf("invalid ctime format: %v", err)
		}
	}

	epochInt := ctime.Unix()
	epochHex := fmt.Sprintf("%x", epochInt)
	filename := fmt.Sprintf("%s.md", epochHex)
	path := filepath.Join(dir, filename)

	_, err := os.Stat(path)
	fileExists := !os.IsNotExist(err)

	if opts.verbose {
		fmt.Fprintf(w, "path:%s\n", path)
		fmt.Fprintf(w, "filename:%s\n", filename)
		fmt.Fprintf(w, "epoch:%d\n", epochInt)
		fmt.Fprintf(w, "ctime:%s\n", ctime.Format("2006-01-02T15:04:05-07:00"))
		fmt.Fprintf(w, "exists:%t\n", fileExists)
	} else {
		fmt.Fprintf(w, "%s\n", path)
	}
}

func notesiumList(dir string, opts listOptions, w io.Writer) {
	populateCache(dir)
	notes := getSortedNotes(opts.sortBy)

	switch opts.limit {
	case "labels":
		for _, note := range notes {
			if note.IsLabel {
				fmt.Fprintf(w, "%s:1: %s\n", note.Filename, note.Title)
			}
		}
		return
	case "orphans":
		for _, note := range notes {
			if len(note.OutgoingLinks) == 0 && len(note.IncomingLinks) == 0 {
				fmt.Fprintf(w, "%s:1: %s\n", note.Filename, note.Title)
			}
		}
		return
	}

	switch opts.prefix {
	case "label":
		var notesWithoutLabelLinks []*Note
		var outputLines []string
		for _, note := range notes {
			labelLinked := false
			for _, link := range note.OutgoingLinks {
				if linkNote, exists := noteCache[link.Filename]; exists && linkNote.IsLabel {
					line := fmt.Sprintf("%s:1: %s%s%s %s", note.Filename, opts.color.Code, linkNote.Title, opts.color.Reset, note.Title)
					if opts.sortBy == "alpha" {
						outputLines = append(outputLines, line)
					} else {
						fmt.Fprintln(w, line)
					}
					labelLinked = true
				}
			}
			if !labelLinked {
				notesWithoutLabelLinks = append(notesWithoutLabelLinks, note)
			}
		}
		if opts.sortBy == "alpha" {
			sortLinesByField(outputLines, ": ", 1)
			for _, line := range outputLines {
				fmt.Fprintln(w, line)
			}
		}
		for _, note := range notesWithoutLabelLinks {
			fmt.Fprintf(w, "%s:1: %s\n", note.Filename, note.Title)
		}
		return
	case "ctime":
		for _, note := range notes {
			dateStamp := getDateStamp(note.Ctime, opts.dateFormat)
			fmt.Fprintf(w, "%s:1: %s%s%s %s\n", note.Filename, opts.color.Code, dateStamp, opts.color.Reset, note.Title)
		}
		return
	case "mtime":
		for _, note := range notes {
			dateStamp := getDateStamp(note.Mtime, opts.dateFormat)
			fmt.Fprintf(w, "%s:1: %s%s%s %s\n", note.Filename, opts.color.Code, dateStamp, opts.color.Reset, note.Title)
		}
		return
	}

	for _, note := range notes {
		fmt.Fprintf(w, "%s:1: %s\n", note.Filename, note.Title)
	}
}

func notesiumLinks(dir string, opts linksOptions, w io.Writer) {
	populateCache(dir)

	if opts.filename != "" {
		note, exists := noteCache[opts.filename]
		if !exists {
			log.Printf("filename does not exist: %s\n", opts.filename)
			return
		}
		switch opts.limit {
		case "outgoing":
			for _, link := range note.OutgoingLinks {
				linkNote, exists := noteCache[link.Filename]
				if exists {
					fmt.Fprintf(w, "%s:1: %s\n", linkNote.Filename, linkNote.Title)
				}
			}
			return
		case "incoming":
			for _, link := range note.IncomingLinks {
				linkNote, exists := noteCache[link.Filename]
				if exists {
					fmt.Fprintf(w, "%s:%d: %s\n", linkNote.Filename, link.LineNumber, linkNote.Title)
				}
			}
			return
		default:
			prefix := fmt.Sprintf("%soutgoing%s", opts.color.Code, opts.color.Reset)
			for _, link := range note.OutgoingLinks {
				linkNote, exists := noteCache[link.Filename]
				if exists {
					fmt.Fprintf(w, "%s:1: %s %s\n", linkNote.Filename, prefix, linkNote.Title)
				}
			}
			prefix = fmt.Sprintf("%sincoming%s", opts.color.Code, opts.color.Reset)
			for _, link := range note.IncomingLinks {
				linkNote, exists := noteCache[link.Filename]
				if exists {
					fmt.Fprintf(w, "%s:%d: %s %s\n", linkNote.Filename, link.LineNumber, prefix, linkNote.Title)
				}
			}
		}
		return
	}

	switch opts.limit {
	case "dangling":
		for _, note := range noteCache {
			for _, link := range note.OutgoingLinks {
				_, exists := noteCache[link.Filename]
				if !exists {
					fmt.Fprintf(w, "%s:%d: %s%s%s → %s\n", note.Filename, link.LineNumber, opts.color.Code, note.Title, opts.color.Reset, link.Filename)
				}
			}
		}
		return
	}

	notes := getSortedNotes("alpha")
	for _, note := range notes {
		for _, link := range note.OutgoingLinks {
			linkNote, exists := noteCache[link.Filename]
			linkTitle := link.Filename
			if exists {
				linkTitle = linkNote.Title
			}
			fmt.Fprintf(w, "%s:%d: %s%s%s → %s\n", note.Filename, link.LineNumber, opts.color.Code, note.Title, opts.color.Reset, linkTitle)
		}
	}
}

func notesiumLines(dir string, opts linesOptions, w io.Writer) {
	files, err := os.ReadDir(dir)
	if err != nil {
		log.Fatalf("Could not read directory: %s\n", err)
	}

	for _, file := range files {
		if !file.IsDir() && strings.HasSuffix(file.Name(), ".md") {
			filename := file.Name()
			path := filepath.Join(dir, filename)
			file, err := os.Open(path)
			if err != nil {
				log.Fatalf("Could not open file: %s\n", err)
			}

			var title string
			scanner := bufio.NewScanner(file)
			lineNumber := 0
			for scanner.Scan() {
				lineNumber++
				line := scanner.Text()
				if line == "" {
					continue
				}
				if opts.prefix == "title" && title == "" && strings.HasPrefix(line, "# ") {
					title = strings.TrimPrefix(line, "# ")
				}
				if opts.filter != "" {
					matches, err := evaluateFilterQuery(opts.filter, line)
					if err != nil {
						fmt.Fprintf(w, "Error: %v\n", err)
						continue
					}
					if !matches {
						continue
					}
				}
				if opts.prefix == "title" {
					fmt.Fprintf(w, "%s:%d: %s%s%s %s\n", filename, lineNumber, opts.color.Code, title, opts.color.Reset, line)
				} else {
					fmt.Fprintf(w, "%s:%d: %s\n", filename, lineNumber, line)
				}
			}

			if err := scanner.Err(); err != nil {
				log.Fatalf("scanner error: %s", err)
			}

			file.Close()
		}
	}
}

func notesiumStats(dir string, opts statsOptions, w io.Writer) {
	populateCache(dir)

	labels := 0
	orphans := 0
	links := 0
	dangling := 0
	lines := 0
	words := 0
	chars := 0

	for _, note := range noteCache {
		if note.IsLabel {
			labels++
		}
		if len(note.OutgoingLinks) == 0 && len(note.IncomingLinks) == 0 {
			orphans++
		}
		for _, link := range note.OutgoingLinks {
			_, exists := noteCache[link.Filename]
			if !exists {
				dangling++
			}
		}

		links += len(note.OutgoingLinks)
		lines += note.Lines
		words += note.Words
		chars += note.Chars
	}

	keyFormat := opts.color.Code + (map[bool]string{true: "%-9s", false: "%s"}[opts.table]) + opts.color.Reset
	fmt.Fprintf(w, keyFormat+" %d\n", "notes", len(noteCache))
	fmt.Fprintf(w, keyFormat+" %d\n", "labels", labels)
	fmt.Fprintf(w, keyFormat+" %d\n", "orphans", orphans)
	fmt.Fprintf(w, keyFormat+" %d\n", "links", links)
	fmt.Fprintf(w, keyFormat+" %d\n", "dangling", dangling)
	fmt.Fprintf(w, keyFormat+" %d\n", "lines", lines)
	fmt.Fprintf(w, keyFormat+" %d\n", "words", words)
	fmt.Fprintf(w, keyFormat+" %d\n", "chars", chars)
}

func notesiumFinder(dir string, opts finderOptions) {
	inputCmd, err := parseOptions(opts.input)
	if err != nil {
		log.Fatal(err)
	}

	theme := os.Getenv("NOTESIUM_FINDER_THEME")
	var fzfColor string
	switch theme {
	case "light":
		fzfColor = "bg:15,bg+:7,fg:241,fg+:241,hl:4,hl+:2,pointer:9,info:6"
	default:
		fzfColor = "bg:8,bg+:0,fg:12,fg+:12,hl:11,hl+:3,pointer:9,info:3"
	}

	optsFzf := []string{
		"--ansi",
		"--exact",
		"--border",
		"--reverse",
		"--no-sort",
		"--no-height",
		"--no-scrollbar",
		"--no-separator",
		"--pointer=>",
		"--delimiter=:",
		"--with-nth=3..",
		fmt.Sprintf("--color=%s", fzfColor),
		fmt.Sprintf("--prompt=%s> ", opts.prompt),
	}

	if opts.preview {
		executablePath, err := os.Executable()
		if err != nil {
			log.Fatalf("error resolving executable path: %v", err)
		}

		executablePath, err = filepath.EvalSymlinks(executablePath)
		if err != nil {
			log.Fatalf("error resolving symlinked executable path: %v", err)
		}

		optsFzf = append(optsFzf,
			"--bind=ctrl-/:toggle-preview",
			"--preview-window=+{2}-/2",
			fmt.Sprintf("--preview=%s cat {}", executablePath),
		)
	}

	inputChan := make(chan string)
	go func() {
		defer close(inputChan)
		writer := &channelWriter{ch: inputChan}

		switch inputCmd.Name {
		case "list":
			notesiumList(dir, inputCmd.Options.(listOptions), writer)
		case "links":
			notesiumLinks(dir, inputCmd.Options.(linksOptions), writer)
		case "lines":
			notesiumLines(dir, inputCmd.Options.(linesOptions), writer)
		default:
			log.Fatal("input command not supported: ", inputCmd.Name)
		}
	}()

	results, code, err := runFinder(inputChan, optsFzf)
	if code != 0 && code != 130 && err != nil {
		fmt.Fprintf(os.Stderr, "Error running fzf: %v\n", err)
	}
	for _, line := range results {
		fmt.Print(line)
	}
	os.Exit(code)
}

func notesiumCat(dir string, opts catOptions) {
	path := filepath.Join(dir, opts.filename)
	file, err := os.Open(path)
	if err != nil {
		log.Fatalf("Error opening file: %v\n", err)
	}
	defer file.Close()
	renderMarkdown(file, os.Stdout, opts.lineNumber)
}

func notesiumWeb(dir string, opts webOptions) {
	populateCache(dir)

	var httpfs http.FileSystem

	if opts.webroot == "embedded" {
		subfs, err := fs.Sub(embedfs, embedfsWebRoot)
		if err != nil {
			log.Fatalf("embedded webroot sub error: %v", err)
		}
		httpfs = http.FS(subfs)
	} else {
		httpfs = http.Dir(opts.webroot)
	}

	ln, err := net.Listen("tcp", fmt.Sprintf("%s:%d", opts.host, opts.port))
	if err != nil {
		log.Fatalf("Failed to listen on a port: %v", err)
	}
	defer ln.Close()

	url := "http://localhost:" + strings.Split(ln.Addr().String(), ":")[1]
	server := &http.Server{
		Addr: ln.Addr().String(),
	}

	http.Handle("/", heartbeatH(http.FileServer(httpfs)))
	http.HandleFunc("/api/notes", heartbeatF(apiList))
	http.HandleFunc("/api/notes/", heartbeatF(func(w http.ResponseWriter, r *http.Request) {
		apiNote(dir, w, r, opts.readOnly)
	}))

	http.HandleFunc("/api/runtime", heartbeatF(func(w http.ResponseWriter, r *http.Request) {
		apiRuntime(dir, w, r, opts)
	}))

	http.HandleFunc("/api/raw/", heartbeatF(func(w http.ResponseWriter, r *http.Request) {
		apiRaw(dir, w, r)
	}))

	for uri, srcDir := range opts.mounts {
		mountfs := http.Dir(srcDir)
		http.Handle(uri, http.StripPrefix(uri, heartbeatH(http.FileServer(mountfs))))
	}

	var idleStopMsg string
	if opts.heartbeat {
		idleStopMsg = " (stop-on-idle enabled)"
		http.HandleFunc("/api/heartbeat", heartbeatF(apiHeartbeat))
		go checkHeartbeat(server)
	}

	if opts.launchBrowser {
		go func() {
			time.Sleep(500 * time.Millisecond)

			var cmd *exec.Cmd
			switch runtime.GOOS {
			case "linux":
				cmd = exec.Command("xdg-open", url)
			case "darwin":
				cmd = exec.Command("open", url)
			case "windows":
				cmd = exec.Command("cmd", "/c", "start", url)
			default:
				log.Println("Unsupported OS for launching browser")
				return
			}

			err := cmd.Start()
			if err != nil {
				log.Printf("Failed to launch the browser: %v", err)
			}
		}()
	}

	fmt.Printf("Serving on %s (bind address %s)\n", url, opts.host)
	fmt.Printf("Press Ctrl+C to stop%s\n", idleStopMsg)
	if err := server.Serve(ln); err != http.ErrServerClosed {
		log.Fatalf("Server closed unexpected:%+v", err)
	}
}

func notesiumExtract(opts extractOptions) {
	switch opts.path {
	case "":
		var files []string
		fs.WalkDir(embedfs, ".", func(path string, d fs.DirEntry, err error) error {
			if err != nil {
				return err
			}
			if !d.IsDir() {
				files = append(files, path)
			}
			return nil
		})

		for _, file := range files {
			fmt.Println(file)
		}

	default:
		content, err := fs.ReadFile(embedfs, opts.path)
		if err != nil {
			log.Fatalf("Failed to read file: %s", err)
		}
		fmt.Println(string(content))
	}
}

func notesiumVersion(opts versionOptions, w io.Writer) {
	version := getVersion(gitversion)

	if opts.check {
		latest, err := getLatestReleaseInfo()
		if err != nil {
			fmt.Fprintf(w, "Error getting latest release info: %v\n", err)
			return
		}

		fmt.Fprintf(w, "Notesium %s (%s/%s)\n", version, runtime.GOOS, runtime.GOARCH)

		comparison := compareVersions(version, latest.Version)
		switch comparison {
		case -1:
			publishedAt := latest.PublishedAt
			if parsedTime, err := time.Parse(time.RFC3339, latest.PublishedAt); err == nil {
				publishedAt = parsedTime.Local().Format("2006-01-02 15:04")
			}
			fmt.Fprintf(w, "A new release is available: %s (%s)\n", latest.Version, publishedAt)
			fmt.Fprintf(w, "https://github.com/alonswartz/notesium/releases\n")
		case 0:
			fmt.Fprintf(w, "You are using the latest version\n")
		case 1:
			fmt.Fprintf(w, "You are using a newer version than latest: %s\n", latest.Version)
		}

		if opts.verbose {
			fmt.Fprintf(w, "\ncomparison:%d\n", comparison)
			fmt.Fprintf(w, "version:%s\n", version)
			fmt.Fprintf(w, "gitversion:%s\n", gitversion)
			fmt.Fprintf(w, "buildtime:%s\n", buildtime)
			fmt.Fprintf(w, "platform:%s/%s\n", runtime.GOOS, runtime.GOARCH)
			fmt.Fprintf(w, "latest.version:%s\n", latest.Version)
			fmt.Fprintf(w, "latest.published:%s\n", latest.PublishedAt)
			fmt.Fprintf(w, "latest.release:%s\n", latest.HTMLURL)
		}
		return
	}

	if opts.verbose {
		fmt.Fprintf(w, "version:%s\n", version)
		fmt.Fprintf(w, "gitversion:%s\n", gitversion)
		fmt.Fprintf(w, "buildtime:%s\n", buildtime)
		fmt.Fprintf(w, "platform:%s/%s\n", runtime.GOOS, runtime.GOARCH)
	} else {
		fmt.Fprintf(w, "%s\n", version)
	}
}

func getDateStamp(t time.Time, dateFormat string) string {
	dateStamp := t.Format(dateFormat)

	// experimental: monday first day of week
	if strings.Contains(dateStamp, "%V") {
		_, week := t.ISOWeek()
		dateStamp = strings.Replace(dateStamp, "%V", fmt.Sprintf("%02d", week), 1)
	}

	// experimental: sunday first day of week
	if strings.Contains(dateStamp, "%U") {
		_, week := t.ISOWeek()
		if t.Weekday() == time.Sunday {
			_, week = t.AddDate(0, 0, 1).ISOWeek()
			if t.Month() == time.January && t.Day() == 1 {
				week = 1
			}
		}
		dateStamp = strings.Replace(dateStamp, "%U", fmt.Sprintf("%02d", week), 1)
	}

	return dateStamp
}
