//
// Copyright (c) SAS Institute Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

// A means of conveying a series of edits to binary files. Each item in a
// patchset consists of an offset into the old file, the number of bytes to
// remove, and the octet string to replace it with.
package binpatch

import (
	"bytes"
	"encoding/binary"
	"errors"
	"fmt"
	"io"
	"os"

	"github.com/sassoftware/relic/lib/atomicfile"
)

const (
	MimeType = "application/x-binary-patch"

	uint32Max = 0xffffffff
)

type PatchSet struct {
	Patches []PatchHeader
	Blobs   [][]byte
}

type PatchSetHeader struct {
	Version, NumPatches uint32
}

type PatchHeader struct {
	Offset           int64
	OldSize, NewSize uint32
}

// Create a new, empty PatchSet
func New() *PatchSet {
	return new(PatchSet)
}

// Add a new patch region to a PatchSet. The bytes beginning at "offset" and
// running for "oldSize" are removed and replaced with "blob". oldSize may be 0.
func (p *PatchSet) Add(offset, oldSize int64, blob []byte) {
	if len(p.Patches) > 0 {
		i := len(p.Patches) - 1
		last := p.Patches[i]
		lastEnd := last.Offset + int64(last.OldSize)
		lastBlob := p.Blobs[i]
		oldCombo := int64(last.OldSize) + oldSize
		newCombo := int64(len(lastBlob)) + int64(len(blob))
		if offset == lastEnd && oldCombo <= uint32Max && newCombo <= uint32Max {
			// coalesce this patch into the previous one
			p.Patches[i].OldSize = uint32(oldCombo)
			p.Patches[i].NewSize = uint32(newCombo)
			if len(blob) > 0 {
				newBlob := make([]byte, newCombo)
				copy(newBlob, lastBlob)
				copy(newBlob[len(lastBlob):], blob)
				p.Blobs[i] = newBlob
			}
			return
		}
	}
	for oldSize > uint32Max {
		p.Patches = append(p.Patches, PatchHeader{offset, uint32Max, 0})
		p.Blobs = append(p.Blobs, nil)
		offset += uint32Max
		oldSize -= uint32Max
	}
	p.Patches = append(p.Patches, PatchHeader{offset, uint32(oldSize), uint32(len(blob))})
	p.Blobs = append(p.Blobs, blob)
}

// Unmarshal a PatchSet from bytes
func Load(blob []byte) (*PatchSet, error) {
	r := bytes.NewReader(blob)
	var h PatchSetHeader
	if err := binary.Read(r, binary.BigEndian, &h); err != nil {
		return nil, err
	} else if h.Version != 1 {
		return nil, fmt.Errorf("unsupported binpatch version %d", h.Version)
	}
	num := int(h.NumPatches)
	p := &PatchSet{
		Patches: make([]PatchHeader, num),
		Blobs:   make([][]byte, num),
	}
	if err := binary.Read(r, binary.BigEndian, p.Patches); err != nil {
		return nil, err
	}
	for i, hdr := range p.Patches {
		p.Blobs[i] = make([]byte, int(hdr.NewSize))
		if _, err := io.ReadFull(r, p.Blobs[i]); err != nil {
			return nil, err
		}
	}
	return p, nil
}

// Marshal a PatchSet to bytes
func (p *PatchSet) Dump() []byte {
	header := PatchSetHeader{1, uint32(len(p.Patches))}
	size := 8 + 16*len(p.Patches)
	for _, hdr := range p.Patches {
		size += int(hdr.NewSize)
	}
	buf := bytes.NewBuffer(make([]byte, 0, size))
	binary.Write(buf, binary.BigEndian, header)
	binary.Write(buf, binary.BigEndian, p.Patches)
	for _, blob := range p.Blobs {
		buf.Write(blob)
	}
	return buf.Bytes()
}

// Apply a PatchSet by taking the input file, transforming it, and writing the
// result to outpath. If outpath is the same name as infile then the file will
// be updated in-place if a direct overwrite is possible. If they are not the
// same file, or the patch requires moving parts of the old file, then the
// output will be written to a temporary file then renamed over the destination
// path.
func (p *PatchSet) Apply(infile *os.File, outpath string) error {
	if outpath == "" {
		outpath = infile.Name()
	}
	// Determine if an in-place overwrite is possible. If any test fails then
	// fall back to doing a full copy (write-rename).
	ininfo, err := infile.Stat()
	if err != nil {
		return p.applyRewrite(infile, outpath)
	}
	outinfo, err := os.Lstat(outpath)
	if err != nil || !canOverwrite(ininfo, outinfo) {
		return p.applyRewrite(infile, outpath)
	}
	size := ininfo.Size()
	for i, patch := range p.Patches {
		// All patches except the last must have oldsize == newsize
		if patch.OldSize == patch.NewSize {
			continue
		} else if i != len(p.Patches)-1 {
			return p.applyRewrite(infile, outpath)
		}
		// For the last patch, either oldsize == newsize or the patch must extend
		// or truncate the file, i.e. the end of the old chunk must coincide
		// with the end of the file.
		oldEnd := patch.Offset + int64(patch.OldSize)
		if oldEnd != ininfo.Size() {
			return p.applyRewrite(infile, outpath)
		}
		size = patch.Offset + int64(patch.NewSize)
	}
	// Do in-place rewrite
	for i, patch := range p.Patches {
		if _, err := infile.WriteAt(p.Blobs[i], patch.Offset); err != nil {
			return err
		}
	}
	return infile.Truncate(size)
}

// Apply a patch by writing the patched result to a new file. This is the
// fallback case whenever an in-place write isn't possible.
func (p *PatchSet) applyRewrite(infile *os.File, outpath string) error {
	if _, err := infile.Seek(0, 0); err != nil {
		return err
	}
	outfile, err := atomicfile.New(outpath)
	if err != nil {
		return err
	}
	defer outfile.Close()
	var pos int64
	for i, patch := range p.Patches {
		blob := p.Blobs[i]
		delta := patch.Offset - pos
		if delta < 0 {
			return errors.New("patches out of order")
		}
		// Copy data before the patch
		if delta > 0 {
			if _, err := io.CopyN(outfile, infile, delta); err != nil {
				return err
			}
			pos += delta
		}
		// Skip the old data on the input file
		delta = int64(patch.OldSize)
		if _, err := infile.Seek(delta, io.SeekCurrent); err != nil {
			return err
		}
		pos += delta
		// Write the new data to the output file
		if _, err := outfile.Write(blob); err != nil {
			return err
		}
	}
	// Copy everything after the last patch
	if _, err := io.Copy(outfile, infile); err != nil {
		return err
	}
	infile.Close()
	return outfile.Commit()
}

func canOverwrite(ininfo, outinfo os.FileInfo) bool {
	if !outinfo.Mode().IsRegular() {
		return false
	}
	if !os.SameFile(ininfo, outinfo) {
		return false
	}
	if hasLinks(outinfo) {
		return false
	}
	return true
}
