// Command syncledger is a small, real, working implementation of network
// file sync to/from a remote SyncLedger server over plain HTTP (stdlib
// net/http only). It is the "vertical" tier of a family of sync tools:
// unlike the local-filesystem-only siblings SyncGuard and FolderSync,
// SyncLedger actually crosses a network boundary between two machines.
//
// See README.txt for the protocol description and usage examples.
package main

import (
	"bytes"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"io/fs"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"
)

// ---------------------------------------------------------------------
// Shared types / helpers
// ---------------------------------------------------------------------

// FileEntry describes one file in a manifest: its slash-separated path
// relative to the sync root, its size in bytes, and its SHA-256 hash.
type FileEntry struct {
	Path   string `json:"path"`
	Size   int64  `json:"size"`
	SHA256 string `json:"sha256"`
}

// Manifest is the JSON body returned by GET /manifest.
type Manifest struct {
	Files []FileEntry `json:"files"`
}

// LedgerRecord is one JSON-lines entry appended to the audit ledger for
// every file actually transferred (only when --apply is used).
type LedgerRecord struct {
	Path      string `json:"path"`
	Size      int64  `json:"size"`
	SHA256    string `json:"sha256"`
	Direction string `json:"direction"` // "push" or "pull"
	Timestamp string `json:"timestamp"`
}

const tokenHeader = "X-Sync-Token"

func usage() {
	fmt.Fprint(os.Stderr, `syncledger - network sync to/from a remote SyncLedger server (HTTP)

Usage:
  syncledger serve <dir> --port 8080 --token SECRET
  syncledger push  <localdir> --remote http://host:port --token SECRET --ledger ledger.jsonl [--apply]
  syncledger pull  <localdir> --remote http://host:port --token SECRET --ledger ledger.jsonl [--apply]
  syncledger help

Commands:
  serve   Run an HTTP server exposing <dir> for sync (manifest/file endpoints).
  push    Upload new/changed local files to the remote server.
  pull    Download new/changed remote files to the local directory.

Flags:
  --port <n>       Port for "serve" to listen on (default 8080).
  --remote <url>   Base URL of a running "syncledger serve" instance.
  --token <secret> Shared secret sent as the X-Sync-Token header. Required
                    on all three commands; requests with a missing or wrong
                    token are rejected by the server with 401 Unauthorized.
  --ledger <path>  JSON-lines audit log file to append transfer records to
                    (push/pull only). Default: ledger.jsonl in <localdir>.
  --apply          Actually perform the transfer. Without it, push/pull
                    only print the sync plan (dry run) and change nothing.

Protocol (implemented with net/http, stdlib only):
  GET  /manifest         -> {"files":[{"path","size","sha256"},...]}
  GET  /file?path=REL    -> raw bytes of that file
  PUT  /file?path=REL    -> request body is written as that file's content

Run "syncledger <command> -h" for command-specific help.
`)
	os.Exit(1)
}

// reorderFlags moves recognized flags (and their values, for flags listed
// in valueFlags) to the front of the argument list and positional
// arguments to the back, so the stdlib flag package -- which stops
// parsing at the first non-flag argument -- can handle flags and
// positional arguments in any order on the command line.
func reorderFlags(args []string, valueFlags map[string]bool) []string {
	var flags, positional []string
	for i := 0; i < len(args); i++ {
		a := args[i]
		name := strings.TrimLeft(a, "-")
		if strings.HasPrefix(a, "-") && valueFlags[name] {
			flags = append(flags, a)
			if i+1 < len(args) {
				i++
				flags = append(flags, args[i])
			}
			continue
		}
		if strings.HasPrefix(a, "-") {
			flags = append(flags, a)
			continue
		}
		positional = append(positional, a)
	}
	return append(flags, positional...)
}

func isHelp(s string) bool {
	return s == "-h" || s == "--help" || s == "help"
}

func sha256File(path string) (sum string, size int64, err error) {
	f, err := os.Open(path)
	if err != nil {
		return "", 0, err
	}
	defer f.Close()
	h := sha256.New()
	n, err := io.Copy(h, f)
	if err != nil {
		return "", 0, err
	}
	return hex.EncodeToString(h.Sum(nil)), n, nil
}

func sha256Bytes(b []byte) string {
	h := sha256.Sum256(b)
	return hex.EncodeToString(h[:])
}

// scanLocalDir walks root and returns a map from slash-separated relative
// path to FileEntry, skipping the ledger file itself (if it lives inside
// root) so the audit log never tries to sync itself.
func scanLocalDir(root, skipAbs string) (map[string]FileEntry, error) {
	out := make(map[string]FileEntry)
	root = filepath.Clean(root)
	err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() {
			return nil
		}
		absP, err := filepath.Abs(p)
		if err != nil {
			return err
		}
		if skipAbs != "" {
			if absSkip, err := filepath.Abs(skipAbs); err == nil && absP == absSkip {
				return nil
			}
		}
		rel, err := filepath.Rel(root, p)
		if err != nil {
			return err
		}
		rel = filepath.ToSlash(rel)
		sum, size, err := sha256File(p)
		if err != nil {
			return err
		}
		out[rel] = FileEntry{Path: rel, Size: size, SHA256: sum}
		return nil
	})
	if err != nil {
		return nil, err
	}
	return out, nil
}

func appendLedger(path string, rec LedgerRecord) error {
	f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
	if err != nil {
		return err
	}
	defer f.Close()
	enc := json.NewEncoder(f)
	return enc.Encode(rec)
}

// ---------------------------------------------------------------------
// main
// ---------------------------------------------------------------------

func main() {
	args := os.Args[1:]
	if len(args) == 0 {
		// Double-clicked in Explorer rather than run from a prompt: ask the
		// one question the program needs and stay on screen. Printing usage
		// and exiting here is what made the window vanish instantly.
		if interactiveConsole() {
			runGuided()
			return
		}
		usage()
	}
	if isHelp(args[0]) {
		usage()
	}

	cmd := args[0]
	rest := args[1:]

	switch cmd {
	case "serve":
		cmdServe(rest)
	case "push":
		cmdPush(rest)
	case "pull":
		cmdPull(rest)
	default:
		fmt.Fprintf(os.Stderr, "syncledger: unknown command %q\n\n", cmd)
		usage()
	}
}

// ---------------------------------------------------------------------
// serve
// ---------------------------------------------------------------------

func serveUsage() {
	fmt.Fprint(os.Stderr, `Usage: syncledger serve <dir> --port 8080 --token SECRET

Runs a real HTTP server (blocking, until interrupted) rooted at <dir>,
implementing:
  GET  /manifest
  GET  /file?path=REL
  PUT  /file?path=REL

Every request must carry a matching "X-Sync-Token: SECRET" header or the
server responds 401 Unauthorized.
`)
	os.Exit(1)
}

func cmdServe(args []string) {
	for _, a := range args {
		if isHelp(a) {
			serveUsage()
		}
	}
	valueFlags := map[string]bool{"port": true, "token": true}
	args = reorderFlags(args, valueFlags)

	fs2 := flag.NewFlagSet("serve", flag.ExitOnError)
	port := fs2.String("port", "8080", "port to listen on")
	token := fs2.String("token", "", "shared-secret auth token (required)")
	fs2.Usage = serveUsage
	fs2.Parse(args)

	positional := fs2.Args()
	if len(positional) != 1 {
		fmt.Fprintln(os.Stderr, "syncledger serve: exactly one <dir> argument is required")
		serveUsage()
	}
	root := positional[0]
	if *token == "" {
		fmt.Fprintln(os.Stderr, "syncledger serve: --token is required")
		serveUsage()
	}

	info, err := os.Stat(root)
	if err != nil || !info.IsDir() {
		fmt.Fprintf(os.Stderr, "syncledger serve: %q is not a directory: %v\n", root, err)
		os.Exit(1)
	}
	root, err = filepath.Abs(root)
	if err != nil {
		fmt.Fprintf(os.Stderr, "syncledger serve: %v\n", err)
		os.Exit(1)
	}

	mux := http.NewServeMux()
	mux.HandleFunc("/manifest", handleManifest(root))
	mux.HandleFunc("/file", handleFile(root))

	handler := authMiddleware(*token, mux)

	addr := ":" + *port
	fmt.Printf("syncledger serve: rooted at %s, listening on %s\n", root, addr)
	if err := http.ListenAndServe(addr, handler); err != nil {
		fmt.Fprintf(os.Stderr, "syncledger serve: %v\n", err)
		os.Exit(1)
	}
}

func authMiddleware(token string, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Header.Get(tokenHeader) != token {
			http.Error(w, "unauthorized: missing or invalid X-Sync-Token", http.StatusUnauthorized)
			return
		}
		next.ServeHTTP(w, r)
	})
}

func handleManifest(root string) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodGet {
			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
			return
		}
		files, err := scanLocalDir(root, "")
		if err != nil {
			http.Error(w, "scan error: "+err.Error(), http.StatusInternalServerError)
			return
		}
		m := Manifest{}
		for _, fe := range files {
			m.Files = append(m.Files, fe)
		}
		sort.Slice(m.Files, func(i, j int) bool { return m.Files[i].Path < m.Files[j].Path })
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(m)
	}
}

// safeJoin resolves a slash-separated relative path against root,
// rejecting anything that would escape root (absolute paths, "..", etc).
func safeJoin(root, relPath string) (string, error) {
	if relPath == "" {
		return "", fmt.Errorf("empty path")
	}
	clean := filepath.Clean(filepath.FromSlash(relPath))
	if filepath.IsAbs(clean) || strings.HasPrefix(clean, "..") {
		return "", fmt.Errorf("invalid path %q", relPath)
	}
	full := filepath.Join(root, clean)
	rootWithSep := root + string(filepath.Separator)
	if full != root && !strings.HasPrefix(full, rootWithSep) {
		return "", fmt.Errorf("invalid path %q", relPath)
	}
	return full, nil
}

func handleFile(root string) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		rel := r.URL.Query().Get("path")
		full, err := safeJoin(root, rel)
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		switch r.Method {
		case http.MethodGet:
			f, err := os.Open(full)
			if err != nil {
				http.Error(w, "not found", http.StatusNotFound)
				return
			}
			defer f.Close()
			w.Header().Set("Content-Type", "application/octet-stream")
			io.Copy(w, f)
		case http.MethodPut:
			if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
				http.Error(w, "mkdir error: "+err.Error(), http.StatusInternalServerError)
				return
			}
			tmp := full + ".tmp"
			out, err := os.Create(tmp)
			if err != nil {
				http.Error(w, "create error: "+err.Error(), http.StatusInternalServerError)
				return
			}
			if _, err := io.Copy(out, r.Body); err != nil {
				out.Close()
				os.Remove(tmp)
				http.Error(w, "write error: "+err.Error(), http.StatusInternalServerError)
				return
			}
			out.Close()
			if err := os.Rename(tmp, full); err != nil {
				http.Error(w, "rename error: "+err.Error(), http.StatusInternalServerError)
				return
			}
			w.WriteHeader(http.StatusCreated)
		default:
			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		}
	}
}

// ---------------------------------------------------------------------
// client helpers (push/pull share these)
// ---------------------------------------------------------------------

type client struct {
	remote string
	token  string
	http   *http.Client
}

func (c *client) getManifest() (Manifest, error) {
	var m Manifest
	req, err := http.NewRequest(http.MethodGet, strings.TrimRight(c.remote, "/")+"/manifest", nil)
	if err != nil {
		return m, err
	}
	req.Header.Set(tokenHeader, c.token)
	resp, err := c.http.Do(req)
	if err != nil {
		return m, err
	}
	defer resp.Body.Close()
	if resp.StatusCode == http.StatusUnauthorized {
		return m, errAuthFailed
	}
	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(resp.Body)
		return m, fmt.Errorf("GET /manifest: unexpected status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
	}
	if err := json.NewDecoder(resp.Body).Decode(&m); err != nil {
		return m, fmt.Errorf("decoding manifest: %w", err)
	}
	return m, nil
}

func (c *client) getFile(relPath string) ([]byte, error) {
	u := strings.TrimRight(c.remote, "/") + "/file?" + url.Values{"path": {relPath}}.Encode()
	req, err := http.NewRequest(http.MethodGet, u, nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set(tokenHeader, c.token)
	resp, err := c.http.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode == http.StatusUnauthorized {
		return nil, errAuthFailed
	}
	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(resp.Body)
		return nil, fmt.Errorf("GET /file?path=%s: unexpected status %d: %s", relPath, resp.StatusCode, strings.TrimSpace(string(body)))
	}
	return io.ReadAll(resp.Body)
}

func (c *client) putFile(relPath string, data []byte) error {
	u := strings.TrimRight(c.remote, "/") + "/file?" + url.Values{"path": {relPath}}.Encode()
	req, err := http.NewRequest(http.MethodPut, u, bytes.NewReader(data))
	if err != nil {
		return err
	}
	req.Header.Set(tokenHeader, c.token)
	req.Header.Set("Content-Type", "application/octet-stream")
	resp, err := c.http.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode == http.StatusUnauthorized {
		return errAuthFailed
	}
	if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(resp.Body)
		return fmt.Errorf("PUT /file?path=%s: unexpected status %d: %s", relPath, resp.StatusCode, strings.TrimSpace(string(body)))
	}
	return nil
}

var errAuthFailed = fmt.Errorf("authentication failed: server rejected the token (401 Unauthorized)")

// ---------------------------------------------------------------------
// push
// ---------------------------------------------------------------------

func pushUsage() {
	fmt.Fprint(os.Stderr, `Usage: syncledger push <localdir> --remote http://host:port --token SECRET [--ledger ledger.jsonl] [--apply]

Compares <localdir>'s files (by SHA-256) against the remote server's
/manifest and uploads any file that is new or changed on the local side.
Files whose remote copy already matches are skipped (resumable).

Without --apply: prints the plan only, transfers nothing.
With --apply: uploads the files and appends one JSON-lines record per
transferred file to the ledger (default: <localdir>/ledger.jsonl).
`)
	os.Exit(1)
}

func cmdPush(args []string) { runSyncCommand("push", args, pushUsage) }

func pullUsage() {
	fmt.Fprint(os.Stderr, `Usage: syncledger pull <localdir> --remote http://host:port --token SECRET [--ledger ledger.jsonl] [--apply]

Compares <localdir>'s files (by SHA-256) against the remote server's
/manifest and downloads any file that is new or changed on the remote
side. Files that already match locally are skipped (resumable).

Without --apply: prints the plan only, transfers nothing.
With --apply: downloads the files and appends one JSON-lines record per
transferred file to the ledger (default: <localdir>/ledger.jsonl).
`)
	os.Exit(1)
}

func cmdPull(args []string) { runSyncCommand("pull", args, pullUsage) }

// runSyncCommand implements both push and pull, since they are mirror
// images of each other: fetch remote manifest, scan local dir, diff by
// sha256, then transfer + ledger-log in the appropriate direction.
func runSyncCommand(direction string, args []string, usageFn func()) {
	for _, a := range args {
		if isHelp(a) {
			usageFn()
		}
	}
	valueFlags := map[string]bool{"remote": true, "token": true, "ledger": true}
	args = reorderFlags(args, valueFlags)

	fs2 := flag.NewFlagSet(direction, flag.ExitOnError)
	remote := fs2.String("remote", "", "base URL of the remote syncledger server (required)")
	token := fs2.String("token", "", "shared-secret auth token (required)")
	ledger := fs2.String("ledger", "", "audit ledger file (default: <localdir>/ledger.jsonl)")
	apply := fs2.Bool("apply", false, "actually transfer files (default: dry run)")
	fs2.Usage = usageFn
	fs2.Parse(args)

	positional := fs2.Args()
	if len(positional) != 1 {
		fmt.Fprintf(os.Stderr, "syncledger %s: exactly one <localdir> argument is required\n", direction)
		usageFn()
	}
	localDir := positional[0]
	if *remote == "" {
		fmt.Fprintf(os.Stderr, "syncledger %s: --remote is required\n", direction)
		usageFn()
	}
	if *token == "" {
		fmt.Fprintf(os.Stderr, "syncledger %s: --token is required\n", direction)
		usageFn()
	}

	if err := os.MkdirAll(localDir, 0755); err != nil {
		fmt.Fprintf(os.Stderr, "syncledger %s: %v\n", direction, err)
		os.Exit(1)
	}
	ledgerPath := *ledger
	if ledgerPath == "" {
		ledgerPath = filepath.Join(localDir, "ledger.jsonl")
	}

	c := &client{remote: *remote, token: *token, http: &http.Client{Timeout: 60 * time.Second}}

	remoteManifest, err := c.getManifest()
	if err != nil {
		if err == errAuthFailed {
			fmt.Fprintln(os.Stderr, "syncledger "+direction+": "+err.Error())
			os.Exit(1)
		}
		fmt.Fprintf(os.Stderr, "syncledger %s: fetching remote manifest: %v\n", direction, err)
		os.Exit(1)
	}
	remoteFiles := make(map[string]FileEntry, len(remoteManifest.Files))
	for _, fe := range remoteManifest.Files {
		remoteFiles[fe.Path] = fe
	}

	localFiles, err := scanLocalDir(localDir, ledgerPath)
	if err != nil {
		fmt.Fprintf(os.Stderr, "syncledger %s: scanning %q: %v\n", direction, localDir, err)
		os.Exit(1)
	}

	var toTransfer []FileEntry
	if direction == "push" {
		// Local files that are new or changed relative to the remote.
		var names []string
		for name := range localFiles {
			names = append(names, name)
		}
		sort.Strings(names)
		for _, name := range names {
			lf := localFiles[name]
			if rf, ok := remoteFiles[name]; !ok || rf.SHA256 != lf.SHA256 {
				toTransfer = append(toTransfer, lf)
			}
		}
	} else {
		// Remote files that are new or changed relative to local.
		var names []string
		for name := range remoteFiles {
			names = append(names, name)
		}
		sort.Strings(names)
		for _, name := range names {
			rf := remoteFiles[name]
			if lf, ok := localFiles[name]; !ok || lf.SHA256 != rf.SHA256 {
				toTransfer = append(toTransfer, rf)
			}
		}
	}

	if len(toTransfer) == 0 {
		fmt.Printf("syncledger %s: up to date, nothing to transfer (%d remote / %d local files compared)\n",
			direction, len(remoteFiles), len(localFiles))
		return
	}

	verb := "would push"
	if direction == "pull" {
		verb = "would pull"
	}
	if !*apply {
		fmt.Printf("syncledger %s: dry run (pass --apply to actually transfer)\n", direction)
		for _, fe := range toTransfer {
			fmt.Printf("  [PLAN] %s: %s (%d bytes, sha256 %s)\n", verb, fe.Path, fe.Size, fe.SHA256[:12])
		}
		fmt.Printf("%d file(s) would be transferred\n", len(toTransfer))
		return
	}

	transferred := 0
	for _, fe := range toTransfer {
		if direction == "push" {
			localPath := filepath.Join(localDir, filepath.FromSlash(fe.Path))
			data, err := os.ReadFile(localPath)
			if err != nil {
				fmt.Fprintf(os.Stderr, "  [ERROR] reading %s: %v\n", fe.Path, err)
				continue
			}
			if err := c.putFile(fe.Path, data); err != nil {
				if err == errAuthFailed {
					fmt.Fprintln(os.Stderr, "syncledger push: "+err.Error())
					os.Exit(1)
				}
				fmt.Fprintf(os.Stderr, "  [ERROR] pushing %s: %v\n", fe.Path, err)
				continue
			}
			fmt.Printf("  [PUSH] uploaded %s (%d bytes)\n", fe.Path, fe.Size)
		} else {
			data, err := c.getFile(fe.Path)
			if err != nil {
				if err == errAuthFailed {
					fmt.Fprintln(os.Stderr, "syncledger pull: "+err.Error())
					os.Exit(1)
				}
				fmt.Fprintf(os.Stderr, "  [ERROR] pulling %s: %v\n", fe.Path, err)
				continue
			}
			localPath := filepath.Join(localDir, filepath.FromSlash(fe.Path))
			if err := os.MkdirAll(filepath.Dir(localPath), 0755); err != nil {
				fmt.Fprintf(os.Stderr, "  [ERROR] mkdir for %s: %v\n", fe.Path, err)
				continue
			}
			if err := os.WriteFile(localPath, data, 0644); err != nil {
				fmt.Fprintf(os.Stderr, "  [ERROR] writing %s: %v\n", fe.Path, err)
				continue
			}
			if got := sha256Bytes(data); got != fe.SHA256 {
				fmt.Fprintf(os.Stderr, "  [WARN] %s: checksum mismatch after download (expected %s got %s)\n", fe.Path, fe.SHA256, got)
			}
			fmt.Printf("  [PULL] downloaded %s (%d bytes)\n", fe.Path, fe.Size)
		}

		rec := LedgerRecord{
			Path:      fe.Path,
			Size:      fe.Size,
			SHA256:    fe.SHA256,
			Direction: direction,
			Timestamp: time.Now().UTC().Format(time.RFC3339),
		}
		if err := appendLedger(ledgerPath, rec); err != nil {
			fmt.Fprintf(os.Stderr, "  [ERROR] writing ledger entry for %s: %v\n", fe.Path, err)
		}
		transferred++
	}

	fmt.Printf("syncledger %s: %d/%d file(s) transferred, ledger updated at %s\n",
		direction, transferred, len(toTransfer), ledgerPath)
}
