import type { Services } from '@learnitive/context';
import { projectFilePath, type ProjectEntry } from '@learnitive/project-storage';
import { WsPath } from '@learnitive/ws-path';
import {
  deleteProjectEnvironmentFile,
  fetchProjectEnvironmentFile,
  listProjectEnvironmentFiles,
  uploadProjectEnvironmentFiles,
  type LinuxTerminalEntry,
  type ProjectEnvironment,
} from './saas-api';

const EXCLUDED_SEGMENTS = new Set(['node_modules','.git','.learnitive','.cache','.vite','.venv','venv','__pycache__']);
const EXCLUDED_TOP_LEVEL = new Set(['dist','build','target']);
function excluded(path:string): boolean {
  const value=path.replaceAll('\\','/').replace(/^\.\//u,'').replace(/\/$/u,'');
  const segments=value.split('/').filter(Boolean);
  if(segments.some((segment)=>EXCLUDED_SEGMENTS.has(segment))) return true;
  return segments.length>0&&EXCLUDED_TOP_LEVEL.has(segments[0]!);
}
function toBase64(bytes:Uint8Array):string { let binary=''; for(let i=0;i<bytes.length;i+=0x8000) binary+=String.fromCharCode(...bytes.subarray(i,Math.min(bytes.length,i+0x8000))); return btoa(binary); }
async function hash(bytes:Uint8Array):Promise<string>{ const digest=await crypto.subtle.digest('SHA-256',bytes); return Array.from(new Uint8Array(digest),b=>b.toString(16).padStart(2,'0')).join(''); }

type FileStamp = { size:number; modifiedAt:number };
type Snap = Record<string,{local?:FileStamp;remote?:FileStamp}>;
function key(projectId:string,generation:number){return`learnitive:sandbox-sync:${projectId}:g${generation}`;}
function readSnapshot(projectId:string,generation:number):Snap{
  try {
    const parsed=JSON.parse(localStorage.getItem(key(projectId,generation))||'{}') as Record<string,unknown>;
    const clean:Snap={};
    for(const [path,value] of Object.entries(parsed)){
      if(!value||typeof value!=='object') continue;
      const item=value as {local?:unknown;remote?:unknown};
      const normalize=(raw:unknown):FileStamp|undefined=>{
        if(!raw||typeof raw!=='object') return undefined;
        const stamp=raw as {size?:unknown;modifiedAt?:unknown};
        const size=Number(stamp.size); const modifiedAt=Number(stamp.modifiedAt);
        return Number.isFinite(size)&&Number.isFinite(modifiedAt)?{size,modifiedAt}:undefined;
      };
      const local=normalize(item.local); const remote=normalize(item.remote);
      if(local||remote) clean[path]={local,remote};
    }
    return clean;
  } catch { return {}; }
}
function writeSnapshot(projectId:string,generation:number,value:Snap){try{localStorage.setItem(key(projectId,generation),JSON.stringify(value));}catch{/* best effort */}}
function localStamp(entry:Extract<ProjectEntry,{kind:'file'}>):FileStamp{return{size:Number(entry.size||0),modifiedAt:Number(entry.modifiedAt||0)};}
function remoteStamp(entry:LinuxTerminalEntry):FileStamp{return{size:Number(entry.size||0),modifiedAt:Date.parse(entry.modifiedAt)||0};}
function sameStamp(left:FileStamp|undefined,right:FileStamp|undefined):boolean{return Boolean(left&&right&&left.size===right.size&&left.modifiedAt===right.modifiedAt);}

export type SandboxSyncResult={environment:ProjectEnvironment;uploaded:number;downloaded:number;deletedRemote:number;conflicts:string[]};
const activeSyncs=new Map<string,Promise<SandboxSyncResult>>();

async function synchronizeSandboxProjectOnce(core:Pick<Services,'projectStorage'|'fileSystem'>,wsName:string,projectId:string):Promise<SandboxSyncResult>{
  const provider=await core.projectStorage.getProvider(wsName);
  const writeDownloadedFile=async(path:string,bytes:Uint8Array):Promise<void>=>{
    const wsPath=WsPath.fromParts(wsName,projectFilePath(path)).wsPath;
    const file=new File([bytes],WsPath.assertFile(wsPath).fileName);
    if(await core.fileSystem.exists(wsPath)) await core.fileSystem.writeFile(wsPath,file);
    else await core.fileSystem.createFile(wsPath,file);
  };
  const deleteDownloadedFile=async(path:string):Promise<void>=>{
    await core.fileSystem.deleteFile(WsPath.fromParts(wsName,projectFilePath(path)).wsPath);
  };
  const [allLocalEntries,remoteListing]=await Promise.all([
    provider.listEntries(),
    listProjectEnvironmentFiles(projectId,true),
  ]);
  const environment=remoteListing.environment;
  const localEntries=allLocalEntries.filter((entry):entry is Extract<ProjectEntry,{kind:'file'}>=>entry.kind==='file'&&!excluded(String(entry.path)));
  const remoteEntries=remoteListing.entries.filter((entry)=>entry.kind==='file'&&!excluded(entry.path));
  const localMap=new Map(localEntries.map(entry=>[String(entry.path),entry]));
  const remoteMap=new Map(remoteEntries.map(entry=>[entry.path,entry]));
  const snapshot=readSnapshot(projectId,environment.generation);
  let uploaded=0,downloaded=0,deletedRemote=0; const conflicts:string[]=[];
  const paths=new Set([...localMap.keys(),...remoteMap.keys()]);
  const uploads:Array<{path:string;content:string}>=[];

  for(const path of paths){
    const localEntry=localMap.get(path); const remoteEntry=remoteMap.get(path); const previous=snapshot[path];
    const currentLocal=localEntry?localStamp(localEntry):undefined;
    const currentRemote=remoteEntry?remoteStamp(remoteEntry):undefined;
    const localChanged=Boolean(localEntry)&&(!previous?.local||!sameStamp(previous.local,currentLocal));
    const remoteChanged=Boolean(remoteEntry)&&(!previous?.remote||!sameStamp(previous.remote,currentRemote));

    if(localEntry&&remoteEntry){
      if(previous&& !localChanged && !remoteChanged) continue;
      // Local project source is canonical when no generation snapshot exists.
      // This materializes/re-materializes the disposable sandbox without first
      // downloading every remote file just to discover that it is unchanged.
      if(!previous || (localChanged&&!remoteChanged)){
        const localBytes=await provider.readFile(projectFilePath(path));
        if(localBytes){uploads.push({path,content:toBase64(localBytes)});uploaded++;}
        continue;
      }
      if(remoteChanged&&!localChanged){
        const remoteBytes=await fetchProjectEnvironmentFile(projectId,path);
        await writeDownloadedFile(path,remoteBytes);downloaded++;
        continue;
      }
      // Both sides changed since the last completed sync. Download only this
      // specific remote file to distinguish equivalent writes from a conflict.
      const [localBytes,remoteBytes]=await Promise.all([
        provider.readFile(projectFilePath(path)),
        fetchProjectEnvironmentFile(projectId,path),
      ]);
      if(localBytes&&await hash(localBytes)===await hash(remoteBytes)) continue;
      conflicts.push(path);
      continue;
    }

    if(localEntry&&!remoteEntry){
      // Remote deletion wins only if local content has not changed since the
      // previous completed sync; otherwise preserve the local edit by upload.
      if(previous?.remote&&previous.local&&sameStamp(previous.local,currentLocal)){
        await deleteDownloadedFile(path).catch(()=>undefined);
        continue;
      }
      const localBytes=await provider.readFile(projectFilePath(path));
      if(localBytes){uploads.push({path,content:toBase64(localBytes)});uploaded++;}
      continue;
    }

    if(!localEntry&&remoteEntry){
      // A local deletion wins only if the remote file itself is unchanged.
      if(previous?.local&&previous.remote&&sameStamp(previous.remote,currentRemote)){
        await deleteProjectEnvironmentFile(projectId,path);deletedRemote++;
        continue;
      }
      const remoteBytes=await fetchProjectEnvironmentFile(projectId,path);
      await writeDownloadedFile(path,remoteBytes);downloaded++;
    }
  }

  if(uploads.length) await uploadProjectEnvironmentFiles(projectId,uploads);

  // A no-op reconciliation can snapshot the metadata we already fetched. Only
  // refresh both sides after a real mutation/download, because those operations
  // can change mtimes and would otherwise create a self-induced sync loop.
  let freshLocalEntries:ProjectEntry[]=allLocalEntries;
  let freshRemoteEntries:LinuxTerminalEntry[]=remoteListing.entries;
  if(uploaded||downloaded||deletedRemote){
    const [nextLocal,nextRemote]=await Promise.all([
      provider.listEntries(),
      listProjectEnvironmentFiles(projectId,true),
    ]);
    freshLocalEntries=nextLocal;
    freshRemoteEntries=nextRemote.entries;
  }
  const next:Snap={};
  for(const entry of freshLocalEntries){
    if(entry.kind!=='file'||excluded(String(entry.path))) continue;
    const path=String(entry.path); next[path]??={}; next[path].local=localStamp(entry);
  }
  for(const entry of freshRemoteEntries){
    if(entry.kind!=='file'||excluded(entry.path)) continue;
    next[entry.path]??={}; next[entry.path].remote=remoteStamp(entry);
  }
  writeSnapshot(projectId,environment.generation,next);
  return{environment,uploaded,downloaded,deletedRemote,conflicts};
}

export async function synchronizeSandboxProject(core:Pick<Services,'projectStorage'|'fileSystem'>,wsName:string,projectId:string):Promise<SandboxSyncResult>{
  const lockKey=`${wsName}:${projectId}`;
  const existing=activeSyncs.get(lockKey);
  if(existing) return existing;
  const current=synchronizeSandboxProjectOnce(core,wsName,projectId).finally(()=>{
    if(activeSyncs.get(lockKey)===current) activeSyncs.delete(lockKey);
  });
  activeSyncs.set(lockKey,current);
  return current;
}
