// Mgmt
// Copyright (C) 2013-2024+ James Shubin and the project contributors
// Written by James Shubin <james@shubin.ca> and the project contributors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program.  If not, see <https://www.gnu.org/licenses/>.
//
// Additional permission under GNU GPL version 3 section 7
//
// If you modify this program, or any covered work, by linking or combining it
// with embedded mcl code and modules (and that the embedded mcl code and
// modules which link with this program, contain a copy of their source code in
// the authoritative form) containing parts covered by the terms of any other
// license, the licensors of this program grant you additional permission to
// convey the resulting work. Furthermore, the licensors of this program grant
// the original author, James Shubin, additional permission to update this
// additional permission if he deems it necessary to achieve the goals of this
// additional permission.

// This particular file is dual-licensed. It's available under the GNU LGPL-3.0+
// and the GNU GPL-3.0+ so that it can be used by other projects easily. If it's
// popular we can consider spinning it out into its own separate git repository.
// SPDX-License-Identifier: GPL-3.0+ OR LGPL-3.0+

// Package safepath implements some types and methods for dealing with POSIX
// file paths safely. When golang programmers use strings to express paths, it
// can sometimes be confusing whether a particular string represents either a
// file or a directory, and whether it is absolute or relative. This package
// provides a type for each of these, and safe methods to manipulate them all.
// The convention is that directories must end with a slash, and absolute paths
// must start with one. There are no generic "path" types for now, you must be
// more specific when using this library. If you can't discern exactly what you
// are, then use a string. It is your responsibility to build the type correctly
// and to call Validate on them to ensure you've done it correctly. If you
// don't, then you could cause a panic.
//
// As a reminder, this library knows nothing about the special path characters
// like period (as in ./) and it only knows about two periods (..) in so far as
// it uses the stdlib Clean method when pulling in new paths.
//
// The ParseInto* family of functions will sometimes add or remove a trailing
// slash to ensure you get a directory or file. It is recommended that you make
// sure to verify your desired type is what you expect before calling this.
package safepath

// NOTE: I started the design of this library by thinking about what types I
// wanted. Absolute and relative files and directories. I don't think there's a
// need to handle symlinks at the moment. This means I'll need four types. Next
// I need to work out what possible operations are valid. My sketch of that is:
//
// join(absdir, relfile) => absfile
// join(absdir, reldir) => absdir
// join(reldir, relfile) => relfile
// join(reldir, reldir) => reldir
// join(absdir, absdir) => error
// join(absdir, absfile) => error
// join(absfile, absfile) => error
// join(relfile, relfile) => error
//
// This isn't Haskell, so I'll either need four separate functions or one join
// function that takes interfaces. The latter would be a big unsafe mess. As it
// turns out, there is exactly one join operation that produces each of
// the four types. So instead of naming each one like `JoinAbsDirRelFile` and so
// on, I decided to name them based on their return type.
//
// Be consistent! Order: file, dir, abs, rel, absfile, absdir, relfile, reldir.
//
// This could probably get spun off into it's own standalone library.

import (
	"fmt"
	stdlibPath "path"
	"strings"
)

// Path represents any absolute or relative file or directory.
type Path interface {
	fmt.Stringer
	Path() string
	IsDir() bool
	IsAbs() bool

	isPath() // private to prevent others from implementing this (ok?)
}

// File represents either an absolute or relative file. Directories are not
// included.
type File interface {
	fmt.Stringer
	Path() string
	//IsDir() bool // TODO: add this to allow a File to become a Path?
	IsAbs() bool

	isFile() // only the files have this
}

// Dir represents either an absolute or relative directory. Files are not
// included.
type Dir interface {
	fmt.Stringer
	Path() string
	//IsDir() bool // TODO: add this to allow a Dir to become a Path?
	IsAbs() bool

	isDir() // only the dirs have this
}

// Abs represents an absolute file or directory. Relative paths are not
// included.
type Abs interface {
	fmt.Stringer
	Path() string
	IsDir() bool
	//IsAbs() bool // TODO: add this to allow an Abs to become a Path?

	isAbs() // only the abs have this
}

// Rel represents a relative file or directory. Absolute paths are not included.
type Rel interface {
	fmt.Stringer
	Path() string
	IsDir() bool
	//IsAbs() bool // TODO: add this to allow a Rel to become a Path?

	isRel() // only the rel have this
}

// AbsFile represents an absolute file path.
type AbsFile struct {
	path string
}

func (obj AbsFile) isAbs()  {}
func (obj AbsFile) isFile() {}
func (obj AbsFile) isPath() {}

// String returns the canonical "friendly" representation of this path. If it is
// a directory, then it will end with a slash.
func (obj AbsFile) String() string { return obj.path }

// Path returns the cleaned version of this path. It is what you expect after
// running the golang path cleaner on the internal representation.
func (obj AbsFile) Path() string { return stdlibPath.Clean(obj.path) }

// IsDir returns false for this struct.
func (obj AbsFile) IsDir() bool { return false }

// IsAbs returns true for this struct.
func (obj AbsFile) IsAbs() bool { return true }

// Validate returns an error if the path was not specified correctly.
func (obj AbsFile) Validate() error {
	if !strings.HasPrefix(obj.path, "/") {
		return fmt.Errorf("file is not absolute")
	}

	if strings.HasSuffix(obj.path, "/") {
		return fmt.Errorf("path is not a file")
	}

	return nil
}

// PanicValidate panics if the path was not specified correctly.
func (obj AbsFile) PanicValidate() {
	if err := obj.Validate(); err != nil {
		panic(err.Error())
	}
}

// Cmp compares two AbsFile's and returns nil if they have the same path.
func (obj AbsFile) Cmp(absFile AbsFile) error {
	if obj.path != absFile.path {
		return fmt.Errorf("files differ")
	}
	return nil
}

// Base returns the last component of the AbsFile, in this case, the filename.
func (obj AbsFile) Base() RelFile {
	obj.PanicValidate()
	ix := strings.LastIndex(obj.path, "/")
	return RelFile{
		path: obj.path[ix+1:],
	}
}

// Dir returns the head component of the AbsFile, in this case, the directory.
func (obj AbsFile) Dir() AbsDir {
	obj.PanicValidate()
	ix := strings.LastIndex(obj.path, "/")
	if ix == 0 {
		return AbsDir{
			path: "/",
		}
	}
	return AbsDir{
		path: obj.path[0:ix],
	}
}

// HasDir returns true if the input relative dir is present in the path.
func (obj AbsFile) HasDir(relDir RelDir) bool {
	obj.PanicValidate()
	relDir.PanicValidate()
	//if obj.path == "/" {
	//	return false
	//}
	// TODO: test with ""

	i := strings.Index(obj.path, relDir.path)
	if i == -1 {
		return false // not found
	}
	if i == 0 {
		// not possible unless relDir is /
		//return false // found the root dir
		panic("relDir was root which isn't relative")
	}
	// We want to make sure we land on a split char, or we didn't match it.
	// We don't need to check the last char, because we know it's a /
	return obj.path[i-1] == '/' // check if the char before is a /
}

// HasExt checks if the file ends with the given extension. It checks for an
// exact string match. You might prefer using HasExtInsensitive instead. As a
// special case, if you pass in an empty string as the extension to match, this
// will return false.
// TODO: add tests
func (obj AbsFile) HasExt(ext string) bool {
	obj.PanicValidate()

	if ext == "" { // special case, not consistent with strings.HasSuffix
		return false
	}

	if !strings.HasSuffix(obj.path, ext) {
		return false
	}

	return true
}

// HasExtInsensitive checks if the file ends with the given extension. It checks
// with a fancy case-insensitive match. As a special case, if you pass in an
// empty string as the extension to match, this will return false.
func (obj AbsFile) HasExtInsensitive(ext string) bool {
	obj.PanicValidate()

	return hasExtInsensitive(obj.path, ext)
}

// ParseIntoAbsFile takes an input path and ensures it's an AbsFile. It doesn't
// do anything particularly magical. It then runs Validate to ensure the path
// was valid overall. It also runs the stdlib path Clean function on it. Please
// note, that passing in the root slash / will cause this to fail.
func ParseIntoAbsFile(path string) (AbsFile, error) {
	if path == "" {
		return AbsFile{}, fmt.Errorf("path is empty")
	}

	path = stdlibPath.Clean(path)

	absFile := AbsFile{path: path}
	return absFile, absFile.Validate()
}

// UnsafeParseIntoAbsFile performs exactly as ParseIntoAbsFile does, but it
// panics if the latter would have returned an error.
func UnsafeParseIntoAbsFile(path string) AbsFile {
	absFile, err := ParseIntoAbsFile(path)
	if err != nil {
		panic(err.Error())
	}
	return absFile
}

// AbsDir represents an absolute dir path.
type AbsDir struct {
	path string
}

func (obj AbsDir) isAbs()  {}
func (obj AbsDir) isDir()  {}
func (obj AbsDir) isPath() {}

// String returns the canonical "friendly" representation of this path. If it is
// a directory, then it will end with a slash.
func (obj AbsDir) String() string { return obj.path }

// Path returns the cleaned version of this path. It is what you expect after
// running the golang path cleaner on the internal representation.
func (obj AbsDir) Path() string { return stdlibPath.Clean(obj.path) }

// IsDir returns true for this struct.
func (obj AbsDir) IsDir() bool { return true }

// IsAbs returns true for this struct.
func (obj AbsDir) IsAbs() bool { return true }

// Validate returns an error if the path was not specified correctly.
func (obj AbsDir) Validate() error {
	if !strings.HasPrefix(obj.path, "/") {
		return fmt.Errorf("dir is not absolute")
	}

	if !strings.HasSuffix(obj.path, "/") {
		return fmt.Errorf("path is not a dir")
	}

	return nil
}

// PanicValidate panics if the path was not specified correctly.
func (obj AbsDir) PanicValidate() {
	if err := obj.Validate(); err != nil {
		panic(err.Error())
	}
}

// Cmp compares two AbsDir's and returns nil if they have the same path.
func (obj AbsDir) Cmp(absDir AbsDir) error {
	if obj.path != absDir.path {
		return fmt.Errorf("dirs differ")
	}
	return nil
}

// HasDir returns true if the input relative dir is present in the path.
func (obj AbsDir) HasDir(relDir RelDir) bool {
	obj.PanicValidate()
	relDir.PanicValidate()
	if obj.path == "/" {
		return false
	}
	// TODO: test with ""

	i := strings.Index(obj.path, relDir.path)
	if i == -1 {
		return false // not found
	}
	if i == 0 {
		// not possible unless relDir is /
		//return false // found the root dir
		panic("relDir was root which isn't relative")
	}
	// We want to make sure we land on a split char, or we didn't match it.
	// We don't need to check the last char, because we know it's a /
	return obj.path[i-1] == '/' // check if the char before is a /
}

// HasDirOne returns true if the input relative dir is present in the path. It
// only works with a single dir as relDir, so it won't work if relDir is `a/b/`.
func (obj AbsDir) HasDirOne(relDir RelDir) bool {
	obj.PanicValidate()
	relDir.PanicValidate()
	if obj.path == "/" {
		return false
	}
	// TODO: test with ""
	sa := strings.Split(obj.path, "/")
	for i := 1; i < len(sa)-1; i++ {
		p := sa[i] + "/"
		if p == relDir.path {
			return true
		}
	}
	return false
}

// ParseIntoAbsDir takes an input path and ensures it's an AbsDir, by adding a
// trailing slash if it's missing. It then runs Validate to ensure the path was
// valid overall. It also runs the stdlib path Clean function on it.
func ParseIntoAbsDir(path string) (AbsDir, error) {
	if path == "" {
		return AbsDir{}, fmt.Errorf("path is empty")
	}

	path = stdlibPath.Clean(path)

	// NOTE: after clean we won't have a trailing slash I think ;)
	if !strings.HasSuffix(path, "/") { // add trailing slash if missing
		path += "/"
	}

	absDir := AbsDir{path: path}
	return absDir, absDir.Validate()
}

// UnsafeParseIntoAbsDir performs exactly as ParseIntoAbsDir does, but it panics
// if the latter would have returned an error.
func UnsafeParseIntoAbsDir(path string) AbsDir {
	absDir, err := ParseIntoAbsDir(path)
	if err != nil {
		panic(err.Error())
	}
	return absDir
}

// RelFile represents a relative file path.
type RelFile struct {
	path string
}

func (obj RelFile) isRel()  {}
func (obj RelFile) isFile() {}
func (obj RelFile) isPath() {}

// String returns the canonical "friendly" representation of this path. If it is
// a directory, then it will end with a slash.
func (obj RelFile) String() string { return obj.path }

// Path returns the cleaned version of this path. It is what you expect after
// running the golang path cleaner on the internal representation.
func (obj RelFile) Path() string { return stdlibPath.Clean(obj.path) }

// IsDir returns false for this struct.
func (obj RelFile) IsDir() bool { return false }

// IsAbs returns false for this struct.
func (obj RelFile) IsAbs() bool { return false }

// Validate returns an error if the path was not specified correctly.
func (obj RelFile) Validate() error {
	if strings.HasPrefix(obj.path, "/") {
		return fmt.Errorf("file is not relative")
	}

	if strings.HasSuffix(obj.path, "/") {
		return fmt.Errorf("path is not a file")
	}

	if obj.path == "" {
		return fmt.Errorf("path is empty")
	}

	return nil
}

// PanicValidate panics if the path was not specified correctly.
func (obj RelFile) PanicValidate() {
	if err := obj.Validate(); err != nil {
		panic(err.Error())
	}
}

// Cmp compares two RelFile's and returns nil if they have the same path.
func (obj RelFile) Cmp(relfile RelFile) error {
	if obj.path != relfile.path {
		return fmt.Errorf("files differ")
	}
	return nil
}

// HasDir returns true if the input relative dir is present in the path.
func (obj RelFile) HasDir(relDir RelDir) bool {
	obj.PanicValidate()
	relDir.PanicValidate()
	//if obj.path == "/" {
	//	return false
	//}
	// TODO: test with ""

	i := strings.Index(obj.path, relDir.path)
	if i == -1 {
		return false // not found
	}
	if i == 0 {
		return true // found at the beginning
	}
	// We want to make sure we land on a split char, or we didn't match it.
	// We don't need to check the last char, because we know it's a /
	return obj.path[i-1] == '/' // check if the char before is a /
}

// HasExt checks if the file ends with the given extension. It checks for an
// exact string match. You might prefer using HasExtInsensitive instead. As a
// special case, if you pass in an empty string as the extension to match, this
// will return false.
// TODO: add tests
func (obj RelFile) HasExt(ext string) bool {
	obj.PanicValidate()

	if ext == "" { // special case, not consistent with strings.HasSuffix
		return false
	}

	if !strings.HasSuffix(obj.path, ext) {
		return false
	}

	return true
}

// HasExtInsensitive checks if the file ends with the given extension. It checks
// with a fancy case-insensitive match. As a special case, if you pass in an
// empty string as the extension to match, this will return false.
func (obj RelFile) HasExtInsensitive(ext string) bool {
	obj.PanicValidate()

	return hasExtInsensitive(obj.path, ext)
}

// ParseIntoRelFile takes an input path and ensures it's an RelFile. It doesn't
// do anything particularly magical. It then runs Validate to ensure the path
// was valid overall. It also runs the stdlib path Clean function on it.
func ParseIntoRelFile(path string) (RelFile, error) {
	if path == "" {
		return RelFile{}, fmt.Errorf("path is empty")
	}

	path = stdlibPath.Clean(path)

	relFile := RelFile{path: path}
	return relFile, relFile.Validate()
}

// UnsafeParseIntoRelFile performs exactly as ParseIntoRelFile does, but it
// panics if the latter would have returned an error.
func UnsafeParseIntoRelFile(path string) RelFile {
	relFile, err := ParseIntoRelFile(path)
	if err != nil {
		panic(err.Error())
	}
	return relFile
}

// RelDir represents a relative dir path.
type RelDir struct {
	path string
}

func (obj RelDir) isRel()  {}
func (obj RelDir) isDir()  {}
func (obj RelDir) isPath() {}

// String returns the canonical "friendly" representation of this path. If it is
// a directory, then it will end with a slash.
func (obj RelDir) String() string { return obj.path }

// Path returns the cleaned version of this path. It is what you expect after
// running the golang path cleaner on the internal representation.
func (obj RelDir) Path() string { return stdlibPath.Clean(obj.path) }

// IsDir returns true for this struct.
func (obj RelDir) IsDir() bool { return true }

// IsAbs returns false for this struct.
func (obj RelDir) IsAbs() bool { return false }

// Validate returns an error if the path was not specified correctly.
func (obj RelDir) Validate() error {
	if strings.HasPrefix(obj.path, "/") {
		return fmt.Errorf("dir is not relative")
	}

	if !strings.HasSuffix(obj.path, "/") {
		return fmt.Errorf("path is not a dir")
	}

	return nil
}

// PanicValidate panics if the path was not specified correctly.
func (obj RelDir) PanicValidate() {
	if err := obj.Validate(); err != nil {
		panic(err.Error())
	}
}

// Cmp compares two RelDir's and returns nil if they have the same path.
func (obj RelDir) Cmp(relDir RelDir) error {
	if obj.path != relDir.path {
		return fmt.Errorf("dirs differ")
	}
	return nil
}

// HasDir returns true if the input relative dir is present in the path.
func (obj RelDir) HasDir(relDir RelDir) bool {
	obj.PanicValidate()
	relDir.PanicValidate()
	//if obj.path == "/" {
	//	return false
	//}
	// TODO: test with ""

	i := strings.Index(obj.path, relDir.path)
	if i == -1 {
		return false // not found
	}
	if i == 0 {
		return true // found at the beginning
	}
	// We want to make sure we land on a split char, or we didn't match it.
	// We don't need to check the last char, because we know it's a /
	return obj.path[i-1] == '/' // check if the char before is a /
}

// HasDirOne returns true if the input relative dir is present in the path. It
// only works with a single dir as relDir, so it won't work if relDir is `a/b/`.
func (obj RelDir) HasDirOne(relDir RelDir) bool {
	obj.PanicValidate()
	relDir.PanicValidate()
	// TODO: test with "" and "/"
	sa := strings.Split(obj.path, "/")
	for i := 1; i < len(sa)-1; i++ {
		p := sa[i] + "/"
		if p == relDir.path {
			return true
		}
	}
	return false
}

// ParseIntoRelDir takes an input path and ensures it's an RelDir, by adding a
// trailing slash if it's missing. It then runs Validate to ensure the path was
// valid overall. It also runs the stdlib path Clean function on it.
func ParseIntoRelDir(path string) (RelDir, error) {
	if path == "" {
		return RelDir{}, fmt.Errorf("path is empty")
	}

	path = stdlibPath.Clean(path)

	// NOTE: after clean we won't have a trailing slash I think ;)
	if !strings.HasSuffix(path, "/") { // add trailing slash if missing
		path += "/"
	}

	relDir := RelDir{path: path}
	return relDir, relDir.Validate()
}

// UnsafeParseIntoRelDir performs exactly as ParseIntoRelDir does, but it panics
// if the latter would have returned an error.
func UnsafeParseIntoRelDir(path string) RelDir {
	relDir, err := ParseIntoRelDir(path)
	if err != nil {
		panic(err.Error())
	}
	return relDir
}

// AbsPath represents an absolute file or dir path.
type AbsPath struct {
	path string
}

func (obj AbsPath) isAbs()  {}
func (obj AbsPath) isPath() {}

// String returns the canonical "friendly" representation of this path. If it is
// a directory, then it will end with a slash.
func (obj AbsPath) String() string { return obj.path }

// Path returns the cleaned version of this path. It is what you expect after
// running the golang path cleaner on the internal representation.
func (obj AbsPath) Path() string { return stdlibPath.Clean(obj.path) }

// IsDir returns true if this is a dir, and false if it's not based on the path
// stored within and the parsing criteria in the IsDir helper function.
func (obj AbsPath) IsDir() bool { return IsDir(obj.path) }

// IsAbs returns true for this struct.
func (obj AbsPath) IsAbs() bool { return true }

// Validate returns an error if the path was not specified correctly.
func (obj AbsPath) Validate() error {
	if !strings.HasPrefix(obj.path, "/") {
		return fmt.Errorf("path is not absolute")
	}

	return nil
}

// PanicValidate panics if the path was not specified correctly.
func (obj AbsPath) PanicValidate() {
	if err := obj.Validate(); err != nil {
		panic(err.Error())
	}
}

// Cmp compares two AbsPath's and returns nil if they have the same path.
func (obj AbsPath) Cmp(absPath AbsPath) error {
	if obj.path != absPath.path {
		return fmt.Errorf("paths differ")
	}
	return nil
}

// Dir returns the head component of the AbsPath, in this case, the directory.
func (obj AbsPath) Dir() AbsDir {
	obj.PanicValidate()
	ix := strings.LastIndex(obj.path, "/")
	if ix == 0 {
		return AbsDir{
			path: "/",
		}
	}
	return AbsDir{
		path: obj.path[0:ix],
	}
}

// HasDir returns true if the input relative dir is present in the path.
// TODO: write tests for this and ensure it doesn't have a bug
func (obj AbsPath) HasDir(relDir RelDir) bool {
	obj.PanicValidate()
	relDir.PanicValidate()
	//if obj.path == "/" {
	//	return false
	//}
	// TODO: test with ""

	i := strings.Index(obj.path, relDir.path)
	if i == -1 {
		return false // not found
	}
	if i == 0 {
		// not possible unless relDir is /
		//return false // found the root dir
		panic("relDir was root which isn't relative")
	}
	// We want to make sure we land on a split char, or we didn't match it.
	// We don't need to check the last char, because we know it's a /
	return obj.path[i-1] == '/' // check if the char before is a /
}

// ParseIntoAbsPath takes an input path and ensures it's an AbsPath. It doesn't
// do anything particularly magical. It then runs Validate to ensure the path
// was valid overall. It also runs the stdlib path Clean function on it. Please
// note, that passing in the root slash / will cause this to fail.
func ParseIntoAbsPath(path string) (AbsPath, error) {
	if path == "" {
		return AbsPath{}, fmt.Errorf("path is empty")
	}

	path = stdlibPath.Clean(path)

	absPath := AbsPath{path: path}
	return absPath, absPath.Validate()
}

// UnsafeParseIntoAbsPath performs exactly as ParseIntoAbsPath does, but it
// panics if the latter would have returned an error.
func UnsafeParseIntoAbsPath(path string) AbsPath {
	absPath, err := ParseIntoAbsPath(path)
	if err != nil {
		panic(err.Error())
	}
	return absPath
}

// RelPath represents a relative file or dir path.
type RelPath struct {
	path string
}

func (obj RelPath) isRel()  {}
func (obj RelPath) isPath() {}

// String returns the canonical "friendly" representation of this path. If it is
// a directory, then it will end with a slash.
func (obj RelPath) String() string { return obj.path }

// Path returns the cleaned version of this path. It is what you expect after
// running the golang path cleaner on the internal representation.
func (obj RelPath) Path() string { return stdlibPath.Clean(obj.path) }

// IsDir returns true if this is a dir, and false if it's not based on the path
// stored within and the parsing criteria in the IsDir helper function.
func (obj RelPath) IsDir() bool { return IsDir(obj.path) }

// IsAbs returns false for this struct.
func (obj RelPath) IsAbs() bool { return false }

// Validate returns an error if the path was not specified correctly.
func (obj RelPath) Validate() error {
	if strings.HasPrefix(obj.path, "/") {
		return fmt.Errorf("path is not relative")
	}

	if obj.path == "" {
		return fmt.Errorf("path is empty")
	}

	return nil
}

// PanicValidate panics if the path was not specified correctly.
func (obj RelPath) PanicValidate() {
	if err := obj.Validate(); err != nil {
		panic(err.Error())
	}
}

// Cmp compares two RelPath's and returns nil if they have the same path.
func (obj RelPath) Cmp(relpath RelPath) error {
	if obj.path != relpath.path {
		return fmt.Errorf("paths differ")
	}
	return nil
}

// HasDir returns true if the input relative dir is present in the path.
// TODO: write tests for this and ensure it doesn't have a bug
func (obj RelPath) HasDir(relDir RelDir) bool {
	obj.PanicValidate()
	relDir.PanicValidate()
	//if obj.path == "/" {
	//	return false
	//}
	// TODO: test with ""

	i := strings.Index(obj.path, relDir.path)
	if i == -1 {
		return false // not found
	}
	if i == 0 {
		return true // found at the beginning
	}
	// We want to make sure we land on a split char, or we didn't match it.
	// We don't need to check the last char, because we know it's a /
	return obj.path[i-1] == '/' // check if the char before is a /
}

// ParseIntoRelPath takes an input path and ensures it's an RelPath. It doesn't
// do anything particularly magical. It then runs Validate to ensure the path
// was valid overall. It also runs the stdlib path Clean function on it.
func ParseIntoRelPath(path string) (RelPath, error) {
	if path == "" {
		return RelPath{}, fmt.Errorf("path is empty")
	}

	path = stdlibPath.Clean(path)

	relPath := RelPath{path: path}
	return relPath, relPath.Validate()
}

// UnsafeParseIntoRelPath performs exactly as ParseIntoRelPath does, but it
// panics if the latter would have returned an error.
func UnsafeParseIntoRelPath(path string) RelPath {
	relPath, err := ParseIntoRelPath(path)
	if err != nil {
		panic(err.Error())
	}
	return relPath
}

// ParseIntoPath takes an input path and a boolean that specifies if it is a dir
// and returns a type that fulfills the Path interface. The isDir boolean
// usually comes from the io/fs.FileMode IsDir() method. The returned underlying
// type will be one of AbsFile, AbsDir, RelFile, RelDir. It then runs Validate
// to ensure the path was valid overall.
func ParseIntoPath(path string, isDir bool) (Path, error) {
	//var safePath Path
	if isDir {
		dir, err := ParseIntoDir(path)
		if err != nil {
			return nil, err
		}
		if absDir, ok := dir.(AbsDir); ok {
			return absDir, absDir.Validate()
		}
		if relDir, ok := dir.(RelDir); ok {
			return relDir, relDir.Validate()
		}
		return nil, fmt.Errorf("unknown dir") // bug
	}
	file, err := ParseIntoFile(path)
	if err != nil {
		return nil, err
	}
	if absFile, ok := file.(AbsFile); ok {
		return absFile, absFile.Validate()
	}
	if relFile, ok := file.(RelFile); ok {
		return relFile, relFile.Validate()
	}
	return nil, fmt.Errorf("unknown file") // bug
}

// UnsafeParseIntoPath performs exactly as ParseIntoPath does, but it panics if
// the latter would have returned an error.
func UnsafeParseIntoPath(path string, isDir bool) Path {
	p, err := ParseIntoPath(path, isDir)
	if err != nil {
		panic(err.Error())
	}
	return p
}

// SmartParseIntoPath performs exactly as ParseIntoPath does, except it
// determines if something is a dir based on whetherthe string path has a
// trailing slash or not.
func SmartParseIntoPath(path string) (Path, error) {
	return ParseIntoPath(path, IsDir(path))
}

// UnsafeSmartParseIntoPath performs exactly as SmartParseIntoPath does, but it
// panics if the latter would have returned an error.
func UnsafeSmartParseIntoPath(path string) Path {
	p, err := SmartParseIntoPath(path)
	if err != nil {
		panic(err.Error())
	}
	return p
}

// ParseIntoFile takes an input path and returns a type that fulfills the File
// interface. The returned underlying type will be one of AbsFile or RelFile.
func ParseIntoFile(path string) (File, error) {
	if strings.HasPrefix(path, "/") { // also matches "/", but that would error
		return ParseIntoAbsFile(path)
	}
	return ParseIntoRelFile(path)
}

// UnsafeParseIntoFile performs exactly as ParseIntoFile does, but it panics if
// the latter would have returned an error.
func UnsafeParseIntoFile(path string) File {
	p, err := ParseIntoFile(path)
	if err != nil {
		panic(err.Error())
	}
	return p
}

// ParseIntoDir takes an input path and returns a type that fulfills the Dir
// interface. The returned underlying type will be one of AbsDir or RelDir.
func ParseIntoDir(path string) (Dir, error) {
	if strings.HasPrefix(path, "/") { // also matches "/"
		return ParseIntoAbsDir(path)
	}
	return ParseIntoRelDir(path)
}

// UnsafeParseIntoDir performs exactly as ParseIntoDir does, but it panics if
// the latter would have returned an error.
func UnsafeParseIntoDir(path string) Dir {
	p, err := ParseIntoDir(path)
	if err != nil {
		panic(err.Error())
	}
	return p
}

// JoinToAbsFile joins an absolute dir with a relative file to produce an
// absolute file.
func JoinToAbsFile(absDir AbsDir, relFile RelFile) AbsFile {
	absDir.PanicValidate()
	relFile.PanicValidate()
	return AbsFile{
		path: absDir.path + relFile.path,
	}
}

// JoinToAbsDir joins an absolute dir with a relative dir to produce an absolute
// dir.
func JoinToAbsDir(absDir AbsDir, relDir RelDir) AbsDir {
	absDir.PanicValidate()
	relDir.PanicValidate()
	return AbsDir{
		path: absDir.path + relDir.path,
	}
}

// JoinToRelFile joins a relative dir with a relative file to produce a relative
// file.
func JoinToRelFile(relDir RelDir, relFile RelFile) RelFile {
	relDir.PanicValidate()
	relFile.PanicValidate()
	return RelFile{
		path: relDir.path + relFile.path,
	}
}

// JoinToRelDir joins any number of relative dir's to produce a relative dir.
func JoinToRelDir(relDir ...RelDir) RelDir {
	p := ""
	for _, x := range relDir {
		x.PanicValidate()
		p += x.path
	}
	return RelDir{
		path: p,
	}
}

// HasPrefix determines if the given path has the specified dir prefix. The
// prefix and path can be absolute or relative. Keep in mind, that if the path
// is absolute, then only an absolute dir can successfully match. Similarly, if
// the path is relative, then only a relative dir can successfully match.
func HasPrefix(path Path, prefix Dir) bool {
	return strings.HasPrefix(path.String(), prefix.String())
}

// StripPrefix removes a dir prefix from a path if it is possible to do so. The
// prefix and path can be both absolute or both relative. The returned result is
// either a relative dir or a relative file. Keep in mind, that if the path is
// absolute, then only an absolute dir can successfully match. Similarly, if the
// path is relative, then only a relative dir can successfully match. The input
// path will be returned unchanged if it is not possible to match (although it
// will return an error in parallel) and if there is a match, then either a
// relative dir or a relative file will be returned with the path interface.
// This is logical, because after removing some prefix, only relative paths can
// possibly remain. This relative returned path will be a file if the input path
// was a file, and a dir if the input path was a dir.
// XXX: add tests!
func StripPrefix(path Path, prefix Dir) (Path, error) {
	if !HasPrefix(path, prefix) {
		return path, fmt.Errorf("no prefix")
	}
	p := strings.TrimPrefix(path.String(), prefix.String())
	// XXX: what happens if we strip the entire dir path from itself? Empty path?
	//if p == "" {
	//	return ???, ???
	//}

	if path.IsDir() {
		return ParseIntoRelDir(p)
	}
	return ParseIntoRelFile(p)
}

// IsDir is a helper that returns true if a string path is considered as such by
// the presence of a trailing slash.
func IsDir(path string) bool {
	return strings.HasSuffix(path, "/")
}

// IsAbs is a helper that returns true if a string path is considered as such by
// the presence of a leading slash.
func IsAbs(path string) bool {
	return strings.HasPrefix(path, "/")
}

// hasExtInsensitive is the helper function for checking if the file ends with
// the given extension. It checks with a fancy case-insensitive match. As a
// special case, if you pass in an empty string as the extension to match, this
// will return false.
// TODO: add tests!
func hasExtInsensitive(path, ext string) bool {
	if ext == "" { // special case, not consistent with strings.HasSuffix
		return false
	}

	if len(ext) > len(path) { // file not long enough to have extension
		return false
	}

	s := path[len(path)-len(ext):] // extract ext length of chars

	if !strings.EqualFold(s, ext) { // fancy case-insensitive compare
		return false
	}

	return true
}
