// goredo -- djb's redo implementation on pure Go
// Copyright (C) 2020-2026 Sergey Matveev <stargrave@stargrave.org>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 of the License.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program.  If not, see <http://www.gnu.org/licenses/>.

package main

import (
	"bufio"
	"bytes"
	"encoding/hex"
	"errors"
	"flag"
	"fmt"
	"io"
	"io/fs"
	"log"
	"os"
	"os/signal"
	"path"
	"runtime"
	"sort"
	"strconv"
	"syscall"

	"github.com/google/uuid"
	"go.stargrave.org/recfile/v4"
	"golang.org/x/sys/unix"
)

const (
	CmdNameGoredo       = "goredo"
	CmdNameRedo         = "redo"
	CmdNameRedoAffects  = "redo-affects"
	CmdNameRedoAlways   = "redo-always"
	CmdNameRedoCleanup  = "redo-cleanup"
	CmdNameRedoDep2Rec  = "redo-dep2rec"
	CmdNameRedoDepFix   = "redo-depfix"
	CmdNameRedoDot      = "redo-dot"
	CmdNameRedoIfchange = "redo-ifchange"
	CmdNameRedoIfcreate = "redo-ifcreate"
	CmdNameRedoInode    = "redo-inode"
	CmdNameRedoLog      = "redo-log"
	CmdNameRedoOOD      = "redo-ood"
	CmdNameRedoSources  = "redo-sources"
	CmdNameRedoStamp    = "redo-stamp"
	CmdNameRedoTargets  = "redo-targets"
	CmdNameRedoWhichdo  = "redo-whichdo"
)

var (
	Cwd       string
	BuildUUID uuid.UUID
	IsTopRedo bool // is it the top redo instance
	UmaskCur  int
)

func mustSetenv(key string) {
	if err := os.Setenv(key, "1"); err != nil {
		panic(err)
	}
}

func mustParseFd(v, name string) *os.File {
	ptr, err := strconv.ParseUint(v, 10, 64)
	if err != nil {
		panic(err)
	}
	fd := os.NewFile(uintptr(ptr), name)
	if fd == nil {
		panic("can not parse fd: " + name)
	}
	return fd
}

func CmdName() string {
	return path.Base(os.Args[0])
}

func main() {
	version := flag.Bool("version", false, "print version")
	warranty := flag.Bool("warranty", false, "print warranty information")

	var symlinks *bool
	cmdName := CmdName()
	if cmdName == CmdNameGoredo {
		symlinks = flag.Bool("symlinks", false, "create necessary symlinks in current directory")
	}

	flag.Usage = func() { usage(os.Args[0]) }

	BuildUUIDStr := os.Getenv(EnvBuildUUID)
	IsTopRedo = BuildUUIDStr == ""

	var args []string
	if IsTopRedo {
		flag.Parse()
		args = flag.Args()
	} else {
		args = os.Args[1:]
	}

	if *warranty {
		fmt.Println(Warranty)
		return
	}
	if *version {
		fmt.Println("goredo", Version, "built with", runtime.Version())
		return
	}
	if cmdName == CmdNameGoredo && *symlinks {
		rc := 0
		for _, cmdName := range []string{
			CmdNameRedo,
			CmdNameRedoAffects,
			CmdNameRedoAlways,
			CmdNameRedoCleanup,
			CmdNameRedoDep2Rec,
			CmdNameRedoDepFix,
			CmdNameRedoDot,
			CmdNameRedoIfchange,
			CmdNameRedoIfcreate,
			CmdNameRedoInode,
			CmdNameRedoLog,
			CmdNameRedoOOD,
			CmdNameRedoSources,
			CmdNameRedoStamp,
			CmdNameRedoTargets,
			CmdNameRedoWhichdo,
		} {
			fmt.Println(os.Args[0], "<-", cmdName)
			if err := os.Symlink(os.Args[0], cmdName); err != nil {
				rc = 1
				log.Print(err)
			}
		}
		os.Exit(rc)
	}
	log.SetFlags(log.Lshortfile)

	UmaskCur = syscall.Umask(0)
	syscall.Umask(UmaskCur)

	var err error
	Cwd, err = os.Getwd()
	if err != nil {
		log.Fatal(err)
	}

	TopDir = os.Getenv(EnvTopDir)
	if TopDir == "" {
		TopDir = "/"
	} else {
		TopDir = mustAbs(TopDir)
	}
	DirPrefix = os.Getenv(EnvDirPrefix)
	DepCwd = os.Getenv(EnvDepCwd)

	if flagStderrKeep != nil && *flagStderrKeep {
		mustSetenv(EnvStderrKeep)
	}
	if flagStderrSilent != nil && *flagStderrSilent {
		mustSetenv(EnvStderrSilent)
	}
	if flagNoProgress != nil && *flagNoProgress {
		mustSetenv(EnvNoProgress)
	}
	if flagDebug != nil && *flagDebug {
		mustSetenv(EnvDebug)
	}
	if flagLogWait != nil && *flagLogWait {
		mustSetenv(EnvLogWait)
	}
	if flagLogLock != nil && *flagLogLock {
		mustSetenv(EnvLogLock)
	}
	if flagLogPid != nil && *flagLogPid {
		mustSetenv(EnvLogPid)
	}
	if flagLogJS != nil && *flagLogJS {
		mustSetenv(EnvLogJS)
	}
	StderrKeep = os.Getenv(EnvStderrKeep) == "1"
	StderrSilent = os.Getenv(EnvStderrSilent) == "1"
	NoProgress = os.Getenv(EnvNoProgress) == "1"
	Debug = os.Getenv(EnvDebug) == "1"
	LogWait = os.Getenv(EnvLogWait) == "1"
	LogLock = os.Getenv(EnvLogLock) == "1"
	LogJS = os.Getenv(EnvLogJS) == "1"
	if Debug || os.Getenv(EnvLogPid) == "1" {
		MyPID = os.Getpid()
	}
	var traced bool
	if flagTraceAll != nil && *flagTraceAll {
		mustSetenv(EnvTrace)
	}
	if os.Getenv(EnvTrace) == "1" {
		TracedAll = true
		traced = true
	} else if flagTrace != nil {
		traced = *flagTrace
	}
	NoColor = os.Getenv(EnvNoColor) != ""
	NoSync = os.Getenv(EnvNoSync) == "1"
	StopIfMod = os.Getenv(EnvStopIfMod) == "1"
	switch s := os.Getenv(EnvInodeTrust); s {
	case "none":
		InodeTrust = InodeTrustNone
	case "", "ctime":
		InodeTrust = InodeTrustCtime
	case "mtime":
		InodeTrust = InodeTrustMtime
	default:
		log.Fatalln("unknown", EnvInodeTrust, "value")
	}

	// Those are internal envs
	FdOODTgts, err = os.CreateTemp("", "ood-tgts")
	if err != nil {
		log.Fatal(err)
	}
	if err = os.Remove(FdOODTgts.Name()); err != nil {
		log.Fatal(err)
	}
	FdOODTgtsLock, err = os.CreateTemp("", "ood-tgts.lock")
	if err != nil {
		log.Fatal(err)
	}
	if err = os.Remove(FdOODTgtsLock.Name()); err != nil {
		log.Fatal(err)
	}

	var fdLock *os.File
	if v := os.Getenv(EnvOODTgtsFd); v != "" {
		fd := mustParseFd(v, EnvOODTgtsFd)
		fdLock = mustParseFd(v, EnvOODTgtsLockFd)
		defer fdLock.Close()
		flock := unix.Flock_t{
			Type:   unix.F_WRLCK,
			Whence: io.SeekStart,
		}
		if err = unix.FcntlFlock(fdLock.Fd(), unix.F_SETLKW, &flock); err != nil {
			log.Fatal(err)
		}
		if _, err = fd.Seek(0, io.SeekStart); err != nil {
			log.Fatal(err)
		}
		var tgtsRaw []byte
		tgtsRaw, err = io.ReadAll(bufio.NewReader(fd))
		if err != nil {
			log.Fatal(err)
		}
		flock.Type = unix.F_UNLCK
		if err = unix.FcntlFlock(fdLock.Fd(), unix.F_SETLK, &flock); err != nil {
			log.Fatal(err)
		}
		OODTgts = make(map[string]struct{})
		for tgtRaw := range bytes.SplitSeq(tgtsRaw, []byte{0}) {
			t := string(tgtRaw)
			if t == "" {
				continue
			}
			OODTgts[t] = struct{}{}
			tracef(CDebug, "ood: known to be: %s", t)
		}
	}

	StderrPrefix = os.Getenv(EnvStderrPrefix)
	if v := os.Getenv(EnvLevel); v != "" {
		Level, err = strconv.Atoi(v)
		if err != nil {
			panic(err)
		}
		if Level < 0 {
			panic("negative " + EnvLevel)
		}
	}

	var fdDep *os.File
	if v := os.Getenv(EnvDepFd); v != "" {
		fdDep = mustParseFd(v, EnvDepFd)
	}

	tgts := make([]*Tgt, 0, len(args))
	for _, arg := range args {
		tgts = append(tgts, NewTgt(arg))
	}
	tgtsWasEmpty := len(tgts) == 0
	if BuildUUIDStr == "" {
		BuildUUID = uuid.New()
		if tgtsWasEmpty {
			tgts = append(tgts, NewTgt("all"))
		}
		tracef(CDebug, "inode-trust: %s", InodeTrust)
	} else {
		BuildUUID, err = uuid.Parse(BuildUUIDStr)
		if err != nil {
			log.Fatal(err)
		}
	}

	if cmdName == CmdNameRedo {
		statusInit()
	}

	killed := make(chan os.Signal, 1)
	signal.Notify(killed, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM)
	go func() {
		<-killed
		tracef(CDebug, "[%s] killed", BuildUUID)
		TmpsToCleanM.Lock()
		for fn := range TmpsToClean {
			tracef(CDebug, "[%s] clean tempfile: %s", BuildUUID, fn)
			os.Remove(fn)
		}
		jsReleaseAll()
		RunningProcsM.Lock()
		for pid, proc := range RunningProcs {
			tracef(CDebug, "[%s] killing child %d", BuildUUID, pid)
			_ = proc.Signal(syscall.SIGTERM)
		}
		os.Exit(1)
	}()

	ok := true
	err = nil
	tracef(CDebug, "[%s] run: %s %s cwd:%s dirprefix:%s",
		BuildUUID, cmdName, tgts, Cwd, DirPrefix)

	switch cmdName {
	case CmdNameRedo:
		for _, tgt := range tgts {
			ok, err = ifchange([]*Tgt{tgt}, true, traced)
			if err != nil || !ok {
				break
			}
		}
	case CmdNameRedoIfchange:
		ok, err = ifchange(tgts, *flagForcedIfchange, traced)
		if err == nil {
			err = depsWrite(fdDep, tgts)
		}
	case CmdNameRedoIfcreate:
		if fdDep == nil {
			log.Fatalln("no", EnvDepFd)
		}
		fdDepW := bufio.NewWriter(fdDep)
		for _, tgt := range tgts {
			err = ifcreate(fdDepW, fdDep.Name(), tgt.RelTo(path.Join(Cwd, DirPrefix)))
			if err != nil {
				break
			}
		}
		err = fdDepW.Flush()
	case CmdNameRedoAlways:
		if fdDep == nil {
			log.Fatalln("no", EnvDepFd)
		}
		err = always(fdDep, fdDep.Name())
	case CmdNameRedoCleanup:
		for _, what := range tgts {
			err = cleanupWalker(Cwd, path.Base(what.a))
			if err != nil {
				break
			}
		}
	case CmdNameRedoDot:
		err = dotPrint(tgts)
	case CmdNameRedoStamp:
		if fdDep == nil {
			log.Fatalln("no", EnvDepFd)
		}
		var hsh Hash
		hsh, err = fileHash(os.Stdin)
		if err != nil {
			break
		}
		err = stamp(fdDep, fdDep.Name(), hsh)
	case CmdNameRedoLog:
		if len(tgts) != 1 {
			log.Fatal("single target expected")
		}
		err = showBuildLog(tgts[0], nil, 0)
	case CmdNameRedoWhichdo:
		if len(tgts) != 1 {
			log.Fatal("single target expected")
		}
		var dos []string
		dos, err = whichdo(tgts[0])
		if err != nil {
			if errors.Is(err, fs.ErrNotExist) {
				err = nil
				ok = false
			} else {
				break
			}
		}
		for _, do := range dos {
			fmt.Println(do)
		}
	case CmdNameRedoTargets:
		raws := make([]string, 0, len(tgts))
		for _, tgt := range tgts {
			raws = append(raws, tgt.rel)
		}
		if tgtsWasEmpty {
			raws = []string{Cwd}
		}
		raws, err = targetsWalker(raws)
		if err != nil {
			err = ErrLine(err)
			break
		}
		sort.Strings(raws)
		for _, tgt := range raws {
			fmt.Println(tgt)
		}
	case CmdNameRedoAffects:
		if tgtsWasEmpty {
			log.Fatal("no targets specified")
		}
		var res []string
		{
			var tgtsKnown []string
			tgtsKnown, err = targetsWalker([]string{Cwd})
			if err != nil {
				err = ErrLine(err)
				break
			}
			deps := make(map[string]map[string]*Tgt)
			for _, tgt := range tgtsKnown {
				collectDeps(NewTgt(tgt), 0, deps, true)
			}
			seen := make(map[string]*Tgt)
			for _, tgt := range tgts {
				collectWholeDeps(deps[tgt.rel], deps, seen)
			}
			res = make([]string, 0, len(seen))
			for _, dep := range seen {
				res = append(res, dep.rel)
			}
		}
		sort.Strings(res)
		for _, dep := range res {
			fmt.Println(dep)
		}
	case CmdNameRedoOOD:
		raws := make([]string, 0, len(tgts))
		for _, tgt := range tgts {
			raws = append(raws, tgt.rel)
		}
		if tgtsWasEmpty {
			raws, err = targetsWalker([]string{Cwd})
			if err != nil {
				break
			}
		}
		sort.Strings(raws)
		var ood bool
		for _, tgt := range raws {
			ood, err = isOOD(NewTgt(tgt), 0, nil)
			if err != nil {
				err = ErrLine(err)
				break
			}
			if ood {
				fmt.Println(tgt)
			}
		}
	case CmdNameRedoSources:
		srcs := make(map[string]*Tgt)
		{
			raws := make([]string, 0, len(tgts))
			for _, tgt := range tgts {
				raws = append(raws, tgt.rel)
			}
			if tgtsWasEmpty {
				raws, err = targetsWalker([]string{Cwd})
				if err != nil {
					err = ErrLine(err)
					break
				}
			}
			sort.Strings(raws)
			tgts = tgts[:0]
			for _, raw := range raws {
				tgts = append(tgts, NewTgt(raw))
			}
			seen := make(map[string]struct{})
			seenDeps := make(map[string]struct{})
			err = ErrLine(sourcesWalker(tgts, seen, seenDeps, srcs))
		}
		if err != nil {
			break
		}
		res := make([]string, 0, len(srcs))
		for _, tgt := range srcs {
			res = append(res, tgt.rel)
		}
		srcs = nil
		sort.Strings(res)
		for _, src := range res {
			fmt.Println(src)
		}
	case CmdNameRedoDepFix:
		IfchangeCache = nil
		DepFixHashCache = make(map[string]Hash)
		err = depFix(Cwd)
	case CmdNameRedoInode:
		var inode *Inode
		for _, tgt := range tgts {
			inode, err = inodeFromFileByPath(tgt.a)
			if err != nil {
				err = ErrLine(err)
				break
			}
			err = recfileWrite(os.Stdout, append(
				[]recfile.Field{{F: "Target", V: tgt.String()}},
				inode.RecfileFields()...)...)
			if err != nil {
				err = ErrLine(err)
				break
			}
		}
	case CmdNameRedoDep2Rec:
		var data []byte
		data, err = os.ReadFile(tgts[0].a)
		if err != nil {
			break
		}
		var build uuid.UUID
		build, data, err = depHeadParse(data)
		if err != nil {
			break
		}
		w := bufio.NewWriter(os.Stdout)
		err = recfileWrite(w, []recfile.Field{{F: "Build", V: build.String()}}...)
		if err != nil {
			break
		}
		var typ byte
		var chunk []byte
		var inode Inode
		for len(data) > 0 {
			typ, chunk, data, _ = chunkRead(data)
			switch typ {
			case DepTypeAlways:
				err = recfileWrite(w, []recfile.Field{{F: "Type", V: "always"}}...)
			case DepTypeStamp:
				err = recfileWrite(w, []recfile.Field{
					{F: "Type", V: "stamp"},
					{F: "Hash", V: hex.EncodeToString(chunk)},
				}...)
			case DepTypeIfcreate:
				err = recfileWrite(w, []recfile.Field{
					{F: "Type", V: "ifcreate"},
					{F: "Target", V: string(chunk)},
				}...)
			case DepTypeIfchange:
				name := string(chunk[InodeLen+HashLen:])
				meta := chunk[:InodeLen+HashLen]
				fields := []recfile.Field{
					{F: "Type", V: "ifchange"},
					{F: "Target", V: name},
				}
				fields = append(fields, recfile.Field{
					F: "Hash", V: Hash(meta[InodeLen:]).String(),
				})
				inode = Inode(meta[:][:InodeLen])
				fields = append(fields, inode.RecfileFields()...)
				err = recfileWrite(w, fields...)
			case DepTypeIfchangeNonex:
				err = recfileWrite(w, []recfile.Field{
					{F: "Type", V: "ifchange"},
					{F: "Target", V: string(chunk)},
				}...)
			}
			if err != nil {
				break
			}
		}
		err = w.Flush()
	default:
		log.Fatalln("unknown command", cmdName)
	}
	if err != nil {
		log.Print(err)
	}
	rc := 0
	if !ok || err != nil {
		rc = 1
	}
	tracef(CDebug, "[%s] finished: %s %s", BuildUUID, cmdName, tgts)
	os.Exit(rc)
}
