// Copyright (c) Microsoft Corporation.  All Rights Reserved.  See License.txt in the project root for license information.

namespace Internal.Utilities

open System
open System.Diagnostics
open System.IO
open System.Reflection
open System.Runtime.InteropServices
open Microsoft.FSharp.Core

#nowarn "44" // ConfigurationSettings is obsolete but the new stuff is horribly complicated.

module internal FSharpEnvironment =

    type private TypeInThisAssembly =
        class
        end

    /// The F# version reported in the banner
    let FSharpBannerVersion =
        UtilsStrings.SR.fSharpBannerVersion (FSharp.BuildProperties.fsProductVersion, FSharp.BuildProperties.fsLanguageVersion)

    let FSharpProductName = UtilsStrings.SR.buildProductName (FSharpBannerVersion)

    let versionOf<'t> = typeof<'t>.Assembly.GetName().Version.ToString()

    let FSharpCoreLibRunningVersion =
        try
            match versionOf<Unit> with
            | null -> None
            | "" -> None
            | s -> Some(s)
        with _ ->
            None

    // The F# binary format revision number. The first three digits of this form the significant part of the
    // format revision number for F# binary signature and optimization metadata. The last digit is not significant.
    //
    // WARNING: Do not change this revision number unless you absolutely know what you're doing.
    let FSharpBinaryMetadataFormatRevision = "2.0.0.0"

    let isRunningOnCoreClr =
        typeof<obj>.Assembly.FullName
            .StartsWith("System.Private.CoreLib", StringComparison.InvariantCultureIgnoreCase)

    module Option =
        /// Convert string into Option string where null and String.Empty result in None
        let ofString s =
            if String.IsNullOrEmpty(s) then None else Some(s)

    let internal tryCurrentDomain () =
        let pathFromCurrentDomain = AppDomain.CurrentDomain.BaseDirectory

        if not (String.IsNullOrEmpty(pathFromCurrentDomain)) then
            Some pathFromCurrentDomain
        else
            None

    // The default location of FSharp.Core.dll and fsc.exe based on the version of fsc.exe that is running
    // Used for
    //     - location of design-time copies of FSharp.Core.dll and FSharp.Compiler.Interactive.Settings.dll for the default assumed environment for scripts
    //     - default ToolPath in tasks in FSharp.Build.dll (for Fsc tasks, but note a probe location is given)
    //     - default F# binaries directory in service.fs (REVIEW: check this)
    //     - default location of fsi.exe in FSharp.VS.FSI.dll (REVIEW: check this)
    //     - default F# binaries directory in (project system) Project.fs
    let BinFolderOfDefaultFSharpCompiler (probePoint: string option) =
        // Check for an app.config setting to redirect the default compiler location
        // Like fsharp-compiler-location
        try
            // We let you set FSHARP_COMPILER_BIN. I've rarely seen this used and its not documented in the install instructions.
            match Environment.GetEnvironmentVariable("FSHARP_COMPILER_BIN") with
            | result when not (String.IsNullOrWhiteSpace result) -> Some result
            | _ ->
                let safeExists f =
                    (try
                        File.Exists(f)
                     with _ ->
                         false)

                // Look in the probePoint if given, e.g. look for a compiler alongside of FSharp.Build.dll
                match probePoint with
                | Some p when safeExists (Path.Combine(p, "FSharp.Core.dll")) -> Some p
                | _ ->
                    let fallback () =
                        let d = Assembly.GetExecutingAssembly()
                        Some(Path.GetDirectoryName d.Location)

                    match tryCurrentDomain () with
                    | None -> fallback ()
                    | Some path -> Some path
        with e ->
            None

    // Specify the tooling-compatible fragments of a path such as:
    //     typeproviders/fsharp41/net461/MyProvider.DesignTime.dll
    //     tools/fsharp41/net461/MyProvider.DesignTime.dll
    // See https://github.com/dotnet/fsharp/issues/3736

    // Represents the F#-compiler <-> type provider protocol.
    // When the API or protocol updates, add a new version moniker to the front of the list here.
    let toolingCompatibleTypeProviderProtocolMonikers () = [ "fsharp41" ]

    // Detect the host tooling context
    let toolingCompatibleVersions =
        if typeof<obj>.Assembly.GetName().Name = "mscorlib" then
            [|
                "net481"
                "net48"
                "net472"
                "net471"
                "net47"
                "net462"
                "net461"
                "net452"
                "net451"
                "net45"
                "netstandard2.0"
            |]
        elif typeof<obj>.Assembly.GetName().Name = "System.Private.CoreLib" then
            [|
                "net8.0"
                "net7.0"
                "net6.0"
                "net5.0"
                "netcoreapp3.1"
                "netcoreapp3.0"
                "netstandard2.1"
                "netcoreapp2.2"
                "netcoreapp2.1"
                "netcoreapp2.0"
                "netstandard2.0"
            |]
        else
            Debug.Assert(false, "Couldn't determine runtime tooling context, assuming it supports at least .NET Standard 2.0")
            [| "netstandard2.0" |]

    let toolPaths = [| "tools"; "typeproviders" |]

    let toolingCompatiblePaths () =
        [
            for toolPath in toolPaths do
                for protocol in toolingCompatibleTypeProviderProtocolMonikers () do
                    for netRuntime in toolingCompatibleVersions do
                        yield Path.Combine(toolPath, protocol, netRuntime)
        ]

    let searchToolPath compilerToolPath =
        seq {
            yield compilerToolPath

            for toolPath in toolingCompatiblePaths () do
                yield Path.Combine(compilerToolPath, toolPath)
        }

    let rec searchToolPaths path compilerToolPaths =
        seq {
            for toolPath in compilerToolPaths do
                yield! searchToolPath toolPath

            match path with
            | None -> ()
            | Some path -> yield! searchToolPath path
        }

    let getTypeProviderAssembly
        (
            runTimeAssemblyFileName: string,
            designTimeAssemblyName: string,
            compilerToolPaths: string list,
            raiseError
        ) =
        // Find and load the designer assembly for the type provider component.
        // We look in the directories stepping up from the location of the runtime assembly.
        let loadFromLocation designTimeAssemblyPath =
            try
                Some(Assembly.UnsafeLoadFrom designTimeAssemblyPath)
            with e ->
                raiseError (Some designTimeAssemblyPath) e

        let rec searchParentDirChain path assemblyName =
            seq {
                match path with
                | None -> ()
                | Some(p: string) ->
                    match Path.GetDirectoryName(p) with
                    | s when s = "" || isNull s || Path.GetFileName(p) = "packages" || s = p -> ()
                    | parentDir -> yield! searchParentDirChain (Some parentDir) assemblyName

                for p in searchToolPaths path compilerToolPaths do
                    let fileName = Path.Combine(p, assemblyName)

                    if File.Exists fileName then
                        yield fileName
            }

        let loadFromParentDirRelativeToRuntimeAssemblyLocation designTimeAssemblyName =
            let runTimeAssemblyPath = Path.GetDirectoryName runTimeAssemblyFileName
            let paths = searchParentDirChain (Some runTimeAssemblyPath) designTimeAssemblyName

            paths
            |> Seq.tryHead
            |> function
                | Some res -> loadFromLocation res
                | None ->
                    // The search failed, just load from the first location and report an error
                    let runTimeAssemblyPath = Path.GetDirectoryName runTimeAssemblyFileName
                    loadFromLocation (Path.Combine(runTimeAssemblyPath, designTimeAssemblyName))

        if designTimeAssemblyName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) then
            loadFromParentDirRelativeToRuntimeAssemblyLocation designTimeAssemblyName
        else
            // Cover the case where the ".dll" extension has been left off and no version etc. has been used in the assembly
            // string specification.  The Name=FullName comparison is particularly strange, and was there to support
            // design-time DLLs specified using "x.DesignTIme, Version= ..." long assembly names and GAC loads.
            // These kind of design-time assembly specifications are no longer used to our knowledge so that comparison is basically legacy
            // and will always succeed.
            let name = AssemblyName(Path.GetFileNameWithoutExtension designTimeAssemblyName)

            if name.Name.Equals(name.FullName, StringComparison.OrdinalIgnoreCase) then
                let designTimeFileName = designTimeAssemblyName + ".dll"
                loadFromParentDirRelativeToRuntimeAssemblyLocation designTimeFileName
            else
                // Load from the GAC using Assembly.Load.  This is legacy since type provider design-time components are
                // never in the GAC these days and  "x.DesignTIme, Version= ..." specifications are never used.
                try
                    let name = AssemblyName designTimeAssemblyName
                    Some(Assembly.Load name)
                with e ->
                    raiseError None e

    let getCompilerToolsDesignTimeAssemblyPaths compilerToolPaths = searchToolPaths None compilerToolPaths

    let getFSharpCoreLibraryName = "FSharp.Core"
    let fsiLibraryName = "FSharp.Compiler.Interactive.Settings"

    let getFSharpCompilerLocationWithDefaultFromType (defaultLocation: Type) =
        let location =
            try
                Some(Path.GetDirectoryName(defaultLocation.Assembly.Location))
            with _ ->
                None

        match BinFolderOfDefaultFSharpCompiler(location) with
        | Some path -> path
        | None ->
            let path = location |> Option.defaultValue "<null>"
#if DEBUG
            Debug.Print(
                sprintf
                    """FSharpEnvironment.BinFolderOfDefaultFSharpCompiler (Some '%s') returned None Location
                customized incorrectly: algorithm here: https://github.com/dotnet/fsharp/blob/03f3f1c35f82af26593d025dabca57a6ef3ea9a1/src/utils/CompilerLocationUtils.fs#L171"""
                    path
            )
#endif
            // Use the location of this dll
            path

    // Fallback to ambient FSharp.CompilerService.dll
    let getFSharpCompilerLocation () =
        Path.Combine(getFSharpCompilerLocationWithDefaultFromType (typeof<TypeInThisAssembly>))

    // Fallback to ambient FSharp.Core.dll
    let getDefaultFSharpCoreLocation () =
        Path.Combine(getFSharpCompilerLocationWithDefaultFromType (typeof<Unit>), getFSharpCoreLibraryName + ".dll")

    // Must be alongside the location of FSharp.CompilerService.dll
    let getDefaultFsiLibraryLocation () =
        Path.Combine(Path.GetDirectoryName(getFSharpCompilerLocation ()), fsiLibraryName + ".dll")

    let isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)

    let dotnet = if isWindows then "dotnet.exe" else "dotnet"

    let fileExists pathToFile =
        try
            File.Exists(pathToFile)
        with _ ->
            false

    // Look for global install of dotnet sdk
    let getDotnetGlobalHostPath () =
        let pf = Environment.GetEnvironmentVariable("ProgramW6432")

        let pf =
            if String.IsNullOrEmpty(pf) then
                Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles)
            else
                pf

        let candidate = Path.Combine(pf, "dotnet", dotnet)

        if fileExists candidate then
            Some candidate
        else
            // Can't find it --- give up
            None

    let getDotnetHostPath () =
        // How to find dotnet.exe --- woe is me; probing rules make me sad.
        // Algorithm:
        // 1. Look for DOTNET_HOST_PATH environment variable
        //    this is the main user programable override .. provided by user to find a specific dotnet.exe
        // 2. Probe for are we part of an .NetSDK install
        //    In an sdk install we are always installed in:   sdk\3.0.100-rc2-014234\FSharp
        //    dotnet or dotnet.exe will be found in the directory that contains the sdk directory
        // 3. We are loaded in-process to some other application ... Eg. try .net
        //    See if the host is dotnet.exe ... from net5.0 on this is fairly unlikely
        // 4. If it's none of the above we are going to have to rely on the path containing the way to find dotnet.exe
        // Use the path to search for dotnet.exe
        let probePathForDotnetHost () =
            let paths =
                let p = Environment.GetEnvironmentVariable("PATH")
                if not (isNull p) then p.Split(Path.PathSeparator) else [||]

            paths |> Array.tryFind (fun f -> fileExists (Path.Combine(f, dotnet)))

        match (Environment.GetEnvironmentVariable("DOTNET_HOST_PATH")) with
        // Value set externally
        | value when not (String.IsNullOrEmpty(value)) && fileExists value -> Some value
        | _ ->
            // Probe for netsdk install, dotnet. and dotnet.exe is a constant offset from the location of System.Int32
            let candidate =
                let assemblyLocation =
                    Path.GetDirectoryName(typeof<Int32>.GetTypeInfo().Assembly.Location)

                Path.GetFullPath(Path.Combine(assemblyLocation, "..", "..", "..", dotnet))

            if fileExists candidate then
                Some candidate
            else
                match probePathForDotnetHost () with
                | Some f -> Some(Path.Combine(f, dotnet))
                | None -> getDotnetGlobalHostPath ()

    let getDotnetHostDirectories () =
        let isDotnetMultilevelLookup =
            (Int32.TryParse(Environment.GetEnvironmentVariable("DOTNET_MULTILEVEL_LOOKUP"))
             |> snd)
            <> 0

        [|
            match getDotnetHostPath (), getDotnetGlobalHostPath () with
            | Some hostPath, Some globalHostPath ->
                yield Path.GetDirectoryName(hostPath)

                if isDotnetMultilevelLookup && hostPath <> globalHostPath then
                    yield Path.GetDirectoryName(globalHostPath)
            | Some hostPath, None -> yield Path.GetDirectoryName(hostPath)
            | None, Some globalHostPath -> yield Path.GetDirectoryName(globalHostPath)
            | None, None -> ()
        |]

    let getDotnetHostDirectory () =
        getDotnetHostDirectories () |> Array.tryHead

    let getDotnetHostSubDirectories (path: string) =
        [|
            for directory in getDotnetHostDirectories () do
                let subdirectory = Path.Combine(directory, path)

                if Directory.Exists(subdirectory) then
                    yield! DirectoryInfo(subdirectory).GetDirectories()
        |]
