/ apps / web / src / hooks / useGenerateExercises.ts
useGenerateExercises.ts
  1  import { useState, useCallback, useEffect, useRef } from 'react'
  2  import { useNavigate } from 'react-router-dom'
  3  import { useAccount, useWriteContract, useWaitForTransactionReceipt, useReadContract } from 'wagmi'
  4  import { litProtocolService } from '../lib/litProtocol'
  5  import { useLitSession } from './useLitSession'
  6  import { gunDBService } from '../services/database/gundb'
  7  import { KARAOKE_CONTRACT_V5_ADDRESS } from '../constants/contracts'
  8  import { KARAOKE_SCHOOL_V5_ABI } from '../contracts/abis/KaraokeSchoolV5'
  9  import { usePGLiteSRS } from './usePGLiteSRS'
 10  import { useGunDBSearch } from './useGunDBSearch'
 11  
 12  // Lit Action code will be loaded at runtime
 13  
 14  const FREE_GENERATION_LIMIT = 3
 15  
 16  // Helper functions for tracking free generation usage
 17  const getFreeGenerationCount = (): number => {
 18    const today = new Date().toISOString().split('T')[0]
 19    const key = `freeGenerations-${today}`
 20    return parseInt(localStorage.getItem(key) || '0', 10)
 21  }
 22  
 23  const incrementFreeGenerationCount = (): void => {
 24    const today = new Date().toISOString().split('T')[0]
 25    const key = `freeGenerations-${today}`
 26    const current = getFreeGenerationCount()
 27    localStorage.setItem(key, String(current + 1))
 28  }
 29  
 30  export interface GeneratedExercise {
 31    referentId: number
 32    fragment: string
 33    questionType: 'trivia' | 'translation'
 34    question: string
 35    choices: {
 36      A: string
 37      B: string
 38      C: string
 39      D: string
 40    }
 41    correctAnswer: 'A' | 'B' | 'C' | 'D'
 42    explanation: string
 43    songId: string
 44    language: string
 45    generatedAt: string
 46  }
 47  
 48  // State machine for generation process
 49  type GenerationState = 
 50    | { status: 'idle' }
 51    | { status: 'checking_credits'; songId: string; language: string }
 52    | { status: 'submitting_transaction'; songId: string; language: string }
 53    | { status: 'waiting_confirmation'; songId: string; language: string; startTime: number }
 54    | { status: 'generating_exercises'; songId: string; language: string; generationType: 'free' | 'paid' }
 55    | { status: 'saving_results'; songId: string; language: string }
 56    | { status: 'success'; exercises: GeneratedExercise[] }
 57    | { status: 'error'; error: string }
 58    | { status: 'quota_exceeded'; error: string }
 59  
 60  interface UseGenerateExercisesReturn {
 61    generateExercises: (songId: string, targetLanguage?: string) => Promise<GeneratedExercise[]>
 62    checkExistingExercises: (songId: string, targetLanguage?: string) => Promise<GeneratedExercise[] | null>
 63    isGenerating: boolean
 64    error: string | null
 65    progress: string
 66    credits: number
 67    canGenerate: boolean
 68    isQuotaExceeded: boolean
 69    shouldShowFreeButton: boolean
 70    freeGenerationsRemaining: number
 71    dismissQuotaAlert: () => void
 72    navigateToPurchase: () => void
 73  }
 74  
 75  // Helper function to get progress message from state
 76  function getProgressMessage(state: GenerationState): string {
 77    switch (state.status) {
 78      case 'checking_credits': return 'Checking credits...'
 79      case 'submitting_transaction': return 'Processing payment...'
 80      case 'waiting_confirmation': return 'Waiting for transaction confirmation...'
 81      case 'generating_exercises': 
 82        return 'Generating...'
 83      case 'saving_results': return 'Saving exercises...'
 84      default: return ''
 85    }
 86  }
 87  
 88  export function useGenerateExercises(): UseGenerateExercisesReturn {
 89    const [generationState, setGenerationState] = useState<GenerationState>({ status: 'idle' })
 90    const generationInProgressRef = useRef<string | null>(null)
 91    const pendingPromiseRef = useRef<{
 92      resolve: (exercises: GeneratedExercise[]) => void
 93      reject: (error: Error) => void
 94    } | null>(null)
 95    
 96    const navigate = useNavigate()
 97    const { sessionSigs, createSession } = useLitSession()
 98    const { address } = useAccount()
 99    const { createExerciseCard, isInitialized: isIDBInitialized } = usePGLiteSRS()
100    const { getSongMetadata } = useGunDBSearch()
101    
102    // Read user credits
103    const { data: credits = 0n } = useReadContract({
104      address: KARAOKE_CONTRACT_V5_ADDRESS,
105      abi: KARAOKE_SCHOOL_V5_ABI,
106      functionName: 'credits',
107      args: address ? [address] : undefined,
108      query: {
109        enabled: !!address
110      }
111    })
112    
113    // Write contract for generation payment
114    const { writeContract: startGeneration, data: generationHash, error: writeError, isError: hasWriteError } = useWriteContract()
115    
116    // Wait for transaction
117    const { isSuccess: isGenerationTxSuccess } = useWaitForTransactionReceipt({ 
118      hash: generationHash 
119    })
120    
121    // Execute the actual generation (for both free and paid)
122    const executeGeneration = useCallback(async (songId: string, language: string, generationType: 'free' | 'paid') => {
123      try {
124        console.log('📚 Executing exercise generation...')
125  
126        // Fetch user's public IP for analytics
127        let userIp = 'unknown';
128        try {
129          const ipResponse = await fetch('https://api.ipify.org?format=json');
130          if (ipResponse.ok) {
131            const ipData = await ipResponse.json();
132            userIp = ipData.ip;
133            console.log('🌐 User IP for analytics:', userIp);
134          }
135        } catch (ipError) {
136          console.warn('Failed to fetch user IP:', ipError);
137        }
138  
139        // Get user agent
140        const userAgent = navigator.userAgent;
141  
142        // Get existing exercise IDs from localStorage to avoid duplicates
143        const existingExercisesKey = `exercises-${songId}-${language}`
144        const existingData = localStorage.getItem(existingExercisesKey)
145        const existingQuestionIds = existingData 
146          ? JSON.parse(existingData).map((ex: GeneratedExercise) => ex.referentId)
147          : []
148  
149        // Ensure we have a valid session
150        let currentSessionSigs = sessionSigs
151        if (!currentSessionSigs) {
152          console.log('🔐 Creating new Lit session for exercise generation...')
153          currentSessionSigs = await createSession()
154          if (!currentSessionSigs) {
155            throw new Error('Failed to create Lit session')
156          }
157        }
158  
159        console.log(`📚 Executing ${generationType} exercise generation Lit Action...`)
160        // Load the appropriate Lit Action code
161        const litActionPath = generationType === 'paid' 
162          ? '/lit-actions/exercises/gen-exercises-paid.js'
163          : '/lit-actions/exercises/gen-exercises-free.js'
164        const exerciseGenCode = await fetch(litActionPath).then(r => r.text())
165        
166        const jsParams = generationType === 'paid' ? {
167          songId,
168          language,
169          existingQuestionIds: existingQuestionIds,
170          page: 1,
171          perPage: 50,
172          chainId: 'baseSepolia',
173          contractAddress: KARAOKE_CONTRACT_V5_ADDRESS,
174          userAddress: address,
175          userIp: userIp,
176          userAgent: userAgent
177        } : {
178          songId,
179          language,
180          existingQuestionIds: existingQuestionIds,
181          page: 1,
182          perPage: 50,
183          userAddress: address || undefined,
184          userIp: userIp,
185          userAgent: userAgent
186        }
187  
188        const result = await litProtocolService.litNodeClient!.executeJs({
189          code: exerciseGenCode,
190          sessionSigs: currentSessionSigs,
191          jsParams
192        })
193  
194        console.log('🔍 Lit Action raw result:', result)
195        const response = JSON.parse(result.response as string)
196        
197        if (!response.success) {
198          console.error('❌ Lit Action error details:', response)
199          if (response.error === 'QUOTA_EXCEEDED' || response.error?.includes('rate limit')) {
200            throw new Error('QUOTA_EXCEEDED')
201          }
202          throw new Error(response.error || 'Failed to generate exercises')
203        }
204  
205        // If free generation succeeded, increment the count
206        if (generationType === 'free') {
207          incrementFreeGenerationCount()
208        }
209  
210        console.log(`✅ Generated ${response.questions.length} exercises`)
211        
212        // Update state to saving
213        setGenerationState({ status: 'saving_results', songId, language })
214        
215        // Store exercises in localStorage
216        localStorage.setItem(existingExercisesKey, JSON.stringify(response.questions))
217        console.log('💾 Saved exercises to localStorage')
218  
219        // Cache to GunDB for other users
220        await gunDBService.saveExercises(songId, language, response)
221        console.log('🔫 Cached exercises to GunDB')
222        
223        // Create SRS cards for all generated exercises
224        const exercises = response.questions as GeneratedExercise[]
225        
226        // Get song metadata for SRS cards
227        const songData = await getSongMetadata(parseInt(songId))
228        const songTitle = songData?.title || ''
229        const artistName = songData?.artist || ''
230        
231        // Use the shared helper function to create cards
232        await createCardsForExercises(exercises, parseInt(songId), songTitle, artistName)
233        
234        // Success!
235        setGenerationState({ status: 'success', exercises })
236        generationInProgressRef.current = null
237        
238        // Resolve the pending promise
239        if (pendingPromiseRef.current) {
240          pendingPromiseRef.current.resolve(exercises)
241          pendingPromiseRef.current = null
242        }
243      } catch (err) {
244        const errorMessage = err instanceof Error ? err.message : 'Failed to generate exercises'
245        console.error('❌ Exercise generation error:', errorMessage)
246        if (errorMessage === 'QUOTA_EXCEEDED') {
247          setGenerationState({ status: 'quota_exceeded', error: 'Free generation limit reached for today' })
248        } else {
249          setGenerationState({ status: 'error', error: errorMessage })
250        }
251        generationInProgressRef.current = null
252        
253        // Reject the pending promise
254        if (pendingPromiseRef.current) {
255          pendingPromiseRef.current.reject(new Error(errorMessage))
256          pendingPromiseRef.current = null
257        }
258      }
259    }, [sessionSigs, createSession, address, createExerciseCard, getSongMetadata, isIDBInitialized])
260    
261    // Handle write errors
262    useEffect(() => {
263      if (hasWriteError && writeError && generationState.status === 'submitting_transaction') {
264        console.error('Write contract error:', writeError)
265        setGenerationState({ status: 'error', error: writeError.message })
266        generationInProgressRef.current = null
267        
268        // Reject the pending promise
269        if (pendingPromiseRef.current) {
270          pendingPromiseRef.current.reject(new Error(writeError.message))
271          pendingPromiseRef.current = null
272        }
273      }
274    }, [hasWriteError, writeError, generationState.status])
275    
276    // Handle transaction hash availability
277    useEffect(() => {
278      if (generationHash && generationState.status === 'submitting_transaction') {
279        console.log('Transaction hash received:', generationHash)
280        setGenerationState(prev => 
281          prev.status === 'submitting_transaction' 
282            ? { ...prev, status: 'waiting_confirmation', startTime: Date.now() }
283            : prev
284        )
285      }
286    }, [generationHash, generationState.status])
287    
288    // Handle transaction confirmation
289    useEffect(() => {
290      if (
291        isGenerationTxSuccess && 
292        generationState.status === 'waiting_confirmation' &&
293        generationInProgressRef.current === generationState.songId
294      ) {
295        console.log('✅ Transaction confirmed, proceeding with exercise generation')
296        
297        // Immediately transition to prevent re-entry
298        setGenerationState(prev => 
299          prev.status === 'waiting_confirmation'
300            ? { status: 'generating_exercises', songId: prev.songId, language: prev.language, generationType: 'paid' }
301            : prev
302        )
303        
304        // Execute the paid generation
305        executeGeneration(generationState.songId, generationState.language, 'paid')
306      }
307    }, [isGenerationTxSuccess, generationState, executeGeneration])
308    
309    // Monitor transaction timeout
310    useEffect(() => {
311      if (generationState.status === 'waiting_confirmation') {
312        const checkTimeout = setInterval(() => {
313          const elapsed = Date.now() - generationState.startTime
314          if (elapsed > 30000) { // 30 second timeout
315            console.error('Transaction timeout')
316            setGenerationState({ status: 'error', error: 'Transaction timeout. Please check your wallet.' })
317            generationInProgressRef.current = null
318            
319            if (pendingPromiseRef.current) {
320              pendingPromiseRef.current.reject(new Error('Transaction timeout'))
321              pendingPromiseRef.current = null
322            }
323          }
324        }, 1000)
325        
326        return () => clearInterval(checkTimeout)
327      }
328    }, [generationState])
329  
330    // Main function to start exercise generation
331    const generateExercises = useCallback(async (
332      songId: string,
333      targetLanguage?: string
334    ): Promise<GeneratedExercise[]> => {
335      // Check if IDB is initialized before starting
336      if (!isIDBInitialized) {
337        console.error('❌ Cannot generate exercises: IDB not initialized')
338        setGenerationState({ status: 'error', error: 'Database not ready. Please refresh the page.' })
339        return []
340      }
341      
342      // Check if already generating for this song
343      if (generationInProgressRef.current === songId) {
344        console.log('⚠️ Generation already in progress for this song')
345        return []
346      }
347      
348      // Reset state
349      setGenerationState({ status: 'idle' })
350      
351      const language = targetLanguage || 'zh-CN'
352      console.log('🎯 Generating exercises for song:', songId, 'in language:', language)
353      
354      // Check if user has sufficient credits for paid generation
355      const hasCredits = Number(credits) >= 2
356      const freeCount = getFreeGenerationCount()
357      const canUseFree = freeCount < FREE_GENERATION_LIMIT
358      
359      if (!hasCredits && !canUseFree) {
360        const error = 'Free generation limit reached for today'
361        setGenerationState({ status: 'quota_exceeded', error })
362        throw new Error('QUOTA_EXCEEDED')
363      }
364      
365      const generationType = hasCredits ? 'paid' : 'free'
366      
367      // Mark as in progress
368      generationInProgressRef.current = songId
369      
370      return new Promise<GeneratedExercise[]>((resolve, reject) => {
371        // Store promise callbacks
372        pendingPromiseRef.current = { resolve, reject }
373        
374        // Start the state machine
375        setGenerationState({ status: 'checking_credits', songId, language })
376        
377        // Small delay to ensure state is set
378        setTimeout(() => {
379          if (generationType === 'paid') {
380            setGenerationState({ status: 'submitting_transaction', songId, language })
381            
382            console.log('💳 Paying 2 credits for exercise generation')
383            console.log('Contract address:', KARAOKE_CONTRACT_V5_ADDRESS)
384            console.log('Current credits:', Number(credits))
385            console.log('Song ID:', songId)
386            
387            // Submit transaction
388            startGeneration({
389              address: KARAOKE_CONTRACT_V5_ADDRESS,
390              abi: KARAOKE_SCHOOL_V5_ABI,
391              functionName: 'startGeneration',
392              args: [songId]
393            })
394          } else {
395            // For free generation, skip transaction and go directly to generation
396            console.log('🆓 Using free generation')
397            setGenerationState({ 
398              status: 'generating_exercises', 
399              songId, 
400              language,
401              generationType: 'free'
402            })
403            
404            // Execute free generation directly
405            executeGeneration(songId, language, 'free')
406          }
407        }, 100)
408      })
409    }, [credits, startGeneration, executeGeneration, isIDBInitialized])
410  
411    // Helper function to create SRS cards for exercises
412    const createCardsForExercises = useCallback(async (
413      exercises: GeneratedExercise[],
414      songId: number,
415      songTitle: string,
416      artistName: string
417    ) => {
418      if (!createExerciseCard || !isIDBInitialized) {
419        console.warn('⚠️ Cannot create cards: IDB not initialized or createExerciseCard not available')
420        return
421      }
422  
423      console.log('📝 Creating SRS cards for exercises...')
424      console.log('🔍 Exercise referentIds:', exercises.map(e => e.referentId))
425      
426      // Check for duplicates
427      const referentIdSet = new Set(exercises.map(e => e.referentId))
428      if (referentIdSet.size !== exercises.length) {
429        console.warn(`⚠️ Found duplicate referentIds! ${exercises.length} exercises but only ${referentIdSet.size} unique IDs`)
430      }
431  
432      let createdCount = 0
433      for (const exercise of exercises) {
434        try {
435          await createExerciseCard({
436            songId: songId,
437            referentId: exercise.referentId,
438            fragment: exercise.fragment,
439            songTitle: songTitle,
440            artistName: artistName
441          })
442          createdCount++
443        } catch (error) {
444          console.error(`Failed to create card for exercise ${exercise.referentId}:`, error)
445        }
446      }
447      
448      console.log(`✅ Created ${createdCount} SRS cards for ${exercises.length} exercises`)
449    }, [createExerciseCard, isIDBInitialized])
450  
451    // Check if exercises already exist
452    const checkExistingExercises = useCallback(async (
453      songId: string,
454      targetLanguage?: string
455    ): Promise<GeneratedExercise[] | null> => {
456      // Always use zh-CN for caching since exercises are generated for Chinese speakers
457      const language = targetLanguage || 'zh-CN'
458      
459      // First check localStorage
460      const existingExercisesKey = `exercises-${songId}-${language}`
461      const localData = localStorage.getItem(existingExercisesKey)
462      if (localData) {
463        const exercises = JSON.parse(localData)
464        console.log(`📦 Found ${exercises.length} exercises in localStorage for song ${songId} (${language})`)
465        console.log('📝 Exercise types:', exercises.map((ex: GeneratedExercise) => ex.questionType))
466        return exercises
467      }
468      
469      // Then check GunDB
470      console.log(`🔍 Checking GunDB for exercises: song=${songId}, language=${language}`)
471      const gunData = await gunDBService.getExercises(songId, language)
472      if (gunData && gunData.questions) {
473        console.log(`📦 Found ${gunData.questions.length} exercises in GunDB for song ${songId} (${language})`)
474        console.log('📝 Exercise types:', gunData.questions.map((ex: GeneratedExercise) => ex.questionType))
475        console.log('🕐 Generated at:', new Date(gunData.generatedAt).toLocaleString())
476        console.log('📊 Total referents available:', gunData.totalReferents)
477        
478        // Save to localStorage for faster access
479        localStorage.setItem(existingExercisesKey, JSON.stringify(gunData.questions))
480        return gunData.questions
481      }
482      
483      console.log(`❌ No exercises found in cache for song ${songId} (${language})`)
484      return null
485    }, [])
486  
487    // Derive values from state machine
488    const isGenerating = generationState.status !== 'idle' && 
489                         generationState.status !== 'success' && 
490                         generationState.status !== 'error' &&
491                         generationState.status !== 'quota_exceeded'
492    
493    const error = (generationState.status === 'error' || generationState.status === 'quota_exceeded') 
494      ? generationState.error 
495      : null
496    const progress = getProgressMessage(generationState)
497    const isQuotaExceeded = generationState.status === 'quota_exceeded'
498    
499    // Calculate free generation state
500    const freeGenerationsRemaining = FREE_GENERATION_LIMIT - getFreeGenerationCount()
501    const shouldShowFreeButton = Number(credits) < 2 && freeGenerationsRemaining > 0
502    
503    // Reset error state when needed
504    useEffect(() => {
505      if (generationState.status === 'error') {
506        const timer = setTimeout(() => {
507          setGenerationState({ status: 'idle' })
508        }, 5000) // Clear error after 5 seconds
509        
510        return () => clearTimeout(timer)
511      }
512    }, [generationState.status])
513    
514    const dismissQuotaAlert = useCallback(() => {
515      setGenerationState({ status: 'idle' })
516    }, [])
517    
518    const navigateToPurchase = useCallback(() => {
519      setGenerationState({ status: 'idle' })
520      navigate('/pricing')
521    }, [navigate])
522    
523    return {
524      generateExercises,
525      checkExistingExercises,
526      isGenerating,
527      error,
528      progress,
529      credits: Number(credits),
530      canGenerate: Number(credits) >= 2 || freeGenerationsRemaining > 0,
531      isQuotaExceeded,
532      shouldShowFreeButton,
533      freeGenerationsRemaining,
534      dismissQuotaAlert,
535      navigateToPurchase
536    }
537  }