// Package diag collects everything needed to work out why USB sharing does
// not work on a given machine.
//
// It exists because the failure modes are platform specific and mostly
// invisible from the outside: a missing kernel module, a driver that did not
// load, permissions on a device node, a filter that is installed but not
// attached. Guessing at those across a chat is slow; a structured report
// turns it into a matter of reading.
package diag

import (
	"encoding/json"
	"fmt"
	"os"
	"runtime"
	"strings"
	"time"
)

// Report is the whole diagnostic picture of one machine.
type Report struct {
	// Generated is filled in by the caller, since a report is often written
	// and read at very different times.
	Generated string `json:"generated"`

	Tool    ToolInfo     `json:"tool"`
	System  SystemInfo   `json:"system"`
	Sharing Capability   `json:"sharing"`
	Using   Capability   `json:"using"`
	Devices []DeviceInfo `json:"devices"`
	Checks  []Check      `json:"checks"`

	// Notes carries anything that did not fit elsewhere, in plain language.
	Notes []string `json:"notes,omitempty"`
}

// ToolInfo identifies the build that produced the report.
type ToolInfo struct {
	Version   string `json:"version"`
	GoVersion string `json:"go_version"`
	OS        string `json:"os"`
	Arch      string `json:"arch"`
}

// SystemInfo describes the machine.
type SystemInfo struct {
	Hostname      string `json:"hostname"`
	OSVersion     string `json:"os_version,omitempty"`
	KernelVersion string `json:"kernel_version,omitempty"`
	Privileged    bool   `json:"privileged"`
	// Container reports whether we appear to be inside one, which changes
	// what device access means.
	Container bool `json:"container,omitempty"`
}

// Capability reports whether one half of the system can work here.
type Capability struct {
	Available bool   `json:"available"`
	Reason    string `json:"reason,omitempty"`
	// Mechanism names what would be used: "usbdevfs", "usbshare filter",
	// "vhci-hcd", "usbip-win2".
	Mechanism string `json:"mechanism,omitempty"`
}

// DeviceInfo is one USB device as the machine sees it.
type DeviceInfo struct {
	BusID     string `json:"bus_id,omitempty"`
	VendorID  string `json:"vendor_id"`
	ProductID string `json:"product_id"`
	Name      string `json:"name,omitempty"`
	Class     string `json:"class,omitempty"`
	Driver    string `json:"driver,omitempty"`
	Speed     string `json:"speed,omitempty"`

	// Shareable reports whether this device could actually be offered, and
	// Blocker says what stands in the way when it cannot.
	Shareable bool   `json:"shareable"`
	Blocker   string `json:"blocker,omitempty"`

	// Endpoints matter for diagnosing devices that attach but stay silent:
	// a wrong transfer type here is exactly that symptom.
	Endpoints []EndpointInfo `json:"endpoints,omitempty"`
}

// EndpointInfo is one endpoint of a device.
type EndpointInfo struct {
	Address      string `json:"address"`
	Direction    string `json:"direction"`
	TransferType string `json:"transfer_type"`
	MaxPacket    uint16 `json:"max_packet"`
	Interval     uint8  `json:"interval"`
}

// Check is one named test with a verdict.
type Check struct {
	Name   string `json:"name"`
	Passed bool   `json:"passed"`
	Detail string `json:"detail,omitempty"`
	// Fix is a concrete action, present only when the check failed and there
	// is something the user can actually do.
	Fix string `json:"fix,omitempty"`
}

// Collect gathers a report for the current machine.
func Collect(version string) *Report {
	hostname, _ := os.Hostname()

	report := &Report{
		Generated: time.Now().Format(time.RFC3339),
		Tool: ToolInfo{
			Version:   version,
			GoVersion: runtime.Version(),
			OS:        runtime.GOOS,
			Arch:      runtime.GOARCH,
		},
		System: SystemInfo{
			Hostname:   hostname,
			Privileged: isPrivileged(),
			Container:  inContainer(),
		},
	}

	collectPlatform(report)

	return report
}

// JSON renders the report for machine consumption.
func (r *Report) JSON() ([]byte, error) {
	return json.MarshalIndent(r, "", "  ")
}

// String renders the report for a human reading a terminal.
func (r *Report) String() string {
	var b strings.Builder

	fmt.Fprintf(&b, "USB Server diagnostics\n")
	fmt.Fprintf(&b, "======================\n\n")
	fmt.Fprintf(&b, "Host:     %s (%s/%s)\n", r.System.Hostname, r.Tool.OS, r.Tool.Arch)
	if r.System.OSVersion != "" {
		fmt.Fprintf(&b, "OS:       %s\n", r.System.OSVersion)
	}
	if r.System.KernelVersion != "" {
		fmt.Fprintf(&b, "Kernel:   %s\n", r.System.KernelVersion)
	}
	fmt.Fprintf(&b, "Elevated: %v\n", r.System.Privileged)
	if r.System.Container {
		fmt.Fprintf(&b, "Container: yes\n")
	}
	fmt.Fprintf(&b, "\n")

	fmt.Fprintf(&b, "Sharing devices: %s\n", capabilityLine(r.Sharing))
	fmt.Fprintf(&b, "Using devices:   %s\n", capabilityLine(r.Using))
	fmt.Fprintf(&b, "\n")

	if len(r.Checks) > 0 {
		fmt.Fprintf(&b, "Checks\n------\n")
		for _, c := range r.Checks {
			mark := "FAIL"
			if c.Passed {
				mark = " ok "
			}
			fmt.Fprintf(&b, "[%s] %s\n", mark, c.Name)
			if c.Detail != "" {
				fmt.Fprintf(&b, "       %s\n", c.Detail)
			}
			if !c.Passed && c.Fix != "" {
				fmt.Fprintf(&b, "       fix: %s\n", c.Fix)
			}
		}
		fmt.Fprintf(&b, "\n")
	}

	fmt.Fprintf(&b, "Devices (%d)\n-----------\n", len(r.Devices))
	for _, d := range r.Devices {
		state := "shareable"
		if !d.Shareable {
			state = "blocked: " + d.Blocker
		}
		fmt.Fprintf(&b, "%-12s %s:%s  %-28s %s\n",
			d.BusID, d.VendorID, d.ProductID, truncate(d.Name, 28), state)
		if d.Driver != "" {
			fmt.Fprintf(&b, "             driver=%s class=%s speed=%s\n", d.Driver, d.Class, d.Speed)
		}
		for _, ep := range d.Endpoints {
			fmt.Fprintf(&b, "             ep %s %-3s %-11s maxpkt=%d interval=%d\n",
				ep.Address, ep.Direction, ep.TransferType, ep.MaxPacket, ep.Interval)
		}
	}

	if len(r.Notes) > 0 {
		fmt.Fprintf(&b, "\nNotes\n-----\n")
		for _, n := range r.Notes {
			fmt.Fprintf(&b, "- %s\n", n)
		}
	}

	return b.String()
}

func capabilityLine(c Capability) string {
	if c.Available {
		if c.Mechanism != "" {
			return "yes (" + c.Mechanism + ")"
		}
		return "yes"
	}
	if c.Reason != "" {
		return "no — " + c.Reason
	}
	return "no"
}

func truncate(s string, max int) string {
	if len(s) <= max {
		return s
	}
	if max <= 3 {
		return s[:max]
	}
	return s[:max-3] + "..."
}

// addCheck appends a check result.
func (r *Report) addCheck(name string, passed bool, detail, fix string) {
	r.Checks = append(r.Checks, Check{
		Name:   name,
		Passed: passed,
		Detail: detail,
		Fix:    fix,
	})
}

// note appends a free-form observation.
func (r *Report) note(format string, args ...interface{}) {
	r.Notes = append(r.Notes, fmt.Sprintf(format, args...))
}

// inContainer guesses whether this process runs inside a container.
//
// It matters for diagnosis: inside a container, missing devices usually mean
// the container was not given access, not that the host lacks them.
func inContainer() bool {
	if _, err := os.Stat("/.dockerenv"); err == nil {
		return true
	}
	if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
		content := string(data)
		if strings.Contains(content, "docker") || strings.Contains(content, "containerd") ||
			strings.Contains(content, "lxc") {
			return true
		}
	}
	return false
}
