import {randomSecret,deriveKey,deriveJoinToken,hashJoinToken,seal,open,toBase64,fromBase64,utf8,text,createDataCiphers,passwordDigest,equalDigest} from './crypto.js';

const CHUNK=16*1024, WINDOW=64, TIMEOUT=60_000;
const id=()=>crypto.randomUUID();
const brokerError=m=>({UNAVAILABLE:'Partage indisponible ou clé incorrecte.',UNAUTHORIZED:'Activez votre essai gratuit ou utilisez une clé de licence valide.',CAPACITY:'Le service a atteint sa capacité. Réessayez plus tard.',FORBIDDEN:'Cette session n’est plus disponible.'}[m.code]||m.message||m.code);
function deferred(){let resolve,reject;const promise=new Promise((a,b)=>{resolve=a;reject=b;});promise.catch(()=>{});return {promise,resolve,reject};}
class BrokerDataChannel extends EventTarget {
  constructor(client,session){super();this.client=client;this.session=session;this.readyState='open';this.bufferedAmountLowThreshold=512*1024;this.poll=setInterval(()=>{if(this.bufferedAmount<=this.bufferedAmountLowThreshold)this.dispatchEvent(new Event('bufferedamountlow'));},25);}
  get bufferedAmount(){return this.client.ws?.bufferedAmount||0;}
  send(bytes){if(this.readyState!=='open')throw new Error('Relais fermé');this.client.sendBroker({type:'relay-packet',sessionId:this.session.id,payload:toBase64(bytes)});}
  close(){if(this.readyState==='closed')return;this.readyState='closed';clearInterval(this.poll);this.dispatchEvent(new Event('close'));this.onclose?.();}
}

export class TransferClient {
  constructor({url=globalThis.location?.origin,onEvent=()=>{},publisherToken='',tokenProvider=null,forceRelay=false,forceWebSocketRelay=false}={}) {this.url=url;this.publisherToken=publisherToken;this.tokenProvider=tokenProvider;this.authenticatedToken=null;this.forceRelay=Boolean(forceRelay);this.forceWebSocketRelay=Boolean(forceWebSocketRelay);this.onEvent=onEvent;this.shares=new Map();this.sessions=new Map();this.seenSessions=new Set();this.requests=new Map();this.iceServers=[];}
  emit(type,details={}) {this.onEvent({type,...details});}
  async connect(){
    await this.refreshPublisher();
    if(this.ws?.readyState===1){await this.authenticatePublisher();return;}
    this.authenticatedToken=null;
    const config=await fetch(new URL('/api/config',this.url));if(!config.ok)throw new Error('Configuration inaccessible');const settings=await config.json();this.iceServers=settings.iceServers||[];this.trialEnabled=Boolean(settings.trialEnabled);
    const target=new URL('/ws',this.url);target.protocol=target.protocol==='https:'?'wss:':'ws:';
    this.ws=new WebSocket(target);const ready=deferred();this.ws.onopen=()=>ready.resolve();this.ws.onerror=()=>ready.reject(new Error('Connexion au serveur impossible'));
    this.ws.onmessage=e=>{this.brokerQueue=(this.brokerQueue||Promise.resolve()).then(()=>this.handleBroker(JSON.parse(e.data))).catch(error=>this.emit('error',{message:error.message}));};
    this.ws.onclose=()=>{for(const request of this.requests.values())request.reject(new Error('Serveur déconnecté'));this.requests.clear();for(const s of this.sessions.values())this.end(s,'Serveur déconnecté');this.emit('connection',{state:'disconnected'});};
    await ready.promise;
    await this.authenticatePublisher();
  }
  async refreshPublisher(){if(!this.tokenProvider)return;const value=await this.tokenProvider();const token=typeof value==='string'?value:value?.publisherToken;if(typeof token!=='string'||!token||token.length>4096)throw new Error('Licence de partage indisponible.');this.publisherToken=token;}
  async authenticatePublisher(){if(!this.publisherToken||this.authenticatedToken===this.publisherToken)return;const token=this.publisherToken;const result=await this.request({type:'authenticate',publisherToken:token});if(result.type!=='authenticated')throw new Error('Authentification du service refusée.');this.authenticatedToken=token;this.publisherPolicy=result.policy||null;if(this.publisherPolicy)this.emit('publisher-policy',{policy:this.publisherPolicy});}
  sendBroker(message){if(this.ws?.readyState!==1)throw new Error('Serveur déconnecté');this.ws.send(JSON.stringify(message));}
  async request(message){const requestId=id(),d=deferred();const timer=setTimeout(()=>{this.requests.delete(requestId);d.reject(new Error('Le serveur ne répond pas'));},15000);this.requests.set(requestId,d);try{this.sendBroker({...message,requestId});return await d.promise;}finally{clearTimeout(timer);this.requests.delete(requestId);}}
  async createShare({files,password='',expiresAt=Date.now()+24*3600_000,maxPeers=3,manualApproval=false}){
    if(!files?.length)throw new Error('Sélectionnez au moins un fichier');if(typeof password!=='string'||password.length>1024)throw new Error('Mot de passe trop long');
    await this.refreshPublisher();await this.authenticatePublisher();
    if(this.trialEnabled&&!this.publisherToken){let saved='';try{saved=localStorage.getItem(`relio-trial:${this.url}`)||'';}catch{}const response=await fetch(new URL('/api/trial',this.url),{method:'POST',headers:{'Content-Type':'text/plain'},body:JSON.stringify({token:saved})});const result=await response.json();if(!response.ok)throw new Error(result.error||'Essai indisponible.');this.trialToken=result.token;try{localStorage.setItem(`relio-trial:${this.url}`,result.token);}catch{}if(result.remaining<=0)throw new Error('Vos trois transferts gratuits ont été utilisés. Activez une licence pour continuer.');}
    const maxFileBytes=this.publisherPolicy?.maxFileBytes ?? (this.trialEnabled&&!this.publisherToken?1e9:null);
    const share={id:id(),secret:randomSecret(),files:new Map(),expiresAt,manualApproval,attempts:0,attemptWindowStartedAt:0,lockUntil:0};
    for(const file of files){if(!Number.isSafeInteger(file.size)||file.size<0||typeof file.slice!=='function')throw new Error('Fichier invalide');if(maxFileBytes!=null&&file.size>maxFileBytes)throw new Error(`Ce fichier dépasse les ${maxFileBytes/1e9} Go autorisés par fichier. Choisissez un fichier plus petit ou une offre supérieure.`);const fileId=id();share.files.set(fileId,{file,meta:{id:fileId,name:file.name,size:file.size,lastModified:file.lastModified}});}
    if(utf8(JSON.stringify([...share.files.values()].map(e=>e.meta))).length>CHUNK)throw new Error('Trop de fichiers ou noms trop longs pour ce prototype (16 Kio de métadonnées maximum)');
    share.passwordHash=password?await passwordDigest(password,share.id):null;this.shares.set(share.id,share);
    try{const joinTokenHash=await hashJoinToken(await deriveJoinToken(share.secret,share.id));const created=await this.request({type:'create',largestFileBytes:Math.max(...files.map(file=>file.size)),shareId:share.id,expiresAt,maxPeers,publisherToken:this.publisherToken,trialToken:this.trialToken,joinTokenHash});if(created.expiresAt!==undefined){expiresAt=created.expiresAt;share.expiresAt=expiresAt;}if(created.trialRemaining!==undefined)this.emit('trial',{remaining:created.trialRemaining});if(created.freeRemaining!==undefined)this.emit('free-quota',{remaining:created.freeRemaining});}catch(error){this.shares.delete(share.id);throw error;}
    const link=new URL('/app',this.url);link.searchParams.set('share',share.id);link.hash=share.secret;
    const result={id:share.id,secret:share.secret,url:link.href,expiresAt};this.emit('share-created',{share:result});return result;
  }
  async joinShare({shareId,secret,password='',displayName=''}){
    const joinToken=await deriveJoinToken(secret,shareId);this.joining={shareId,secret,password,displayName:displayName.slice(0,100)};
    const result=await this.request({type:'join',shareId,joinToken});return {sessionId:result.sessionId};
  }
  async handleBroker(message){
    if(message.type==='joined'){
      const join=this.joining;if(!join||join.shareId!==message.shareId)throw new Error('Session inattendue');
      const s=await this.makeSession(message,false,join.secret);s.password=join.password;s.displayName=join.displayName;this.receiver=s;this.joining=null;
    }
    if(message.requestId&&this.requests.has(message.requestId)){const d=this.requests.get(message.requestId);if(message.type==='error'){const error=new Error(brokerError(message));error.code=message.code;d.reject(error);}else d.resolve(message);}
    if(message.type==='peer'){
      const share=this.shares.get(message.shareId);if(!share)return;
      const s=await this.makeSession(message,true,share.secret);s.share=share;
      const dc=s.pc.createDataChannel('file-transfer',{ordered:true});this.bindChannel(s,dc);
      await s.pc.setLocalDescription(await s.pc.createOffer());await this.signal(s,{description:s.pc.localDescription});
    }else if(['relay-preparing','relay-cancelled','relay-started','relay-packet'].includes(message.type)){
      const s=this.sessions.get(message.sessionId);if(!s||s.ended)return;
      if(message.type==='relay-preparing'){
        if(s.channelOpened){this.sendBroker({type:'direct-ready',sessionId:s.id});return;}
        if(!s.tx||!s.rx){this.fail(s,new Error('Clés de relais non disponibles'));return;}
        s.fallbackPending=true;this.sendBroker({type:'relay-ready',sessionId:s.id});
      }else if(message.type==='relay-cancelled'){
        s.fallbackPending=false;s.fallbackRequested=false;if(s.dc?.readyState==='open'&&!s.channelOpened)s.dc.onopen?.();
      }else if(message.type==='relay-started')this.activateRelay(s);
      else if(s.transport==='websocket'){
        if(typeof message.payload!=='string'||message.payload.length>24576){this.fail(s,new Error('Paquet relais invalide'));return;}
        try{const bytes=fromBase64(message.payload);if(toBase64(bytes)!==message.payload)throw new Error();s.dc.onmessage?.({data:bytes.buffer});}catch{this.fail(s,new Error('Paquet relais invalide'));}
      }
    }else if(message.type==='ice-updated'){
      const s=this.sessions.get(message.sessionId);if(s&&!s.ended)try{await this.applyIceUpdate(s,message);}catch(error){this.fail(s,error);}
    }else if(message.type==='signal'){
      const s=this.sessions.get(message.sessionId);if(!s||s.ended||s.transport==='websocket')return;
      try{
        const decoded=JSON.parse(text(await open(s.signalKey,fromBase64(message.payload),s.id)));if(s.ended)return;
        if(decoded.description&&decoded.restartId){await this.handleIceRestart(s,decoded);}
        else if(decoded.description){
          if(decoded.description.type!==(s.owner?'answer':'offer')||s.remoteNonce)throw new Error('Négociation inattendue');
          if(typeof decoded.nonce!=='string'||fromBase64(decoded.nonce).length!==32)throw new Error('Aléa distant invalide');
          s.remoteNonce=decoded.nonce;
          const ciphers=await createDataCiphers({secret:s.secret,sessionId:s.id,owner:s.owner,ownerNonce:s.owner?s.localNonce:s.remoteNonce,receiverNonce:s.owner?s.remoteNonce:s.localNonce});
          if(s.ended)return;s.tx=ciphers.tx;s.rx=ciphers.rx;delete s.secret;
          if(s.owner&&(this.forceWebSocketRelay||s.fallbackDue)){this.startRelay(s).catch(error=>this.fail(s,error));return;}
          await s.pc.setRemoteDescription(decoded.description);if(s.ended)return;
          for(const candidate of s.candidates.splice(0)){await s.pc.addIceCandidate(candidate);if(s.ended)return;}
          if(decoded.description.type==='offer'){await s.pc.setLocalDescription(await s.pc.createAnswer());if(s.ended)return;await this.signal(s,{description:s.pc.localDescription});}
        }
        else if(decoded.candidate){if((decoded.iceGeneration||null)!==(s.iceGeneration||null))return;if(s.pc.remoteDescription&&!s.awaitingRestartDescription)await s.pc.addIceCandidate(decoded.candidate);else if(s.candidates.length<64)s.candidates.push(decoded.candidate);}
      }catch(error){this.fail(s,new Error('Signalisation invalide ou clé incorrecte'));}
    }else if(message.type==='peer-left'){const s=this.sessions.get(message.sessionId);if(s)this.end(s,'Correspondant déconnecté');}
    else if(message.type==='revoked'){this.shares.delete(message.shareId);this.emit('revoked',{shareId:message.shareId});}
    else if(message.type==='error'&&!message.requestId){if(message.sessionId&&!this.sessions.has(message.sessionId))return;this.emit('error',{sessionId:message.sessionId,message:message.message||message.code});}
  }
  async makeSession(message,owner,secret){
    if(typeof message.sessionId!=='string'||this.seenSessions.has(message.sessionId))throw new Error('Identifiant de session réutilisé');this.seenSessions.add(message.sessionId);
    const s={id:message.sessionId,shareId:message.shareId,owner,candidates:[],sendQueue:Promise.resolve(),readQueue:Promise.resolve(),authenticated:false,ended:false,queuedBytes:0,acks:new Map()};
    const base=`session:${s.id}`;s.signalKey=await deriveKey(secret,`${base}:signal`);s.secret=secret;s.localNonce=randomSecret();
    s.pc=new RTCPeerConnection({iceServers:message.iceServers||this.iceServers,iceTransportPolicy:this.forceRelay?'relay':'all'});this.sessions.set(s.id,s);
    s.timer=setTimeout(()=>this.fail(s,new Error('Délai de connexion ou authentification dépassé')),TIMEOUT);
    s.pc.onicecandidate=e=>{if(e.candidate)this.signal(s,{candidate:e.candidate,iceGeneration:s.iceGeneration||null}).catch(error=>this.fail(s,error));};
    s.pc.ondatachannel=e=>this.bindChannel(s,e.channel);
    s.pc.onconnectionstatechange=()=>{if(s.ended)return;this.emit('connection',{sessionId:s.id,state:s.pc.connectionState,route:'unknown'});if(s.pc.connectionState==='failed'){if(s.channelOpened)this.end(s,'Connexion interrompue');else if(s.owner&&s.tx)this.startRelay(s).catch(error=>this.fail(s,error));}if(s.pc.connectionState==='connected')this.route(s);};this.scheduleIceRenewal(s,message.iceExpiresAt);
    if(s.owner)s.fallbackTimer=setTimeout(()=>{if(!s.channelOpened&&!s.ended)this.startRelay(s).catch(error=>this.fail(s,error));},15000);return s;
  }
  async startRelay(s){
    if(s.ended||!s.owner||s.channelOpened||s.fallbackRequested||s.transport==='websocket')return;
    if(!s.tx||!s.rx){s.fallbackDue=true;return;}s.fallbackRequested=true;s.fallbackPending=true;
    try{await this.request({type:'relay-start',sessionId:s.id});}catch(error){s.fallbackRequested=false;s.fallbackPending=false;if(error.code==='ALREADY_CONNECTED'||s.channelOpened){if(s.dc?.readyState==='open'&&!s.channelOpened)s.dc.onopen?.();return;}throw error;}
  }
  activateRelay(s){
    if(s.channelOpened||!s.fallbackPending||!s.tx||!s.rx){this.fail(s,new Error('Bascule relais non autorisée'));return;}
    clearTimeout(s.fallbackTimer);clearTimeout(s.iceTimer);clearTimeout(s.restartTimer);
    const previous=s.dc;if(previous){previous.onopen=previous.onclose=previous.onerror=previous.onmessage=null;previous.close();}
    s.pc.onconnectionstatechange=s.pc.onicecandidate=s.pc.ondatachannel=null;s.pc.close();
    s.transport='websocket';s.fallbackPending=false;const channel=new BrokerDataChannel(this,s);this.bindChannel(s,channel);channel.onopen();
    this.emit('connection',{sessionId:s.id,state:'connected',route:'websocket'});
  }
  async paceRelay(s,bytes){const now=Date.now(),at=Math.max(now,s.nextRelaySend||now);s.nextRelaySend=at+Math.max(8,bytes*1000/(1024*1024));if(at>now)await new Promise(resolve=>setTimeout(resolve,at-now));}
  scheduleIceRenewal(s,expiresAt){
    clearTimeout(s.iceTimer);s.iceExpiresAt=expiresAt;
    if(!s.owner||s.transport==='websocket'||!Number.isSafeInteger(expiresAt)||s.ended)return;
    s.iceTimer=setTimeout(()=>{this.renewIce(s.id).catch(error=>this.fail(s,error));},Math.max(1000,expiresAt-Date.now()-120000));
  }
  async renewIce(sessionId){const s=this.sessions.get(sessionId);if(!s||s.ended||!s.tx)throw new Error('Session non prête');return this.request({type:'renew-ice',sessionId});}
  async applyIceUpdate(s,message){
    if(!s.tx||typeof message.generation!=='string'||message.generation===s.iceGeneration||!Number.isSafeInteger(message.iceExpiresAt)||message.iceExpiresAt<=Date.now())throw new Error('Renouvellement de relais invalide');
    if(s.pendingRestart||s.awaitingRestartDescription)throw new Error('Renouvellement déjà en cours');
    s.pc.setConfiguration({...s.pc.getConfiguration(),iceServers:message.iceServers});
    s.iceGeneration=message.generation;s.awaitingRestartDescription=true;s.candidates=[];
    clearTimeout(s.restartTimer);s.restartTimer=setTimeout(()=>this.fail(s,new Error('Renouvellement du relais sans réponse')),TIMEOUT);
    this.scheduleIceRenewal(s,message.iceExpiresAt);
    if(s.owner){
      if(s.pc.signalingState!=='stable')throw new Error('Négociation non stabilisée');s.pendingRestart=message.generation;
      await s.pc.setLocalDescription(await s.pc.createOffer({iceRestart:true}));if(s.ended)return;
      await this.signal(s,{description:s.pc.localDescription,restartId:message.generation});
    }
    this.emit('relay-renewing',{sessionId:s.id,expiresAt:message.iceExpiresAt});
  }
  async handleIceRestart(s,decoded){
    if(!s.tx||decoded.restartId!==s.iceGeneration||decoded.nonce!==s.remoteNonce||decoded.description.type!==(s.owner?'answer':'offer')||!s.awaitingRestartDescription)throw new Error('Renégociation non autorisée');
    if(s.owner&&s.pendingRestart!==decoded.restartId)throw new Error('Réponse de renégociation inattendue');
    await s.pc.setRemoteDescription(decoded.description);if(s.ended)return;s.awaitingRestartDescription=false;
    for(const candidate of s.candidates.splice(0)){await s.pc.addIceCandidate(candidate);if(s.ended)return;}
    if(!s.owner){await s.pc.setLocalDescription(await s.pc.createAnswer());if(s.ended)return;await this.signal(s,{description:s.pc.localDescription,restartId:decoded.restartId});}
    if(s.ended)return;s.pendingRestart=null;clearTimeout(s.restartTimer);this.emit('relay-renewed',{sessionId:s.id,expiresAt:s.iceExpiresAt});
    // Keep the live DataChannel and its existing AEAD keys/counters across an ICE restart.
  }
  async route(s){try{const stats=await s.pc.getStats();if(s.ended||s.transport==='websocket')return;let route='unknown';for(const row of stats.values()){if(row.type==='transport'&&row.selectedCandidatePairId){const pair=stats.get(row.selectedCandidatePairId),local=stats.get(pair?.localCandidateId),remote=stats.get(pair?.remoteCandidateId);route=local?.candidateType==='relay'||remote?.candidateType==='relay'?'relay':'direct';}}this.emit('connection',{sessionId:s.id,state:'connected',route});}catch{}}
  async signal(s,payload){if(s.ended)return;if(payload.description)payload={...payload,nonce:s.localNonce};const packet=toBase64(await seal(s.signalKey,utf8(JSON.stringify(payload)),s.id));if(s.ended)return;this.sendBroker({type:'signal',sessionId:s.id,payload:packet});}
  bindChannel(s,dc){s.dc=dc;dc.binaryType='arraybuffer';dc.onopen=()=>{if(s.ended)return;if(s.fallbackPending&&s.transport!=='websocket')return;if(s.channelOpened)return;if(!s.tx||!s.rx){this.fail(s,new Error('Clés de session non établies'));return;}s.channelOpened=true;clearTimeout(s.fallbackTimer);if(s.transport!=='websocket')this.sendBroker({type:'direct-ready',sessionId:s.id});if(!s.owner)this.control(s,{type:'auth',password:s.password,displayName:s.displayName}).then(()=>{s.password='';}).catch(e=>this.fail(s,e));};dc.onclose=()=>{if(s.channelOpened)this.end(s,'Canal fermé');};dc.onerror=()=>{if(s.channelOpened)this.fail(s,new Error('Erreur du canal'));};
    dc.onmessage=e=>{if(s.ended)return;if(s.transport!=='websocket'&&!s.channelOpened){s.channelOpened=true;s.fallbackPending=false;clearTimeout(s.fallbackTimer);this.sendBroker({type:'direct-ready',sessionId:s.id});}if(!(e.data instanceof ArrayBuffer)||e.data.byteLength>CHUNK+8192){this.fail(s,new Error('Trame invalide'));return;}s.queuedBytes+=e.data.byteLength;if(s.queuedBytes>2*1024*1024){this.fail(s,new Error('File de réception excessive'));return;}s.readQueue=s.readQueue.then(async()=>{if(s.ended)return;const bytes=await s.rx.decrypt(e.data);if(s.ended)return;if(bytes[0]===0)await this.handleControl(s,JSON.parse(text(bytes.slice(1))));else if(bytes[0]===1)await this.handleChunk(s,bytes);else throw new Error('Type de trame invalide');}).catch(error=>this.fail(s,error)).finally(()=>{s.queuedBytes-=e.data.byteLength;});};
  }
  send(s,bytes){const task=s.sendQueue.then(async()=>{if(s.ended||!s.tx||s.dc?.readyState!=='open')throw new Error('Canal non disponible');const packet=await s.tx.encrypt(bytes);if(s.transport==='websocket')await this.paceRelay(s,packet.byteLength);if(s.ended)throw new Error('Session terminée');await this.drain(s);if(s.ended)throw new Error('Session terminée');s.dc.send(packet);});s.sendQueue=task.catch(()=>{});return task;}
  control(s,value){const body=utf8(JSON.stringify(value)),bytes=new Uint8Array(body.length+1);bytes.set(body,1);return this.send(s,bytes);}
  passwordLocked(share,now=Date.now()){if(share.lockUntil>now)return true;if(share.lockUntil||!share.attemptWindowStartedAt||now-share.attemptWindowStartedAt>=60_000){share.attempts=0;share.lockUntil=0;share.attemptWindowStartedAt=now;}return false;}
  async denyLocked(s){const message='Trop d’essais. Réessayez dans une minute';await this.control(s,{type:'denied',message,until:s.share.lockUntil});this.end(s,message);}
  async handleControl(s,m){
    if(m.type==='auth'&&s.owner){
      if(s.authAttempted)throw new Error('Authentification déjà tentée');s.authAttempted=true;
      if(Date.now()>=new Date(s.share.expiresAt).getTime())throw new Error('Partage expiré');if(this.passwordLocked(s.share)){await this.denyLocked(s);return;}
      if(typeof m.password!=='string'||m.password.length>1024)throw new Error('Mot de passe invalide');
      const valid=!s.share.passwordHash||equalDigest(s.share.passwordHash,await passwordDigest(m.password,s.share.id));if(s.ended)return;
      if(this.passwordLocked(s.share)){await this.denyLocked(s);return;}
      if(!valid){s.share.attempts++;if(s.share.attempts>=5){s.share.lockUntil=Date.now()+60_000;this.emit('share-locked',{shareId:s.share.id,until:s.share.lockUntil});await this.denyLocked(s);}else{await this.control(s,{type:'denied',message:'Mot de passe incorrect'});this.end(s,'Mot de passe incorrect');}return;}
      s.displayName=typeof m.displayName==='string'?m.displayName.slice(0,100):'';s.authenticated=true;clearTimeout(s.timer);this.emit('peer',{sessionId:s.id,displayName:s.displayName,verified:false});
      if(s.share.manualApproval){s.awaitingApproval=true;await this.control(s,{type:'waiting'});this.emit('approval-needed',{sessionId:s.id,displayName:s.displayName});}else await this.approve(s.id);return;
    }
    if(m.type==='waiting'&&!s.owner){clearTimeout(s.timer);this.emit('waiting-approval',{sessionId:s.id});return;}
    if(m.type==='denied'){throw new Error(m.message||'Accès refusé');}
    if(m.type==='manifest'&&!s.owner){if(!Array.isArray(m.files)||m.files.length>10000)throw new Error('Manifeste invalide');for(const f of m.files)if(typeof f.id!=='string'||typeof f.name!=='string'||!Number.isSafeInteger(f.size)||f.size<0)throw new Error('Fichier invalide');s.files=m.files;s.authenticated=true;clearTimeout(s.timer);this.emit('manifest',{sessionId:s.id,files:s.files});return;}
    if(!s.authenticated||(s.owner&&!s.approved))throw new Error('Accès non autorisé');
    if(m.type==='get'&&s.owner){if(s.upload)throw new Error('Un transfert est déjà actif');const entry=s.share.files.get(m.fileId);if(!entry)throw new Error('Fichier absent');s.upload={fileId:m.fileId,entry,cancelled:false,phase:'sending'};this.upload(s,s.upload).catch(e=>this.fail(s,e));}
    else if(m.type==='ack'&&s.owner){const pending=s.acks.get(m.offset);if(!pending||pending.fileId!==m.fileId||!s.upload||m.offset!==Math.min(s.upload.ackOffset+CHUNK,s.upload.entry.meta.size))throw new Error('Accusé de réception invalide');s.upload.ackOffset=m.offset;s.acks.delete(m.offset);clearInterval(pending.timer);pending.resolve();this.emit('progress',{sessionId:s.id,fileId:m.fileId,bytes:m.offset,total:s.upload.entry.meta.size,role:'sender'});}
    else if(m.type==='pause'&&s.owner){s.remotePaused=true;}
    else if(m.type==='resume'&&s.owner){s.remotePaused=false;for(const pending of s.acks.values())pending.lastActive=Date.now();}
    else if(m.type==='done'&&!s.owner){const d=s.download;if(!d||d.fileId!==m.fileId||d.offset!==d.total)throw new Error('Fichier incomplet');await d.sink.close();if(s.ended||s.download!==d)return;await this.control(s,{type:'finished',fileId:d.fileId});if(s.ended||s.download!==d)return;this.emit('complete',{sessionId:s.id,fileId:d.fileId,bytes:d.offset,role:'receiver'});s.download=null;d.resolve();}
    else if(m.type==='finished'&&s.owner){if(!s.upload||s.upload.fileId!==m.fileId||s.upload.phase!=='awaiting-finished'||s.upload.ackOffset!==s.upload.entry.meta.size||s.acks.size!==0)throw new Error('Confirmation inattendue ou transfert incomplet');this.emit('complete',{sessionId:s.id,fileId:m.fileId,bytes:s.upload.entry.meta.size,role:'sender'});s.upload=null;}
    else if(m.type==='cancel'){this.end(s,'Transfert annulé');}
    else throw new Error('Commande inattendue');
  }
  async approve(sessionId){const s=this.sessions.get(sessionId);if(!s?.owner||!s.authenticated||s.ended)throw new Error('Session non autorisée');s.approved=true;s.awaitingApproval=false;const files=[...s.share.files.values()].map(e=>e.meta);if(utf8(JSON.stringify(files)).length>CHUNK)throw new Error('Trop de métadonnées pour ce prototype');await this.control(s,{type:'manifest',files});}
  async reject(sessionId){const s=this.sessions.get(sessionId);if(s){await this.control(s,{type:'denied',message:'Accès refusé par l’émetteur'});this.end(s,'Accès refusé');}}
  async drain(s){
    if(s.dc.bufferedAmount<=1024*1024)return;
    await new Promise((resolve,reject)=>{const dc=s.dc;dc.bufferedAmountLowThreshold=512*1024;let timer;const cleanup=()=>{clearTimeout(timer);dc.removeEventListener('bufferedamountlow',low);dc.removeEventListener('close',closed);};const low=()=>{cleanup();resolve();};const closed=()=>{cleanup();reject(new Error('Canal fermé'));};timer=setTimeout(()=>{cleanup();reject(new Error('Canal saturé'));},TIMEOUT);dc.addEventListener('bufferedamountlow',low);dc.addEventListener('close',closed);if(dc.bufferedAmount<=dc.bufferedAmountLowThreshold)low();});
  }
  async upload(s,u){
    u.ackOffset=0;
    for(let offset=0;offset<u.entry.file.size;){
      const batch=[];
      for(let count=0;count<WINDOW&&offset<u.entry.file.size;count++){
        if(s.ended||u.cancelled)return;if(Date.now()>=new Date(s.share.expiresAt).getTime())throw new Error('Partage expiré');
        const data=new Uint8Array(await u.entry.file.slice(offset,offset+CHUNK).arrayBuffer());if(s.ended||u.cancelled)return;if(!data.length)throw new Error('Lecture du fichier interrompue');
        const header=utf8(JSON.stringify({sessionId:s.id,fileId:u.fileId,offset}));const frame=new Uint8Array(5+header.length+data.length);frame[0]=1;new DataView(frame.buffer).setUint32(1,header.length);frame.set(header,5);frame.set(data,5+header.length);
        const next=offset+data.length,pending={...deferred(),fileId:u.fileId,lastActive:Date.now()};
        pending.timer=setInterval(()=>{if(s.remotePaused)pending.lastActive=Date.now();else if(Date.now()-pending.lastActive>=TIMEOUT){clearInterval(pending.timer);pending.reject(new Error('Destination sans réponse'));}},1000);
        s.acks.set(next,pending);batch.push(pending.promise);await this.send(s,frame);offset=next;
      }
      await Promise.all(batch);
    }
    if(!u.cancelled&&!s.ended){await this.control(s,{type:'done',fileId:u.fileId});if(!s.ended&&!u.cancelled)u.phase='awaiting-finished';}
  }
  async download(fileId,{sink}={}){const s=this.receiver;if(!s?.authenticated||s.ended)throw new Error('Connexion non prête');if(s.download)throw new Error('Un téléchargement est déjà actif');if(!sink?.write||!sink?.close||!sink?.abort)throw new Error('Destination manquante');const meta=s.files.find(f=>f.id===fileId);if(!meta)throw new Error('Fichier absent');const d=deferred();s.download={...d,fileId,sink,total:meta.size,offset:0,paused:false};try{await this.control(s,{type:'get',fileId});return await d.promise;}catch(error){if(s.download){await Promise.resolve(sink.abort(error)).catch(()=>{});s.download=null;}throw error;}}
  async handleChunk(s,bytes){const d=s.download;if(s.owner||!d||bytes.length<5)throw new Error('Bloc inattendu');const n=new DataView(bytes.buffer,bytes.byteOffset,bytes.byteLength).getUint32(1);if(n>2048||5+n>bytes.length)throw new Error('En-tête invalide');const h=JSON.parse(text(bytes.slice(5,5+n))),data=bytes.slice(5+n);if(h.sessionId!==s.id||h.fileId!==d.fileId||h.offset!==d.offset||!data.length||data.length>CHUNK||d.offset+data.length>d.total)throw new Error('Bloc hors séquence');await d.sink.write(data);if(s.ended||s.download!==d)return;d.offset+=data.length;this.emit('progress',{sessionId:s.id,fileId:d.fileId,bytes:d.offset,total:d.total,role:'receiver'});if(d.paused){d.resume=deferred();await d.resume.promise;}if(s.ended||s.download!==d)return;await this.control(s,{type:'ack',fileId:d.fileId,offset:d.offset});}
  pause(){const s=this.receiver;if(s?.download){s.download.paused=true;this.control(s,{type:'pause'}).catch(e=>this.fail(s,e));}}
  resume(){const s=this.receiver,d=s?.download;if(d){this.control(s,{type:'resume'}).then(()=>{if(s.ended||s.download!==d)return;d.paused=false;d.resume?.resolve();d.resume=null;}).catch(e=>this.fail(s,e));}}
  async cancelDownload(){const s=this.receiver;if(s?.download){await this.control(s,{type:'cancel'}).catch(()=>{});this.end(s,'Téléchargement annulé');}}
  async revokeShare(shareId){for(const s of this.sessions.values())if(s.shareId===shareId)this.end(s,'Partage révoqué');this.shares.delete(shareId);this.sendBroker({type:'revoke',shareId});}
  fail(s,error){if(s.ended)return;this.emit('error',{sessionId:s.id,message:error.message});this.end(s,error.message);}
  end(s,reason){if(s.ended)return;s.ended=true;delete s.secret;if(this.receiver===s)this.receiver=null;clearTimeout(s.timer);clearTimeout(s.iceTimer);clearTimeout(s.restartTimer);clearTimeout(s.fallbackTimer);if(s.upload)s.upload.cancelled=true;for(const pending of s.acks.values()){clearInterval(pending.timer);pending.reject(new Error(reason));}s.acks.clear();if(s.download){const d=s.download;s.download=null;d.resume?.resolve();Promise.resolve(d.sink.abort(new Error(reason))).catch(()=>{});d.reject(new Error(reason));}s.dc?.close();s.pc.close();this.emit('connection',{sessionId:s.id,state:'closed',reason});try{this.sendBroker({type:'leave',sessionId:s.id});}catch{}this.sessions.delete(s.id);}
  close(){for(const s of [...this.sessions.values()])this.end(s,'Application fermée');this.shares.clear();this.ws?.close();}
}
