Module AppStore
	EnableExplicit
	
	; Constants
	#AppsDir = "apps/"
	#TempDir = "tmp/"
	#ValidPermCount = 4
	
	; Variables
	Global Dim _ValidPerms.s(#ValidPermCount - 1)
	
	_ValidPerms(0) = "storage"
	_ValidPerms(1) = "fs.read"
	_ValidPerms(2) = "fs.write"
	_ValidPerms(3) = "notify"
	
	;- Private declarations
	Declare _IsValidAppID(AppID.s)
	Declare _IsValidPermission(Perm.s)
	Declare _IsValidFilePath(Path.s)
	Declare.s _BundleDir(UserID, AppID.s)
	Declare _EnsureDir(Path.s)
	Declare _DeleteDirRecursive(Path.s)
	Declare _WipeBundle(UserID, AppID.s)
	Declare _WipeStorage(UserID, AppID.s)
	Declare _ExtractZip(ZipFile.s, DestDir.s)
	Declare.s _ReadManifestFromZip(ZipFile.s)
	Declare.s _ValidateManifest(JSON.s, *OutAppID.String, *OutPerms.String)
	
	;- Public procedures
	Procedure Init()
		If FileSize(#AppsDir) <> -2 : CreateDirectory(#AppsDir) : EndIf
		If FileSize(#TempDir) <> -2 : CreateDirectory(#TempDir) : EndIf
	EndProcedure
	
	; GET /api/apps/list
	Procedure HandleList(*Request)
		Protected Username.s = Auth::GetSessionUser(*Request)
		If Username = ""
			General::RespondJSON(*Request, ~"{\"error\":\"Unauthorized\"}", "401 Unauthorized")
			ProcedureReturn
		EndIf
		General::RespondJSON(*Request, Database::AppList(Database::FindUser(Username)))
	EndProcedure
	
	; POST /api/apps/install   body: url=<zip url>
	Procedure HandleInstall(*Request)
		Protected UserID, HiddenDir, Home, AppsRoot
		Protected Username.s, URL.s, TempZip.s, ManifestJSON.s, ErrMsg.s, AppID.s, BundleDir.s, Perms.s
		Protected OutAppID.String, OutPerms.String
		
		Username = Auth::GetSessionUser(*Request)
		If Username = ""
			General::RespondJSON(*Request, ~"{\"error\":\"Unauthorized\"}", "401 Unauthorized")
			ProcedureReturn
		EndIf
		UserID = Database::FindUser(Username)
		
		URL = General::GetPostField(*Request, "url")
		If URL = ""
			General::RespondJSON(*Request, ~"{\"error\":\"Missing url\"}")
			ProcedureReturn
		EndIf
		If Left(URL, 7) <> "http://" And Left(URL, 8) <> "https://"
			General::RespondJSON(*Request, ~"{\"error\":\"URL must begin with http:// or https://\"}")
			ProcedureReturn
		EndIf
		
		TempZip = #TempDir + "pkg_" + UserID + "_" + ElapsedMilliseconds() + ".zip"
		
		If Not ReceiveHTTPFile(URL, TempZip)
			General::RespondJSON(*Request, ~"{\"error\":\"Failed to download package from URL\"}")
			ProcedureReturn
		EndIf
		
		; Read and validate manifest before touching anything
		ManifestJSON = _ReadManifestFromZip(TempZip)
		If ManifestJSON = ""
			DeleteFile(TempZip)
			General::RespondJSON(*Request, ~"{\"error\":\"manifest.json not found or unreadable in package\"}")
			ProcedureReturn
		EndIf
		
		ErrMsg = _ValidateManifest(ManifestJSON, @OutAppID, @OutPerms)
		If ErrMsg <> ""
			DeleteFile(TempZip)
			General::RespondJSON(*Request, ~"{\"error\":\"" + ReplaceString(ErrMsg, ~"\"", ~"\\\"") + ~"\"}")
			ProcedureReturn
		EndIf
		
		AppID = OutAppID\s
		Perms = OutPerms\s
		
		If Database::AppExists(UserID, AppID)
			_WipeBundle(UserID, AppID)
			Database::AppUninstall(UserID, AppID)
		EndIf
		
		BundleDir = _BundleDir(UserID, AppID)
		If Not _EnsureDir(BundleDir)
			DeleteFile(TempZip)
			General::RespondJSON(*Request, ~"{\"error\":\"Could not create app directory\"}")
			ProcedureReturn
		EndIf
		
		If Not _ExtractZip(TempZip, BundleDir)
			DeleteFile(TempZip)
			_WipeBundle(UserID, AppID)
			General::RespondJSON(*Request, ~"{\"error\":\"Failed to extract package\"}")
			ProcedureReturn
		EndIf
		
		DeleteFile(TempZip)
		
		If Not Database::AppInstall(UserID, AppID, ManifestJSON, Perms)
			_WipeBundle(UserID, AppID)
			General::RespondJSON(*Request, ~"{\"error\":\"Failed to register app in database\"}")
			ProcedureReturn
		EndIf
		
		HiddenDir = Database::FSResolve(UserID, "/.apps/" + AppID)
		If HiddenDir = 0
			AppsRoot = Database::FSResolve(UserID, "/.apps")
			If AppsRoot = 0
				Home = Database::FSGetOrCreateHome(UserID)
				AppsRoot = Database::FSMkdir(UserID, Home, ".apps")
			EndIf
			If AppsRoot > 0
				Database::FSMkdir(UserID, AppsRoot, AppID)
			EndIf
		EndIf
		
		General::RespondJSON(*Request, ~"{\"success\":true,\"app_id\":\"" + ReplaceString(AppID, ~"\"", ~"\\\"") + ~"\"}")
	EndProcedure
	
	; POST /api/apps/uninstall   body: app_id=...  [&keep_data=1]
	Procedure HandleUninstall(*Request)
		Protected UserID, KeepData
		Protected Username.s, AppID.s
		
		Username = Auth::GetSessionUser(*Request)
		If Username = ""
			General::RespondJSON(*Request, ~"{\"error\":\"Unauthorized\"}", "401 Unauthorized")
			ProcedureReturn
		EndIf
		UserID = Database::FindUser(Username)
		
		AppID    = General::GetPostField(*Request, "app_id")
		KeepData = Val(General::GetPostField(*Request, "keep_data"))
		
		If AppID = "" Or Not _IsValidAppID(AppID)
			General::RespondJSON(*Request, ~"{\"error\":\"Invalid or missing app_id\"}")
			ProcedureReturn
		EndIf
		If Not Database::AppExists(UserID, AppID)
			General::RespondJSON(*Request, ~"{\"error\":\"App not installed\"}", "404 Not Found")
			ProcedureReturn
		EndIf
		
		Database::AppUninstall(UserID, AppID)
		_WipeBundle(UserID, AppID)
		
		If Not KeepData : _WipeStorage(UserID, AppID) : EndIf
		
		General::RespondJSON(*Request, ~"{\"success\":true}")
	EndProcedure
	
	; GET /api/apps/{app_id}/{filepath}
	Procedure HandleServeFile(*Request, AppID.s, FilePath.s)
		Protected UserID
		Protected Username.s, FullPath.s
		
		Username = Auth::GetSessionUser(*Request)
		If Username = ""
			General::RespondJSON(*Request, ~"{\"error\":\"Unauthorized\"}", "401 Unauthorized")
			ProcedureReturn
		EndIf
		
		If Not _IsValidAppID(AppID)
			General::RespondJSON(*Request, ~"{\"error\":\"Bad Request\"}", "400 Bad Request")
			ProcedureReturn
		EndIf
		
		If Not _IsValidFilePath(FilePath)
			General::RespondJSON(*Request, ~"{\"error\":\"Forbidden\"}", "403 Forbidden")
			ProcedureReturn
		EndIf
		
		UserID = Database::FindUser(Username)
		If Not Database::AppExists(UserID, AppID)
			General::RespondJSON(*Request, ~"{\"error\":\"Not found\"}", "404 Not Found")
			ProcedureReturn
		EndIf
		
		FullPath = _BundleDir(UserID, AppID) + FilePath
		If FileSize(FullPath) < 0
			General::RespondJSON(*Request, ~"{\"error\":\"Not found\"}", "404 Not Found")
			ProcedureReturn
		EndIf
		
		If Not FastCGI::RespondFile(*Request, FullPath)
			General::RespondJSON(*Request, ~"{\"error\":\"Read error\"}", "500 Internal Server Error")
		EndIf
	EndProcedure
	
	;- Private procedures
	Procedure _IsValidAppID(AppID.s)
		Protected Length, i
		Protected c.s
		
		Length = Len(AppID)
		If Length = 0 Or Length > 64 : ProcedureReturn #False : EndIf
		For i = 1 To Length
			c = Mid(AppID, i, 1)
			If Not ((c >= "a" And c <= "z") Or (c >= "A" And c <= "Z") Or (c >= "0" And c <= "9") Or c = "." Or c = "-" Or c = "_")
				ProcedureReturn #False
			EndIf
		Next
		ProcedureReturn #True
	EndProcedure
	
	Procedure _IsValidPermission(Perm.s)
		Protected i
		For i = 0 To #ValidPermCount - 1
			If _ValidPerms(i) = Perm : ProcedureReturn #True : EndIf
		Next
		ProcedureReturn #False
	EndProcedure
	
	Procedure _IsValidFilePath(Path.s)
		If Path = "" : ProcedureReturn #False : EndIf
		If Left(Path, 1) = "/" Or Left(Path, 1) = "\" : ProcedureReturn #False : EndIf
		If FindString(Path, "..") : ProcedureReturn #False : EndIf
		If FindString(Path, "//") Or FindString(Path, "\\") : ProcedureReturn #False : EndIf
		ProcedureReturn #True
	EndProcedure
	
	Procedure.s _BundleDir(UserID, AppID.s)
		ProcedureReturn #AppsDir + UserID + "/" + AppID + "/"
	EndProcedure
	
	Procedure _EnsureDir(Path.s)
		Protected Parts, i
		Protected Current.s, Seg.s, Probe.s
		
		Path = ReplaceString(Path, "\", "/")
		If Right(Path, 1) <> "/" : Path + "/" : EndIf
		
		Parts = CountString(Path, "/")
		For i = 1 To Parts
			Seg = StringField(Path, i, "/")
			If Seg = "" : Continue : EndIf
			Current + Seg + "/"
			Probe = Left(Current, Len(Current) - 1)
			If FileSize(Probe) <> -2
				If Not CreateDirectory(Probe) : ProcedureReturn #False : EndIf
			EndIf
		Next
		ProcedureReturn #True
	EndProcedure
	
	; Built-in DeleteDirectory with #PB_FileSystem_Recursive handles the walk for us
	Procedure _DeleteDirRecursive(Path.s)
		If Right(Path, 1) = "/" : Path = Left(Path, Len(Path) - 1) : EndIf
		DeleteDirectory(Path, "", #PB_FileSystem_Recursive)
	EndProcedure
	
	Procedure _WipeBundle(UserID, AppID.s)
		_DeleteDirRecursive(_BundleDir(UserID, AppID))
	EndProcedure
	
	Procedure _WipeStorage(UserID, AppID.s)
		Protected NodeID
		Protected VPath.s = "/.apps/" + AppID
		NodeID = Database::FSResolve(UserID, VPath)
		If NodeID > 0 : Database::FSDelete(NodeID) : EndIf
	EndProcedure
	
	Procedure _ExtractZip(ZipFile.s, DestDir.s)
		Protected OK, LastSlash, Pos
		Protected EntryName.s
		
		If Not OpenPack(0, ZipFile, #PB_PackerPlugin_Zip) : ProcedureReturn #False : EndIf
		If Not ExaminePack(0)                            : ClosePack(0) : ProcedureReturn #False : EndIf
		
		OK = #True
		While NextPackEntry(0)
			EntryName = PackEntryName(0)
			
			If Not _IsValidFilePath(EntryName) : OK = #False : Break : EndIf
			
			; Directory entry — just ensure it exists
			If Right(EntryName, 1) = "/"
				_EnsureDir(DestDir + EntryName)
				Continue
			EndIf
			
			; Ensure parent directory exists
			LastSlash = 0
			Pos = FindString(EntryName, "/")
			While Pos > 0
				LastSlash = Pos
				Pos = FindString(EntryName, "/", Pos + 1)
			Wend
			If LastSlash > 0 : _EnsureDir(DestDir + Left(EntryName, LastSlash)) : EndIf
			
			If Not UncompressPackFile(0, DestDir + EntryName, EntryName)
				OK = #False : Break
			EndIf
		Wend
		
		ClosePack(0)
		ProcedureReturn OK
	EndProcedure
	
	Procedure.s _ReadManifestFromZip(ZipFile.s)
		Protected Found, Size, FileID, *Buf
		Protected TempPath.s, Result.s
		
		If Not OpenPack(0, ZipFile, #PB_PackerPlugin_Zip) : ProcedureReturn "" : EndIf
		
		TempPath = #TempDir + "manifest_" + ElapsedMilliseconds() + ".json"
		
		If ExaminePack(0)
			While NextPackEntry(0)
				If PackEntryName(0) = "manifest.json"
					Found = Bool(UncompressPackFile(0, TempPath, "manifest.json"))
					Break
				EndIf
			Wend
		EndIf
		ClosePack(0)
		
		If Not Found : ProcedureReturn "" : EndIf
		
		Size = FileSize(TempPath)
		If Size > 0 And Size < 65536   ; sanity cap: 64 KB
			FileID = ReadFile(#PB_Any, TempPath, #PB_File_SharedRead)
			If FileID
				*Buf = AllocateMemory(Size + 1)
				If *Buf
					ReadData(FileID, *Buf, Size)
					PokeB(*Buf + Size, 0)
					Result = PeekS(*Buf, -1, #PB_UTF8)
					FreeMemory(*Buf)
				EndIf
				CloseFile(FileID)
			EndIf
		EndIf
		
		DeleteFile(TempPath)
		ProcedureReturn Result
	EndProcedure
	
	Procedure.s _ValidateManifest(JSON.s, *OutAppID.String, *OutPerms.String)
		Protected Root, PermsNode, PermCount, i
		Protected AppID.s, Name_.s, Entry.s, PermsJSON.s, Perm.s
		
		If Not ParseJSON(0, JSON) : ProcedureReturn "Invalid JSON in manifest" : EndIf
		
		Root  = JSONValue(0)
		AppID = GetJSONString(GetJSONMember(Root, "id"))
		Name_ = GetJSONString(GetJSONMember(Root, "name"))
		Entry = GetJSONString(GetJSONMember(Root, "entry"))
		
		If AppID = "" Or Name_ = "" Or Entry = ""
			FreeJSON(0)
			ProcedureReturn "manifest.json missing required field(s): id, name, entry"
		EndIf
		
		If Not _IsValidAppID(AppID)
			FreeJSON(0)
			ProcedureReturn "Invalid app id '" + AppID + "' (use reverse-domain format)"
		EndIf
		
		If Not _IsValidFilePath(Entry)
			FreeJSON(0)
			ProcedureReturn "Invalid entry path"
		EndIf
		
		PermsNode = GetJSONMember(Root, "permissions")
		PermCount = JSONArraySize(PermsNode)
		PermsJSON = ~"[\"storage\""
		
		For i = 0 To PermCount - 1
			Perm = GetJSONString(GetJSONElement(PermsNode, i))
			If Perm = "" Or Perm = "storage" : Continue : EndIf
			If Not _IsValidPermission(Perm)
				FreeJSON(0)
				ProcedureReturn "Unknown permission '" + Perm + "'"
			EndIf
			PermsJSON + ~",\"" + Perm + ~"\""
		Next
		PermsJSON + "]"
		
		*OutAppID\s = AppID
		*OutPerms\s = PermsJSON
		
		FreeJSON(0)
		ProcedureReturn ""
	EndProcedure
	
EndModule
; IDE Options = PureBasic 6.30 (Windows - x64)
; CursorPosition = 402
; Folding = BAg
; EnableXP
; DPIAware