// Copyright 2019 The Go Language Server Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package uri

import (
	"errors"
	"fmt"
	"net/url"
	"path/filepath"
	"runtime"
	"strings"
	"unicode"
)

const (
	// FileScheme schema of filesystem path.
	FileScheme = "file"

	// HTTPScheme schema of http.
	HTTPScheme = "http"

	// HTTPSScheme schema of https.
	HTTPSScheme = "https"
)

const (
	hierPart = "://"
)

// URI Uniform Resource Identifier (URI) https://tools.ietf.org/html/rfc3986.
//
// This class is a simple parser which creates the basic component parts
// (http://tools.ietf.org/html/rfc3986#section-3) with minimal validation
// and encoding.
//
//	  foo://example.com:8042/over/there?name=ferret#nose
//	  \_/   \______________/\_________/ \_________/ \__/
//	   |           |            |            |        |
//	scheme     authority       path        query   fragment
//	   |   _____________________|__
//	  / \ /                        \
//	  urn:example:animal:ferret:nose
type URI string

// Filename returns the file path for the given URI.
// It is an error to call this on a URI that is not a valid filename.
func (u URI) Filename() string {
	filename, err := filename(u)
	if err != nil {
		panic(err)
	}

	return filepath.FromSlash(filename)
}

// ParseDocumentURI interprets a string as a DocumentURI, applying VS
// Code workarounds; see [DocumentURI.UnmarshalText] for details.
// If "s" is a file name, use [URIFromPath] instead.
func ParseDocumentURI(s string) (URI, error) {
	if s == "" {
		return "", nil
	}

	if !strings.HasPrefix(s, "file://") {
		return "", fmt.Errorf("DocumentURI scheme is not 'file': %s", s)
	}

	// VS Code sends URLs with only two slashes,
	// which are invalid. golang/go#39789.
	if !strings.HasPrefix(s, "file:///") {
		s = "file:///" + s[len("file://"):]
	}

	// Even though the input is a URI, it may not be in canonical form. VS Code
	// in particular over-escapes :, @, etc. Unescape and re-encode to canonicalize.
	path, err := url.PathUnescape(s[len("file://"):])
	if err != nil {
		return "", err
	}

	// File URIs from Windows may have lowercase drive letters.
	// Since drive letters are guaranteed to be case insensitive,
	// we change them to uppercase to remain consistent.
	// For example, file:///c:/x/y/z becomes file:///C:/x/y/z.
	if isWindowsDriveURI(path) {
		path = path[:1] + strings.ToUpper(string(path[1])) + path[2:]
	}
	u := url.URL{Scheme: FileScheme, Path: path}
	return URI(u.String()), nil
}

func filename(uri URI) (string, error) {
	u, err := url.ParseRequestURI(string(uri))
	if err != nil {
		return "", fmt.Errorf("failed to parse request URI: %w", err)
	}

	if u.Scheme != FileScheme {
		return "", fmt.Errorf("only file URIs are supported, got %v", u.Scheme)
	}

	if isWindowsDriveURI(u.Path) {
		u.Path = u.Path[1:]
	}

	return u.Path, nil
}

// New parses and creates a new URI from s.
func New(s string) URI {
	if u, err := url.PathUnescape(s); err == nil {
		s = u
	}

	if strings.HasPrefix(s, FileScheme+hierPart) {
		return URI(s)
	}

	return File(s)
}

// URIFromPath returns DocumentURI for the supplied file path.
// Given "", it returns "".
func URIFromPath(path string) URI {
	if path == "" {
		return ""
	}
	if !isWindowsDrivePath(path) {
		if abs, err := filepath.Abs(path); err == nil {
			path = abs
		}
	}
	// Check the file path again, in case it became absolute.
	if isWindowsDrivePath(path) {
		path = "/" + strings.ToUpper(string(path[0])) + path[1:]
	}
	path = filepath.ToSlash(path)
	u := url.URL{
		Scheme: FileScheme,
		Path:   path,
	}
	return URI(u.String())
}

// File parses and creates a new filesystem URI from path.
func File(path string) URI {
	const goRootPragma = "$GOROOT"
	if len(path) >= len(goRootPragma) && strings.EqualFold(goRootPragma, path[:len(goRootPragma)]) {
		path = runtime.GOROOT() + path[len(goRootPragma):]
	}

	if !isWindowsDrivePath(path) {
		if abs, err := filepath.Abs(path); err == nil {
			path = abs
		}
	}

	if isWindowsDrivePath(path) {
		path = "/" + path
	}

	path = filepath.ToSlash(path)
	u := url.URL{
		Scheme: FileScheme,
		Path:   path,
	}

	return URI(u.String())
}

// Parse parses and creates a new URI from s.
func Parse(s string) (u URI, err error) {
	us, err := url.Parse(s)
	if err != nil {
		return u, fmt.Errorf("url.Parse: %w", err)
	}

	switch us.Scheme {
	case FileScheme:
		ut := url.URL{
			Scheme:  FileScheme,
			Path:    us.Path,
			RawPath: filepath.FromSlash(us.Path),
		}
		u = URI(ut.String())

	case HTTPScheme, HTTPSScheme:
		ut := url.URL{
			Scheme:   us.Scheme,
			Host:     us.Host,
			Path:     us.Path,
			RawQuery: us.Query().Encode(),
			Fragment: us.Fragment,
		}
		u = URI(ut.String())

	default:
		return u, errors.New("unknown scheme")
	}

	return
}

// From returns the new URI from args.
func From(scheme, authority, path, query, fragment string) URI {
	switch scheme {
	case FileScheme:
		u := url.URL{
			Scheme:  FileScheme,
			Path:    path,
			RawPath: filepath.FromSlash(path),
		}
		return URI(u.String())

	case HTTPScheme, HTTPSScheme:
		u := url.URL{
			Scheme:   scheme,
			Host:     authority,
			Path:     path,
			RawQuery: url.QueryEscape(query),
			Fragment: fragment,
		}
		return URI(u.String())

	default:
		panic(fmt.Sprintf("unknown scheme: %s", scheme))
	}
}

// isWindowsDrivePath returns true if the file path is of the form used by Windows.
//
// We check if the path begins with a drive letter, followed by a ":".
func isWindowsDrivePath(path string) bool {
	if len(path) < 4 {
		return false
	}
	return unicode.IsLetter(rune(path[0])) && path[1] == ':'
}

// isWindowsDriveURI returns true if the file URI is of the format used by
// Windows URIs. The url.Parse package does not specially handle Windows paths
// (see https://golang.org/issue/6027). We check if the URI path has
// a drive prefix (e.g. "/C:"). If so, we trim the leading "/".
func isWindowsDriveURI(uri string) bool {
	if len(uri) < 4 {
		return false
	}
	return uri[0] == '/' && unicode.IsLetter(rune(uri[1])) && uri[2] == ':'
}
