-- SPDX-FileCopyrightText: 2026 Alexandra de Wit
--
-- SPDX-License-Identifier: MIT

{- | The ambient cloud-SDK environment: the handful of @AWS_*@ variables Écluse
itself consults, read straight from the process environment at boot and carried
beside the parsed configuration, never through the config document or its
environment overlay.

Keeping them out of the config AST makes "secrets never live in the structured
config" structural: a document key like @awsSecretAccessKey@ is an unknown key
and a loud parse failure, not a silently ignored ghost. The AWS SDK's own
credential discovery (@AWS_ACCESS_KEY_ID@, @AWS_SECRET_ACCESS_KEY@, the instance
role) is untouched; this record carries only the values Écluse reads explicitly.
-}
module Ecluse.Config.Ambient (
    AmbientAws (..),
    ambientAwsFromEnv,
    parseEndpointUrl,
) where

import Data.List (lookup)
import Data.Text qualified as T

import Ecluse.Core.Security (splitHostPort)
import Ecluse.Core.Text (nonBlank)

{- | The @AWS_*@ values Écluse consults directly (region scoping and endpoint
overrides); each is 'Nothing' when the variable is unset. Blank-value handling
stays with each consumer, so sourcing these ambiently changes no behaviour.
-}
data AmbientAws = AmbientAws
    { AmbientAws -> Maybe Text
ambientAwsRegion :: Maybe Text
    {- ^ @AWS_REGION@: scopes the SQS mirror queue. (CodeArtifact's mint region
    comes from the mirror-target host, not from here.)
    -}
    , AmbientAws -> Maybe Text
ambientAwsEndpointUrlSqs :: Maybe Text
    {- ^ @AWS_ENDPOINT_URL_SQS@: the SQS endpoint override (a local emulator or a
    VPC endpoint).
    -}
    , AmbientAws -> Maybe Text
ambientAwsEndpointUrl :: Maybe Text
    {- ^ @AWS_ENDPOINT_URL@: the generic endpoint override, consulted by the S3
    advisory-database client (the proxy's sync and Pilot's export).
    -}
    }
    deriving stock (AmbientAws -> AmbientAws -> Bool
(AmbientAws -> AmbientAws -> Bool)
-> (AmbientAws -> AmbientAws -> Bool) -> Eq AmbientAws
forall a. (a -> a -> Bool) -> (a -> a -> Bool) -> Eq a
$c== :: AmbientAws -> AmbientAws -> Bool
== :: AmbientAws -> AmbientAws -> Bool
$c/= :: AmbientAws -> AmbientAws -> Bool
/= :: AmbientAws -> AmbientAws -> Bool
Eq, Int -> AmbientAws -> ShowS
[AmbientAws] -> ShowS
AmbientAws -> String
(Int -> AmbientAws -> ShowS)
-> (AmbientAws -> String)
-> ([AmbientAws] -> ShowS)
-> Show AmbientAws
forall a.
(Int -> a -> ShowS) -> (a -> String) -> ([a] -> ShowS) -> Show a
$cshowsPrec :: Int -> AmbientAws -> ShowS
showsPrec :: Int -> AmbientAws -> ShowS
$cshow :: AmbientAws -> String
show :: AmbientAws -> String
$cshowList :: [AmbientAws] -> ShowS
showList :: [AmbientAws] -> ShowS
Show)

{- | Read the ambient AWS values from the process environment (as
'System.Environment.getEnvironment' returns it).
-}
ambientAwsFromEnv :: [(String, String)] -> AmbientAws
ambientAwsFromEnv :: [(String, String)] -> AmbientAws
ambientAwsFromEnv [(String, String)]
env =
    AmbientAws
        { ambientAwsRegion :: Maybe Text
ambientAwsRegion = String -> Maybe Text
look String
"AWS_REGION"
        , ambientAwsEndpointUrlSqs :: Maybe Text
ambientAwsEndpointUrlSqs = String -> Maybe Text
look String
"AWS_ENDPOINT_URL_SQS"
        , ambientAwsEndpointUrl :: Maybe Text
ambientAwsEndpointUrl = String -> Maybe Text
look String
"AWS_ENDPOINT_URL"
        }
  where
    look :: String -> Maybe Text
look String
name = String -> Text
T.pack (String -> Text) -> Maybe String -> Maybe Text
forall (f :: * -> *) a b. Functor f => (a -> b) -> f a -> f b
<$> String -> [(String, String)] -> Maybe String
forall a b. Eq a => a -> [(a, b)] -> Maybe b
lookup String
name [(String, String)]
env

{- | Parse an endpoint override URL (an 'ambientAwsEndpointUrl' or
'ambientAwsEndpointUrlSqs' value) into its (TLS flag, host, port). The scheme picks
the TLS flag and the default port (443\/80) when none is given; an absent scheme or a
non-numeric port yields 'Nothing'. The @host[:port]@ authority is split by the shared
bracket-aware 'Ecluse.Core.Security.splitHostPort', so a bracketed IPv6 literal
(@[::1]:4566@) is split on its closing bracket, not on an inner colon, and the host is
returned without brackets -- the same primitive the data-plane host extractor uses, so
the two cannot drift on an authority edge case.
-}
parseEndpointUrl :: Text -> Maybe (Bool, Text, Int)
parseEndpointUrl :: Text -> Maybe (Bool, Text, Int)
parseEndpointUrl Text
raw = do
    (secure, afterScheme) <-
        ((Bool
True,) (Text -> (Bool, Text)) -> Maybe Text -> Maybe (Bool, Text)
forall (f :: * -> *) a b. Functor f => (a -> b) -> f a -> f b
<$> Text -> Text -> Maybe Text
T.stripPrefix Text
"https://" Text
raw) Maybe (Bool, Text) -> Maybe (Bool, Text) -> Maybe (Bool, Text)
forall a. Maybe a -> Maybe a -> Maybe a
forall (f :: * -> *) a. Alternative f => f a -> f a -> f a
<|> ((Bool
False,) (Text -> (Bool, Text)) -> Maybe Text -> Maybe (Bool, Text)
forall (f :: * -> *) a b. Functor f => (a -> b) -> f a -> f b
<$> Text -> Text -> Maybe Text
T.stripPrefix Text
"http://" Text
raw)
    let authority = (Char -> Bool) -> Text -> Text
T.takeWhile (Char -> String -> Bool
forall (f :: * -> *) a.
(Foldable f, DisallowElem f, Eq a) =>
a -> f a -> Bool
`notElem` [Char
'/', Char
'?', Char
'#']) Text
afterScheme
    (hostText, portText) <- splitHostPort authority
    host <- nonBlank hostText
    port <- case T.stripPrefix ":" portText of
        Maybe Text
Nothing -> Int -> Maybe Int
forall a. a -> Maybe a
Just (if Bool
secure then Int
443 else Int
80)
        Just Text
digits -> String -> Maybe Int
forall a. Read a => String -> Maybe a
readMaybe (Text -> String
forall a. ToString a => a -> String
toString Text
digits)
    pure (secure, host, port)