package main

import (
	"bufio"
	"fmt"
	"log"
	"os"
	"path"
	"path/filepath"
	"regexp"
	"strings"
)

const (
	PYFILE_NOT_BYTECOMPILED_TAG        = "py-file-not-bytecompiled"
	PYSHARED_FILE_NOT_BYTECOMPILED_TAG = "pyshared-file-not-bytecompiled" // obsolete, kept for backwards compat
)

type pyfileErr struct {
	pkg  string
	path string
}

func (c pyfileErr) Error() string {
	if c == (pyfileErr{}) {
		return ""
	}
	return fmt.Sprintf("%s: %s %s", c.pkg, PYFILE_NOT_BYTECOMPILED_TAG, c.path)
}

type pyfileChecker struct {
	emit bool
}

func newPyfileChecker(tags tagFilter) pyfileChecker {
	return pyfileChecker{
		emit: tags.shouldEmit(PYFILE_NOT_BYTECOMPILED_TAG),
	}
}

func (cc pyfileChecker) check(pkg2files map[string][]string) []error {
	if !cc.emit {
		return nil
	}
	nopy3 := len(py3versions()) == 0
	pypyInstalled := pathExists("/usr/bin/pypy")
	nonSheBangPath := regexp.MustCompile("/usr/lib/python[0-9](?:[.][0-9]+)?(?:config-)")

	var tags []error
	for pkg, files := range pkg2files {
		for _, f := range files {
			if !strings.HasSuffix(f, ".py") ||
				byteCompilationNotNeeded.MatchString(f) ||
				!pathExists(f) ||
				pathExists(f+"c") ||
				// Don't expect third-party Python 3.X modules to be
				// byte-compiled if no supported Python 3.X version is
				// installed:
				(strings.HasPrefix(f, "/usr/lib/python3/dist-packages/") && nopy3) {

				continue
			}

			// Check for PEP-3147 *.pyc repository directories.
			impl := "cpython"
			if strings.HasPrefix(f, "/usr/lib/pypy/") {
				if !pypyInstalled {
					continue
				}
				impl = "pypy"
			}
			basename := strings.TrimSuffix(path.Base(f), ".py")
			g := fmt.Sprintf("%s/__pycache__/%s.%s-*.pyc", path.Dir(f), basename, impl)
			matches, err := filepath.Glob(g)
			if err != nil {
				log.Fatalf("Constructed bad glob pattern %q", g)
			}
			if len(matches) > 0 {
				continue
			}

			isExec, err := isExecutable(f)
			if err != nil {
				log.Fatalf("Failed to stat %q: %v", f, err)
			}
			if !nonSheBangPath.MatchString(f) && isExec && hasSheBang(f) {
				continue
			}

			tags = append(tags, pyfileErr{
				pkg:  pkg,
				path: f,
			})
		}
	}
	return tags
}

func py3versions() []string {
	const defaultsFile = "/usr/share/python3/debian_defaults"
	if !pathExists(defaultsFile) {
		return nil
	}
	buf, err := os.ReadFile(defaultsFile)
	if err != nil {
		log.Fatalf("Failed to read %q: %v\n", defaultsFile, err)
	}

	const fieldName = "supported-versions"
	for {
		advance, line, err := bufio.ScanLines(buf, true)
		switch {
		case err != nil:
			log.Fatalf("Failed to scan %q: %v\n", defaultsFile, err)
		case advance == 0:
			return nil // we got to EoF
		case advance <= len(buf):
			buf = buf[advance:]
		}

		// Sample line we're after:
		//     supported-versions = python3.11, python3.12, ...
		stripped := strings.Replace(string(line), " ", "", -1)
		if !strings.HasPrefix(stripped, fieldName+"=") {
			continue
		}
		return strings.Split(
			strings.Replace(
				stripped[len(fieldName)+1:], "python", "", -1),
			",")
	}
}

// hasSheBang returns true if any of the following is true:
// - f's first bytes match "#! ?/"
// - f is less than 4 bytes long
// - we have no permission to read it
//
// Calls log.Fatalf() on non-permission errors.
func hasSheBang(f string) bool {
	fi, err := os.Open(f)
	if err != nil {
		if os.IsPermission(err) {
			return true
		}
		log.Fatal("Open: ", err)
	}
	defer fi.Close()

	head := make([]byte, 4)
	n, err := fi.Read(head)
	if err != nil {
		log.Fatal("Read: ", err)
	}
	return (n < 4 || (head[0] == '#' && head[1] == '!' &&
		((head[2] == ' ' && head[3] == '/') ||
			head[2] == '/')))
}

var (
	pyfileExemptPaths = strings.Split(`
/etc/
|/bin/
|/sbin/
|/usr/bin/
|/usr/games/
|/usr/lib/debug/bin/
|/usr/lib/debug/sbin/
|/usr/lib/debug/usr/bin/
|/usr/lib/debug/usr/games/
|/usr/lib/debug/usr/sbin/
|/usr/lib/pypy/lib-python/\d[.]\d+/test/bad
|/usr/lib/pypy/lib-python/\d[.]\d+/lib2to3/tests/data/
|/usr/sbin/
|/usr/share/apport/package-hooks/
|/usr/share/doc/
|/usr/share/jython/
|/usr/share/paster_templates/
|/usr/lib/python\d[.]\d+/__phello__[.]foo[.]py$
|/usr/lib/python\d[.]\d+/lib2to3/tests/data/
|/usr/lib/python\d[.]\d+/test/bad`, "\n")[1:]

	byteCompilationNotNeeded = regexp.MustCompile(strings.Join(pyfileExemptPaths, ""))
)
