package lsof

// Copyright 2015 Keybase, Inc. All rights reserved. Use of
// this source code is governed by the included BSD license.

// CREDIT: https://github.com/keybase/client/tree/master/go/lsof

import (
	"fmt"
	"os/exec"
	"strings"
)

// Process defines a process using an open file. Properties here are strings
// for compatibility with different platforms.
type Process struct {
	PID             string
	Command         string
	UserID          string
	FileDescriptors []FileDescriptor
}

func (p Process) String() string {
	var fds []string
	for _, fd := range p.FileDescriptors {
		fds = append(fds, fmt.Sprintf("\n      %s", fd))
	}
	return fmt.Sprintf(
		"pid=%s, uid=%s '%s'%s",
		p.PID, p.UserID, p.Command, strings.Join(fds, ""),
	)
}

// FileType defines the type of file in use by a process
type FileType string

const (
	FileTypeUnknown FileType = ""
	FileTypeDir     FileType = "DIR"
	FileTypeFile    FileType = "REG"
)

// FileDescriptor defines a file in use by a process
type FileDescriptor struct {
	FD   string
	Type FileType
	Name string
}

func (f FileDescriptor) String() string {
	return fmt.Sprintf("fd=%s, type=%s, name=%s", f.FD, f.Type, f.Name)
}

// ExecError is an error running lsof
type ExecError struct {
	command string
	args    []string
	output  string
	err     error
}

func (e ExecError) Error() string {
	return fmt.Sprintf("error running %s %s: %s (%s)", e.command, e.args, e.err, e.output)
}

// MountPoint returns processes using the mountpoint "lsof /dir"
func MountPoint(dir string) ([]Process, error) {
	// TODO: Fix lsof to not return error on exit status 1 since it isn't
	// really any error, only an indication that there was no use of the
	// mount.
	return run([]string{"-F", "pcuftn", dir})
}

func fileTypeFromString(s string) FileType {
	switch s {
	case "DIR":
		return FileTypeDir
	case "REG":
		return FileTypeFile
	default:
		return FileTypeUnknown
	}
}

func (p *Process) fillField(s string) error {
	if s == "" {
		return fmt.Errorf("empty field")
	}
	// See Output for Other Programs at http://linux.die.net/man/8/lsof
	key := s[0]
	value := s[1:]
	switch key {
	case 'p':
		p.PID = value
	case 'c':
		p.Command = value
	case 'u':
		p.UserID = value
	default:
		// Skip unhandled field
	}
	return nil
}

func (f *FileDescriptor) fillField(s string) error {
	// See Output for Other Programs at http://linux.die.net/man/8/lsof
	key := s[0]
	value := s[1:]
	switch key {
	case 't':
		f.Type = fileTypeFromString(value)
	case 'f':
		f.FD = value
	case 'n':
		f.Name = value
	default:
		// Skip unhandled field
	}

	return nil
}

func (p *Process) parseFileLines(lines []string) error {
	file := FileDescriptor{}
	for _, line := range lines {
		if strings.HasPrefix(line, "f") && file.FD != "" {
			// New file
			p.FileDescriptors = append(p.FileDescriptors, file)
			file = FileDescriptor{}
		}
		err := file.fillField(line)
		if err != nil {
			return err
		}
	}
	if file.FD != "" {
		p.FileDescriptors = append(p.FileDescriptors, file)
	}
	return nil
}

func parseProcessLines(lines []string) (Process, error) {
	p := Process{}
	for index, line := range lines {
		if strings.HasPrefix(line, "f") {
			err := p.parseFileLines(lines[index:])
			if err != nil {
				return p, err
			}
			break
		} else {
			err := p.fillField(line)
			if err != nil {
				return p, err
			}
		}
	}
	return p, nil
}

func parseAppendProcessLines(processes []Process, linesChunk []string) ([]Process, []string, error) {
	if len(linesChunk) == 0 {
		return processes, linesChunk, nil
	}
	process, err := parseProcessLines(linesChunk)
	if err != nil {
		return processes, linesChunk, err
	}
	processesAfter := processes
	processesAfter = append(processesAfter, process)
	linesChunkAfter := []string{}
	return processesAfter, linesChunkAfter, nil
}

func parse(s string) ([]Process, error) {
	lines := strings.Split(s, "\n")
	linesChunk := []string{}
	processes := []Process{}
	var err error
	for _, line := range lines {
		if strings.TrimSpace(line) == "" {
			continue
		}

		// End of process, let's parse those lines
		if strings.HasPrefix(line, "p") && len(linesChunk) > 0 {
			processes, linesChunk, err = parseAppendProcessLines(processes, linesChunk)
			if err != nil {
				return nil, err
			}
		}
		linesChunk = append(linesChunk, line)
	}
	processes, _, err = parseAppendProcessLines(processes, linesChunk)
	if err != nil {
		return nil, err
	}
	return processes, nil
}

func run(args []string) ([]Process, error) {
	// Some systems (Arch, Debian) install lsof in /usr/bin and others (centos)
	// install it in /usr/sbin, even though regular users can use it too. FreeBSD,
	// on the other hand, puts it in /usr/local/sbin. So do not specify absolute path.
	command := "lsof"
	args = append([]string{"-w"}, args...)
	output, err := exec.Command(command, args...).Output()
	if err != nil {
		return nil, ExecError{command: command, args: args, output: string(output), err: err}
	}
	return parse(string(output))
}
