package main

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"os"
	"path/filepath"
	"sort"
	"time"
)

// partSuffix is appended to every file pocketsync writes while it is being
// written. A .part file is never a user file: it is created by this program,
// hash-verified, and then renamed into place. Scans skip them.
const partSuffix = ".pocketsync.part"

// manifestVersion is the on-disk baseline format version.
const manifestVersion = 1

// FileMeta is the recorded identity of one regular file.
type FileMeta struct {
	SHA256  string    `json:"sha256"`
	Size    int64     `json:"size"`
	ModTime time.Time `json:"modtime"`
}

// Manifest is the baseline: the agreed-identical state of the two sides.
type Manifest struct {
	Tool    string              `json:"tool"`
	Version int                 `json:"version"`
	Created time.Time           `json:"created"`
	SideA   string              `json:"side_a"`
	SideB   string              `json:"side_b"`
	Files   map[string]FileMeta `json:"files"`
}

func newManifest(a, b string) *Manifest {
	return &Manifest{
		Tool:    appName,
		Version: manifestVersion,
		Created: time.Now().UTC(),
		SideA:   a,
		SideB:   b,
		Files:   map[string]FileMeta{},
	}
}

// Tree is one scanned side.
type Tree struct {
	Root    string
	Files   map[string]FileMeta // key: slash-separated path relative to Root
	Bytes   int64
	Dirs    int64
	Skipped []string // non-regular entries (symlinks, sockets, devices)
	Errors  []string // entries that could not be read
	Parts   []string // leftover .part files found and ignored
}

func newTree(root string) *Tree {
	return &Tree{Root: root, Files: map[string]FileMeta{}}
}

// Paths returns every recorded path, sorted.
func (t *Tree) Paths() []string {
	out := make([]string, 0, len(t.Files))
	for p := range t.Files {
		out = append(out, p)
	}
	sort.Strings(out)
	return out
}

// get returns a copy of the metadata for p, or nil when absent.
func (t *Tree) get(p string) *FileMeta {
	if t == nil {
		return nil
	}
	m, ok := t.Files[p]
	if !ok {
		return nil
	}
	return &m
}

func (m *Manifest) get(p string) *FileMeta {
	if m == nil || m.Files == nil {
		return nil
	}
	f, ok := m.Files[p]
	if !ok {
		return nil
	}
	return &f
}

// scanTree walks root and records the SHA-256, size and modification time of
// every regular file. Symlinks are NOT followed. Unreadable entries are
// recorded and skipped, never fatal. Paths in exclude (absolute) are ignored,
// which is how a baseline file living inside a watched tree stays out of it.
func scanTree(root string, exclude map[string]bool) (*Tree, error) {
	t := newTree(root)
	err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
		if err != nil {
			t.Errors = append(t.Errors, fmt.Sprintf("%s: %v", p, err))
			return nil
		}
		if exclude[p] {
			if d.IsDir() {
				return filepath.SkipDir
			}
			return nil
		}
		if d.IsDir() {
			if p != root {
				t.Dirs++
			}
			return nil
		}
		if !d.Type().IsRegular() {
			t.Skipped = append(t.Skipped, p)
			return nil
		}
		rel, rerr := filepath.Rel(root, p)
		if rerr != nil {
			t.Errors = append(t.Errors, fmt.Sprintf("%s: %v", p, rerr))
			return nil
		}
		rel = filepath.ToSlash(rel)
		if isPartPath(rel) {
			t.Parts = append(t.Parts, rel)
			return nil
		}
		info, ierr := d.Info()
		if ierr != nil {
			t.Errors = append(t.Errors, fmt.Sprintf("%s: %v", p, ierr))
			return nil
		}
		sum, n, herr := hashFile(p)
		if herr != nil {
			t.Errors = append(t.Errors, fmt.Sprintf("%s: %v", p, herr))
			return nil
		}
		t.Files[rel] = FileMeta{SHA256: sum, Size: n, ModTime: info.ModTime().UTC()}
		t.Bytes += n
		return nil
	})
	if err != nil {
		return nil, fmt.Errorf("cannot scan %s: %w", root, err)
	}
	sort.Strings(t.Skipped)
	sort.Strings(t.Errors)
	sort.Strings(t.Parts)
	return t, nil
}

func isPartPath(rel string) bool {
	return len(rel) > len(partSuffix) && rel[len(rel)-len(partSuffix):] == partSuffix
}

// hashFile returns the hex SHA-256 and the byte count of a file.
func hashFile(path string) (string, int64, 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
}

// ---------------------------------------------------------------------------
// Baseline manifest I/O
// ---------------------------------------------------------------------------

var errNoBaseline = errors.New("no baseline file")

func loadManifest(path string) (*Manifest, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, errNoBaseline
		}
		return nil, fmt.Errorf("cannot read baseline %s: %w", path, err)
	}
	var m Manifest
	if err := json.Unmarshal(data, &m); err != nil {
		return nil, fmt.Errorf("baseline %s is not valid JSON: %w", path, err)
	}
	if m.Files == nil {
		m.Files = map[string]FileMeta{}
	}
	if m.Version > manifestVersion {
		return nil, fmt.Errorf("baseline %s was written by a newer %s (format v%d, this build understands v%d)",
			path, appName, m.Version, manifestVersion)
	}
	return &m, nil
}

// writeManifest writes m to path without ever overwriting an existing baseline
// in place: the previous file is first renamed to <path>.prev-<timestamp>.json.
// It returns the path the previous baseline was kept at, or "".
func writeManifest(path string, m *Manifest) (string, error) {
	if dir := filepath.Dir(path); dir != "" && dir != "." {
		if err := os.MkdirAll(dir, 0o755); err != nil {
			return "", fmt.Errorf("cannot create %s: %w", dir, err)
		}
	}
	data, err := json.MarshalIndent(m, "", "  ")
	if err != nil {
		return "", fmt.Errorf("cannot encode baseline: %w", err)
	}
	data = append(data, '\n')

	tmp := path + partSuffix
	if err := os.WriteFile(tmp, data, 0o644); err != nil {
		return "", fmt.Errorf("cannot write baseline %s: %w", tmp, err)
	}

	kept := ""
	if _, err := os.Stat(path); err == nil {
		kept = previousBaselineName(path, m.Created)
		if err := os.Rename(path, kept); err != nil {
			_ = removeTemp(tmp)
			return "", fmt.Errorf("cannot keep previous baseline as %s: %w", kept, err)
		}
	} else if !os.IsNotExist(err) {
		_ = removeTemp(tmp)
		return "", fmt.Errorf("cannot stat baseline %s: %w", path, err)
	}

	if err := os.Rename(tmp, path); err != nil {
		return kept, fmt.Errorf("cannot install baseline %s: %w", path, err)
	}
	return kept, nil
}

// previousBaselineName produces a collision-free name for the outgoing
// baseline. The old file is kept, never removed.
func previousBaselineName(path string, at time.Time) string {
	ext := filepath.Ext(path)
	stem := path[:len(path)-len(ext)]
	stamp := at.UTC().Format("20060102T150405Z")
	cand := fmt.Sprintf("%s.prev-%s%s", stem, stamp, ext)
	for i := 2; ; i++ {
		if _, err := os.Stat(cand); os.IsNotExist(err) {
			return cand
		}
		cand = fmt.Sprintf("%s.prev-%s-%d%s", stem, stamp, i, ext)
	}
}

// removeTemp deletes a file this program created. It refuses any path that is
// not one of our own .part files, which is why no user file can be unlinked.
func removeTemp(path string) error {
	if !isPartPath(filepath.ToSlash(path)) {
		return fmt.Errorf("refusing to remove %s: not a %s temporary file", path, appName)
	}
	err := os.Remove(path)
	if err != nil && os.IsNotExist(err) {
		return nil
	}
	return err
}

// BaselineStats explains what went into a written baseline.
type BaselineStats struct {
	Agreed         int      `json:"agreed"`          // present on both sides, identical content
	Carried        int      `json:"carried_forward"` // still-divergent paths the old baseline knew about
	DroppedDeleted int      `json:"dropped_deleted"` // known paths now gone from both sides
	LeftOut        []string `json:"left_out"`        // divergent or one-sided paths not recorded
}

// mergeBaseline builds the manifest to write.
//
//	rule 1  a path present on BOTH sides with identical content is the agreed
//	        state: record what is there now.
//	rule 2  a path the OLD baseline already knew about, which is now divergent
//	        or present on one side only, keeps its old entry. This is what stops
//	        a deletion from being undone: the path stays known as "was agreed,
//	        then removed on one side", so the next run classifies it as a
//	        deletion again instead of as a brand new file to copy back.
//	rule 3  a path the old baseline knew about that is now gone from BOTH sides
//	        is dropped: both sides agree it is gone.
//	rule 4  anything else - a file that exists on one side only and was never
//	        agreed - is deliberately left out, so the next run sees it as new.
func mergeBaseline(old *Manifest, a, b *Tree) (*Manifest, BaselineStats) {
	m := newManifest(a.Root, b.Root)
	var st BaselineStats

	seen := map[string]bool{}
	var paths []string
	add := func(p string) {
		if !seen[p] {
			seen[p] = true
			paths = append(paths, p)
		}
	}
	if old != nil {
		for p := range old.Files {
			add(p)
		}
	}
	for p := range a.Files {
		add(p)
	}
	for p := range b.Files {
		add(p)
	}
	sort.Strings(paths)

	for _, p := range paths {
		fa, fb := a.get(p), b.get(p)
		fo := old.get(p)
		switch {
		case fa != nil && fb != nil && fa.SHA256 == fb.SHA256:
			m.Files[p] = *fa
			st.Agreed++
		case fa == nil && fb == nil:
			// Known to the old baseline, now gone from both sides.
			st.DroppedDeleted++
		case fo != nil:
			m.Files[p] = *fo
			st.Carried++
			st.LeftOut = append(st.LeftOut, p)
		default:
			st.LeftOut = append(st.LeftOut, p)
		}
	}
	sort.Strings(st.LeftOut)
	return m, st
}
