Module FS
	EnableExplicit
	
	;- Globals
	; Pending operation state
	; Safe because JS is single-threaded — one Read_ and one Write in flight at most.
	Global _R_ID.s, _R_Path.s, _R_Content.s, *_R_Cb
	Global _W_ID.s, _W_Path.s, _W_Content.s, *_W_Cb
	
	;- Private declarations
	Declare _FindLastSlash(Path.s)
	Declare _ReadExistsCallback(Success, DataString.s)
	Declare _ReadCachedCallback(Success, DataString.s)
	Declare _ReadServerCallback(Success, DataString.s)
	Declare _ReadCacheStoreCallback(Success, DataString.s)
	Declare _WriteCacheCallback(Success, DataString.s)
	Declare _WriteServerCallback(Success, DataString.s)
	
	;- Public procedures
	
	; List directory contents.
	; DataString = JSON array of {id, name, is_dir, mime_type, size, modified_at}
	Procedure List(Path.s, *Callback)
		HTTPRequest(#PB_HTTP_Get, "/api/fs/list?path=" + URLEncoder(Path, #PB_UTF8), "", *Callback)
	EndProcedure
	
	; Stat a single node.
	; DataString = JSON object with id, name, is_dir, size, etc. or error.
	Procedure Stat(Path.s, *Callback)
		HTTPRequest(#PB_HTTP_Get, "/api/fs/stat?path=" + URLEncoder(Path, #PB_UTF8), "", *Callback)
	EndProcedure
	
	; Read file content. Cache-first: if the file is in IndexedDB, returns it
	; immediately without a server round-trip. Otherwise fetches from server
	; and populates the cache for next time.
	; FileID = string form of the numeric node id (from Stat or List).
	; Path   = virtual path, stored in cache meta for Sync to use later.
	Procedure Read_(FileID.s, Path.s, *Callback)
		_R_ID   = FileID
		_R_Path = Path
		*_R_Cb  = *Callback
		FileCache::Exists(FileID, @_ReadExistsCallback())
	EndProcedure
	
	; Write content. Hits the cache immediately (dirty), fires the callback,
	; then syncs to the server best-effort in the background.
	; If the server write fails the file stays dirty — Sync() will retry it.
	Procedure Write(FileID.s, Path.s, Content.s, *Callback)
		_W_ID      = FileID
		_W_Path    = Path
		_W_Content = Content
		*_W_Cb     = *Callback
		FileCache::Write(FileID, Content, @_WriteCacheCallback())
	EndProcedure
	
	; Create a directory.
	; DataString = JSON {success:true, id:N} or error.
	Procedure Mkdir(Path.s, *Callback)
		HTTPRequest(#PB_HTTP_Post, "/api/fs/mkdir", "path=" + URLEncoder(Path, #PB_UTF8), *Callback)
	EndProcedure
	
	; Delete a node by ID. Evicts from cache too.
	; DataString = JSON {success:true} or error.
	Procedure Delete_(FileID.s, *Callback)
		FileCache::Evict(FileID, #Null)  ; best-effort, don't wait
		HTTPRequest(#PB_HTTP_Post, "/api/fs/delete", "id=" + FileID, *Callback)
	EndProcedure
	
	; Move/rename a node by ID.
	; DataString = JSON {success:true} or error.
	Procedure Move(FileID.s, NewParentID.s, NewName.s, *Callback)
		HTTPRequest(#PB_HTTP_Post, "/api/fs/move", "id=" + FileID + "&to_parent_id=" + NewParentID + "&name=" + URLEncoder(NewName, #PB_UTF8), *Callback)
	EndProcedure
	
	; Push all dirty cached files to the server sequentially.
	Procedure Sync(*Callback)
		!var _cb = p_callback;
		!var _db = window._kumos_idb;
		!if (!_db) { if (_cb) _cb(0, 'IDB not open'); return; }
		!
		!// Collect dirty entries from file_meta
		!var tx    = _db.transaction(['file_meta'], 'readonly');
		!var store = tx.objectStore('file_meta');
		!var req   = store.openCursor();
		!var dirty = [];
		!
		!req.onsuccess = function(ev) {
		!  var cursor = ev.target.result;
		!  if (cursor) {
		!    try {
		!      var meta = JSON.parse(cursor.value);
		!      if (meta.dirty) dirty.push({ id: String(cursor.key), path: meta.path });
		!    } catch(e) {}
		!    cursor.continue();
		!  }
		!};
		!
		!tx.oncomplete = function() {
		!  if (dirty.length === 0) {
		!    if (_cb) _cb(1, JSON.stringify({ synced: 0, failed: 0 }));
		!    return;
		!  }
		!
		!  var synced = 0, failed = 0, total = dirty.length;
		!
		!  function markClean(id, onDone) {
		!    var wtx = _db.transaction(['file_meta'], 'readwrite');
		!    var ws  = wtx.objectStore('file_meta');
		!    var gr  = ws.get(id);
		!    gr.onsuccess = function(ev) {
		!      var m = {};
		!      try { m = JSON.parse(ev.target.result || '{}'); } catch(e) {}
		!      m.dirty = false;
		!      ws.put(JSON.stringify(m), id);
		!    };
		!    wtx.oncomplete = onDone;
		!    wtx.onerror    = onDone;
		!  }
		!
		!  function syncNext(i) {
		!    if (i >= total) {
		!      if (_cb) _cb(failed === 0 ? 1 : 0,
		!        JSON.stringify({ synced: synced, failed: failed }));
		!      return;
		!    }
		!    var entry = dirty[i];
		!
		!    // Read content from IDB
		!    var rtx  = _db.transaction(['file_content'], 'readonly');
		!    var rreq = rtx.objectStore('file_content').get(entry.id);
		!
		!    rreq.onsuccess = function(ev) {
		!      var content = ev.target.result !== undefined ? String(ev.target.result) : '';
		!
		!      // POST to server
		!      fetch('/api/fs/write', {
		!        method:  'POST',
		!        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
		!        body:    'path='    + encodeURIComponent(entry.path) +
		!                 '&content=' + encodeURIComponent(content)
		!      })
		!      .then(function(r) { return r.json(); })
		!      .then(function(json) {
		!        if (json.success) {
		!          markClean(entry.id, function() { synced++; syncNext(i + 1); });
		!        } else {
		!          failed++; syncNext(i + 1);
		!        }
		!      })
		!      .catch(function() { failed++; syncNext(i + 1); });
		!    };
		!
		!    rreq.onerror = function() { failed++; syncNext(i + 1); };
		!  }
		!
		!  syncNext(0);
		!};
		!
		!tx.onerror = function(e) {
		!  if (_cb) _cb(0, e.target.error ? e.target.error.message : 'sync scan failed');
		!};
	EndProcedure
	
	Procedure.s GetFilePart(Path.s)
		Protected Last = _FindLastSlash(Path)
		If Last = 0 : ProcedureReturn Path : EndIf
		ProcedureReturn Mid(Path, Last + 1)
	EndProcedure
	
	Procedure.s GetPathPart(Path.s)
		Protected Last = _FindLastSlash(Path)
		If Last = 0 : ProcedureReturn "/" : EndIf  ; no slash — return root
		ProcedureReturn Left(Path, Last)		   ; includes the trailing slash
	EndProcedure
	
	;- Private procedures
	
	Procedure _FindLastSlash(Path.s)
		Protected Pos, Last
		Pos = FindString(Path, "/")
		While Pos > 0
			Last = Pos
			Pos  = FindString(Path, "/", Pos + 1)
		Wend
		ProcedureReturn Last
	EndProcedure
	
	Procedure _ReadExistsCallback(Success, DataString.s)
		Debug "Exists → Success=" + Success + " Data=" + DataString
		If Success And DataString = "1"
			; Cache hit — return directly
			FileCache::Read_(_R_ID, @_ReadCachedCallback())
		Else
			; Cache miss — fetch from server
			HTTPRequest(#PB_HTTP_Get, "/api/fs/read?id=" + _R_ID, "", @_ReadServerCallback())
		EndIf
	EndProcedure
	
	Procedure _ReadCachedCallback(Success, DataString.s)
		If *_R_Cb
			!fs$g__r_cb(v_success, v_datastring);
		EndIf
	EndProcedure
	
	Procedure _ReadServerCallback(Success, DataString.s)
		If Success
			_R_Content = DataString
			FileCache::Cache(_R_ID, _R_Path, DataString, @_ReadCacheStoreCallback())
		Else
			!fs$g__r_cb(0, v_datastring);
		EndIf
	EndProcedure
	
	Procedure _ReadCacheStoreCallback(Success, DataString.s)
		Protected Content.s = _R_Content
		If *_R_Cb
			!fs$g__r_cb(1, v_content);
		EndIf
	EndProcedure
	
	Procedure _WriteCacheCallback(Success, DataString.s)
		If *_W_Cb
			!fs$g__w_cb(v_success, v_datastring);
		EndIf
		If Success
			HTTPRequest(#PB_HTTP_Post, "/api/fs/write", "path=" + URLEncoder(_W_Path, #PB_UTF8) + "&content=" + URLEncoder(_W_Content, #PB_UTF8), @_WriteServerCallback())
		EndIf
	EndProcedure
	
	Procedure _WriteServerCallback(Success, DataString.s)
		If Success
			If ParseJSON(0, DataString)
				If GetJSONBoolean(GetJSONMember(JSONValue(0), "success"))
					FileCache::MarkClean(_W_ID, #Null)
				EndIf
				FreeJSON(0)
			EndIf
		EndIf
	EndProcedure
	
EndModule

; IDE Options = SpiderBasic 3.20 (Windows - x86)
; CursorPosition = 21
; Folding = BAA-
; iOSAppOrientation = 0
; AndroidAppCode = 0
; AndroidAppOrientation = 0
; EnableXP
; DPIAware
; CompileSourceDirectory