package main

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

// Apply statuses.
const (
	StatusDone            = "done"
	StatusAlreadyPresent  = "skipped-already-present"
	StatusSourceChanged   = "skipped-source-changed"
	StatusDestUnexpected  = "skipped-destination-unexpected"
	StatusFailed          = "failed"
	StatusSameFileSkipped = "skipped-same-file"
)

// OpResult is what actually happened to one planned operation.
type OpResult struct {
	Op     Op     `json:"op"`
	Status string `json:"status"`
	Detail string `json:"detail,omitempty"`
	Bytes  int64  `json:"bytes,omitempty"`
}

// BaselineAdvance describes the baseline written after a successful sync.
type BaselineAdvance struct {
	Path         string        `json:"path"`
	PreviousKept string        `json:"previous_kept_as,omitempty"`
	Files        int           `json:"files"`
	Stats        BaselineStats `json:"stats"`
}

// ApplyResult is the outcome of a sync --apply.
type ApplyResult struct {
	Started       time.Time        `json:"started"`
	Finished      time.Time        `json:"finished"`
	Results       []OpResult       `json:"operations"`
	Done          int              `json:"done"`
	Skipped       int              `json:"skipped"`
	Failed        []OpResult       `json:"failed,omitempty"`
	BytesWritten  int64            `json:"bytes_written"`
	Baseline      *BaselineAdvance `json:"baseline,omitempty"`
	BaselineError string           `json:"baseline_error,omitempty"`
}

// applyPlan performs the planned operations. Every write goes to a .part file
// which is hash-verified before being renamed into place, so a destination is
// never left half written. Nothing here ever unlinks a file of yours: the only
// path removeTemp will accept is one of our own .part files.
func applyPlan(p *Plan) (*ApplyResult, error) {
	res := &ApplyResult{Started: time.Now().UTC()}
	for _, op := range p.Ops {
		r := applyOne(p, op)
		res.Results = append(res.Results, r)
		switch r.Status {
		case StatusDone:
			res.Done++
			res.BytesWritten += r.Bytes
		case StatusFailed:
			res.Failed = append(res.Failed, r)
		default:
			res.Skipped++
		}
	}
	res.Finished = time.Now().UTC()
	return res, nil
}

func applyOne(p *Plan, op Op) OpResult {
	src := filepath.Join(p.rootFor(op.SrcSide), filepath.FromSlash(op.SrcPath))
	dst := filepath.Join(p.rootFor(op.DstSide), filepath.FromSlash(op.DstPath))
	out := OpResult{Op: op}

	if src == dst {
		out.Status = StatusSameFileSkipped
		out.Detail = "source and destination are the same file"
		return out
	}

	// The source must still be exactly what was planned.
	srcSum, srcSize, err := hashFile(src)
	if err != nil {
		out.Status = StatusFailed
		out.Detail = fmt.Sprintf("cannot read source %s: %v", src, err)
		return out
	}
	if srcSum != op.SHA256 {
		out.Status = StatusSourceChanged
		out.Detail = fmt.Sprintf("source %s changed since the plan was made; skipped", src)
		return out
	}

	// The destination must be absent, already correct, or exactly the content
	// the plan expected to replace.
	if info, err := os.Stat(dst); err == nil {
		if info.IsDir() {
			out.Status = StatusFailed
			out.Detail = fmt.Sprintf("destination %s is a directory", dst)
			return out
		}
		dstSum, _, err := hashFile(dst)
		if err != nil {
			out.Status = StatusFailed
			out.Detail = fmt.Sprintf("cannot read destination %s: %v", dst, err)
			return out
		}
		switch {
		case dstSum == op.SHA256:
			out.Status = StatusAlreadyPresent
			out.Detail = "destination already holds this exact content"
			return out
		case op.ExpectDest != "" && dstSum == op.ExpectDest:
			// Allowed replacement: the destination still holds the content the
			// plan said it held.
		default:
			out.Status = StatusDestUnexpected
			out.Detail = fmt.Sprintf("destination %s holds unexpected content; refusing to overwrite it", dst)
			return out
		}
	} else if !os.IsNotExist(err) {
		out.Status = StatusFailed
		out.Detail = fmt.Sprintf("cannot stat destination %s: %v", dst, err)
		return out
	}

	if err := copyVerified(src, dst); err != nil {
		out.Status = StatusFailed
		out.Detail = err.Error()
		return out
	}
	out.Status = StatusDone
	out.Bytes = srcSize
	return out
}

// copyVerified writes src to dst.part, hashes what it wrote, compares it with a
// fresh hash of the source, and only then renames the .part into place.
func copyVerified(src, dst string) error {
	if dir := filepath.Dir(dst); dir != "" {
		if err := os.MkdirAll(dir, 0o755); err != nil {
			return fmt.Errorf("cannot create %s: %w", dir, err)
		}
	}
	in, err := os.Open(src)
	if err != nil {
		return fmt.Errorf("cannot open %s: %w", src, err)
	}
	defer in.Close()
	info, err := in.Stat()
	if err != nil {
		return fmt.Errorf("cannot stat %s: %w", src, err)
	}
	mode := info.Mode().Perm()
	if mode == 0 {
		mode = 0o644
	}

	tmp := dst + partSuffix
	out, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
	if err != nil {
		return fmt.Errorf("cannot create %s: %w", tmp, err)
	}
	h := sha256.New()
	written, cerr := io.Copy(io.MultiWriter(out, h), in)
	if cerr != nil {
		out.Close()
		_ = removeTemp(tmp)
		return fmt.Errorf("cannot copy %s -> %s: %w", src, tmp, cerr)
	}
	if err := out.Sync(); err != nil {
		out.Close()
		_ = removeTemp(tmp)
		return fmt.Errorf("cannot flush %s: %w", tmp, err)
	}
	if err := out.Close(); err != nil {
		_ = removeTemp(tmp)
		return fmt.Errorf("cannot close %s: %w", tmp, err)
	}

	// Verify what landed on disk against a fresh read of the source.
	wantSum, wantSize, err := hashFile(src)
	if err != nil {
		_ = removeTemp(tmp)
		return fmt.Errorf("cannot re-read source %s: %w", src, err)
	}
	gotSum := hex.EncodeToString(h.Sum(nil))
	if gotSum != wantSum || written != wantSize {
		_ = removeTemp(tmp)
		return fmt.Errorf("verification failed for %s: wrote %d bytes %s, source is %d bytes %s",
			dst, written, gotSum, wantSize, wantSum)
	}
	diskSum, diskSize, err := hashFile(tmp)
	if err != nil {
		_ = removeTemp(tmp)
		return fmt.Errorf("cannot verify %s: %w", tmp, err)
	}
	if diskSum != wantSum || diskSize != wantSize {
		_ = removeTemp(tmp)
		return fmt.Errorf("verification failed for %s: file on disk is %d bytes %s, source is %d bytes %s",
			dst, diskSize, diskSum, wantSize, wantSum)
	}

	if err := os.Chmod(tmp, mode); err != nil {
		_ = removeTemp(tmp)
		return fmt.Errorf("cannot set mode on %s: %w", tmp, err)
	}
	if err := os.Chtimes(tmp, time.Now(), info.ModTime()); err != nil {
		_ = removeTemp(tmp)
		return fmt.Errorf("cannot set times on %s: %w", tmp, err)
	}
	if err := os.Rename(tmp, dst); err != nil {
		_ = removeTemp(tmp)
		return fmt.Errorf("cannot install %s: %w", dst, err)
	}
	return nil
}

// advanceBaseline re-scans both sides after a sync and records the new agreed
// state, carrying forward what the old baseline already knew about paths that
// still do not agree. The previous baseline is kept alongside the new one,
// never overwritten in place.
func advanceBaseline(path, rootA, rootB string) (*BaselineAdvance, error) {
	old, err := loadManifest(path)
	if err != nil {
		if !errors.Is(err, errNoBaseline) {
			return nil, err
		}
		old = nil
	}
	exclude := map[string]bool{path: true, path + partSuffix: true}
	a, err := scanTree(rootA, exclude)
	if err != nil {
		return nil, err
	}
	b, err := scanTree(rootB, exclude)
	if err != nil {
		return nil, err
	}
	m, st := mergeBaseline(old, a, b)
	prev, err := writeManifest(path, m)
	if err != nil {
		return nil, err
	}
	return &BaselineAdvance{
		Path: path, PreviousKept: prev,
		Files: len(m.Files), Stats: st,
	}, nil
}
