content-org/learning-paths.ts

/**
 * @module LearningPaths
 */

import { GET, POST } from '../../infrastructure/http/HttpClient'
import {
  fetchByRailContentId,
  fetchByRailContentIds,
  fetchMethodV2Structure,
  fetchParentChildRelationshipsFor
} from '../sanity.js'
import { addContextToLearningPaths } from '../contentAggregator.js'
import {
  contentStatusCompleted,
  contentStatusCompletedMany,
  contentStatusReset,
  getAllCompletedByIds,
  getIdsWhereLastAccessedFromMethod,
  getProgressState,
} from '../contentProgress.js'
import { COLLECTION_ID_SELF, COLLECTION_TYPE, STATE } from '../sync/models/ContentProgress'
import { SyncWriteDTO } from '../sync'
import { ContentProgress } from '../sync/models'
import { CollectionParameter } from '../sync/models/ContentProgress'
import dayjs from 'dayjs'
import { LEARNING_PATH_LESSON } from "../../contentTypeConfig";

const BASE_PATH: string = `/api/content-org`
const LEARNING_PATHS_PATH = `${BASE_PATH}/v1/user/learning-paths`
let dailySessionPromise: Promise<DailySessionResponse|""> | null = null
let activePathPromise: Promise<ActiveLearningPathResponse|""> | null = null

interface ActiveLearningPathResponse {
  user_id: number
  brand: string
  active_learning_path_id: number
}

interface DailySessionResponse {
  user_id: number
  brand: string
  user_date: string
  daily_session: DailySession[]
  active_learning_path_id: number
  active_learning_path_created_at: string
}

interface DailySession {
  content_ids: number[]
  learning_path_id: number
}

interface CollectionObject {
  id: number
  type: COLLECTION_TYPE.LEARNING_PATH
}

/**
 * Gets today's daily session for the user.
 * If the daily session doesn't exist, it will be created.
 * @param brand
 * @param userDate - local datetime. must have date and time - format 2025-10-31T13:45:00
 * @param forceRefresh - force cache refresh
 */
export async function getDailySession(
  brand: string,
  userDate: Date,
  forceRefresh: boolean = false
) {
  if (dailySessionPromise && !forceRefresh) {
    return dailySessionPromise
  }

  dailySessionPromise = (async () => {
    const dateWithTimezone = formatLocalDateTime(userDate)
    const url = `${LEARNING_PATHS_PATH}/daily-session/get?brand=${brand}&userDate=${encodeURIComponent(dateWithTimezone)}`

    const response = await GET(url, {
      cache: forceRefresh ? 'reload' : 'default',
    }) as DailySessionResponse | ''

    if (!response) {
      return await updateDailySession(brand, userDate, false)
    }
    return response as DailySessionResponse
  })()

  try {
    return await dailySessionPromise
  } catch (error) {
    console.error('Error fetching daily session:', (error as any).message)
    return null
  } finally {
    dailySessionPromise = null
  }
}

/**
 * Updates the daily session for the user. Optionally, keeps the first learning path's dailies from a matching day's session.
 * @param brand
 * @param userDate - format 2025-10-31
 * @param keepFirstLearningPath
 */
export async function updateDailySession(
  brand: string,
  userDate: Date,
  keepFirstLearningPath: boolean = false
) {
  const dateWithTimezone = formatLocalDateTime(userDate)
  const url: string = `${LEARNING_PATHS_PATH}/daily-session/create`
  const body = {
    brand: brand,
    userDate: dateWithTimezone,
    keepFirstLearningPath: keepFirstLearningPath,
  }
  try {
    const response = (await POST(url, body)) as DailySessionResponse|''

    if (response || response === '') { // refresh cached value
      const urlGet: string = `${LEARNING_PATHS_PATH}/daily-session/get?brand=${brand}&userDate=${encodeURIComponent(dateWithTimezone)}`
      dataPromiseGET(urlGet, true).then(() => {
        dailySessionPromise = null
      })

    }

    return (response !== '' ? response : null)
  } catch (error: any) {
    return null
  }
}

function formatLocalDateTime(date: Date): string {
  return dayjs(date).format('YYYY-MM-DD Z')
}

/**
 * Gets user's active learning path.
 * @param brand
 * @param forceRefresh - force cache refresh
 */
export async function getActivePath(brand: string, forceRefresh: boolean = false) {
  const url: string = `${LEARNING_PATHS_PATH}/active-path/get?brand=${brand}`

  const response = await dataPromiseGET(url, forceRefresh) as ActiveLearningPathResponse
  activePathPromise = null

  return response
}

/**
 * Sets a new learning path as the user's active learning path.
 * @param brand
 * @param learningPathId
 */
export async function startLearningPath(brand: string, learningPathId: number) {
  const url: string = `${LEARNING_PATHS_PATH}/active-path/set`
  const body = { brand: brand, learning_path_id: learningPathId }

  const response = (await POST(url, body)) as ActiveLearningPathResponse

  // manual BE call to avoid recursive POST<->GET calls
  if (response) {
    const urlGet: string = `${LEARNING_PATHS_PATH}/active-path/get?brand=${brand}`
    dataPromiseGET(urlGet, true).then(() => {
      activePathPromise = null
    })
  }

  return response
}

async function dataPromiseGET(
  url: string,
  forceRefresh: boolean
): Promise<DailySessionResponse | ActiveLearningPathResponse | ""> {
  if (url.includes('daily-session')) {
    if (!dailySessionPromise || forceRefresh) {
      dailySessionPromise = GET(url, {
        cache: forceRefresh ? 'reload' : 'default',
      }) as Promise<DailySessionResponse>
    }
    return dailySessionPromise
  } else if (url.includes('active-path')) {
    if (!activePathPromise || forceRefresh) {
      activePathPromise = GET(url, {
        cache: forceRefresh ? 'reload' : 'default',
      }) as Promise<ActiveLearningPathResponse>
    }
    return activePathPromise
  }
}

/**
 * Resets the user's learning path.
 */
export async function resetAllLearningPaths() {
  const url: string = `${LEARNING_PATHS_PATH}/reset`
  return await POST(url, {})
}

/**
 * Returns learning path with lessons and progress data
 * @param {number} learningPathId - The learning path ID
 * @returns {Promise<Object>} Learning path with enriched lesson data
 */
export async function getEnrichedLearningPath(learningPathId) {
  let response = (await addContextToLearningPaths(
    fetchByRailContentId,
    learningPathId,
    COLLECTION_TYPE.LEARNING_PATH,
    {
      dataField: 'children',
      dataField_includeParent: true,
      dataField_includeIntroVideo: true,
      addProgressStatus: true,
      addProgressPercentage: true,
      addProgressTimestamp: true,
      addResumeTimeSeconds: true,
      addNavigateTo: true,
    }
  )) as any
  // add awards to LP parents only
  response = await addContextToLearningPaths(() => response, { addAwards: true })
  if (!response) return response

  response.children = mapContentToParent(
      response.children,
      {lessonType: LEARNING_PATH_LESSON, parentContentId: learningPathId}
  )
  return response
}

/**
 * Returns learning paths with lessons and progress data
 * @param {number[]} learningPathIds - The learning path IDs
 * @returns {Promise<Object>} Learning paths with enriched lesson data
 */
export async function getEnrichedLearningPaths(learningPathIds: number[]) {
  let response = (await addContextToLearningPaths(
    fetchByRailContentIds,
    learningPathIds,
    COLLECTION_TYPE.LEARNING_PATH,
    {
      dataField: 'children',
      dataField_includeParent: true,
      dataField_includeIntroVideo: true,
      addProgressStatus: true,
      addProgressPercentage: true,
      addProgressTimestamp: true,
      addResumeTimeSeconds: true,
      addNavigateTo: true,
    }
  )) as any
  // add awards to LP parents only
  response = await addContextToLearningPaths(() => response, { addAwards: true })

  if (!response) return response

  response.forEach((learningPath) => {
    learningPath.children = mapContentToParent(
        learningPath.children,
        {lessonType: LEARNING_PATH_LESSON, parentContentId: learningPath.id}
    )
  })
  return response
}

/**
 * Get specific learning path lessons by content IDs
 * @param {number[]} contentIds - Array of content IDs to filter
 * @param {number} learningPathId - The learning path ID
 * @returns {Promise<Array>} Filtered lessons
 */
export async function getLearningPathLessonsByIds(contentIds, learningPathId) {
  // It is more efficient to load the entire learning path than individual lessons
  // Also adds reliability check whether content is actually in the learning path
  const learningPath = await getEnrichedLearningPath(learningPathId)
  return learningPath.children.filter((lesson) => contentIds.includes(lesson.id))
}

/**
 * Maps content to its parent learning path - fixes multi-parent problems for cta when lessons have a special collection.
 * @param lessons - sanity documents
 * @param options
 * @param options.lessonType
 * @param options.parentContentId
 */
export function mapContentToParent(
   lessons: any,
  options?: { lessonType?: string; parentContentId?: number }
) {
  if (!lessons || (Array.isArray(lessons) && lessons.length === 0)) return lessons

  function mapIt(lesson: any) {
    const mappedLesson = { ...lesson }
    if (options?.lessonType !== undefined) mappedLesson.type = options.lessonType
    if (options?.parentContentId !== undefined) mappedLesson.parent_id = options.parentContentId
    return mappedLesson

  }

  if (typeof lessons === 'object' && !Array.isArray(lessons)) {
    return mapIt(lessons)

  } else if (Array.isArray(lessons)) {
    return lessons.map((lesson: any) => {
      return mapIt(lesson)
    })
  }
}

interface fetchLearningPathLessonsResponse {
  id: number
  thumbnail?: string
  title: string
  children: any[]
  is_active_learning_path: boolean
  active_learning_path_id?: number
  active_learning_path_created_at?: string
  upcoming_lessons?: any[]
  completed_lessons?: any[]
  learning_path_dailies?: any[]
  next_learning_path_dailies?: any[]
  next_learning_path_id?: number
  previous_learning_path_dailies?: any[]
  previous_learning_path_id?: number
}

/** Fetches and organizes learning path lessons.
 *
 * @param {number} learningPathId - The learning path ID.
 * @param {string} brand
 * @param {Date} userDate - Users local date - format 2025-10-31
 * @returns {Promise<Object>} result - The result object.
 * @returns {number} result.id - The learning path ID.
 * @returns {string} result.thumbnail - Optional thumbnail URL for the learning path.
 * @returns {string} result.title - The title of the learning path.
 * @returns {Array} result.children - Array of all lessons.
 * @returns {boolean} result.is_active_learning_path - Whether the learning path is currently active.
 * @returns {number} result.active_learning_path_id - The active learning path ID from daily session.
 * @returns {string} result.active_learning_path_created_at - The datetime the learning path was set as active.
 * @returns {Array} result.upcoming_lessons - Array of upcoming lessons.
 * @returns {Array} result.learning_path_dailies - Array of today's dailies in this learning path.
 * @returns {Array} result.next_learning_path_dailies - Array of today's dailies in the next learning path.
 * @returns {number} result.next_learning_path_id - the next learning path (after the active path).
 * @returns {Array} result.completed_lessons - Array of completed lessons in this learning path.
 * @returns {Array} result.previous_learning_path_dailies - Array of today's dailies in the previous learning path.
 * @returns {number} result.previous_learning_path_id - the previous learning path (before the active path)
 */
export async function fetchLearningPathLessons(
  learningPathId: number,
  brand: string,
  userDate: Date
) {
  const learningPath = await getEnrichedLearningPath(learningPathId)
  if (!learningPath || learningPath.children?.length === 0) return null

  let dailySession = (await getDailySession(brand, userDate)) as DailySessionResponse

  const isActiveLearningPath = (dailySession?.active_learning_path_id || 0) == learningPathId
  if (!isActiveLearningPath) {
    return {
      ...learningPath,
      is_active_learning_path: isActiveLearningPath,
    } as fetchLearningPathLessonsResponse
  }

  let todayContentIds = []
  let todayLearningPathId = null
  let nextContentIds = []
  let nextLearningPathId = null
  let previousContentIds = []
  let previousLearningPathId = null

  for (const session of dailySession.daily_session) {
    if (session.learning_path_id === learningPathId) {
      todayContentIds = session.content_ids || []
      todayLearningPathId = session.learning_path_id
    } else {
      if (!todayLearningPathId) {
        previousContentIds = session.content_ids || []
        previousLearningPathId = session.learning_path_id
      } else if (!nextLearningPathId) {
        nextContentIds = session.content_ids || []
        nextLearningPathId = session.learning_path_id
      }
    }
  }

  const completedLessons = []
  let thisLPDailies = []
  let nextLPDailies = []
  let previousLPDailies = []
  const upcomingLessons = []

  //previous/next never within LP
  learningPath.children.forEach((lesson: any) => {
    if (todayContentIds.includes(lesson.id)) {
      thisLPDailies.push(lesson)
    } else if (lesson.progressStatus === STATE.COMPLETED) {
      completedLessons.push(lesson)
    } else {
      upcomingLessons.push(lesson)
    }
  })

  if (previousContentIds.length !== 0) {
    previousLPDailies = await getLearningPathLessonsByIds(
      previousContentIds,
      previousLearningPathId
    )
  }
  if (nextContentIds.length !== 0) {
    nextLPDailies = await getLearningPathLessonsByIds(nextContentIds, nextLearningPathId).then(
      (lessons) =>
        lessons.map((lesson) => ({
          ...lesson,
          in_next_learning_path: learningPath.progressStatus === STATE.COMPLETED,
        }))
    )
  }

  return {
    ...learningPath,
    is_active_learning_path: isActiveLearningPath,
    active_learning_path_id: dailySession?.active_learning_path_id,
    active_learning_path_created_at: dailySession?.active_learning_path_created_at,
    upcoming_lessons: upcomingLessons,
    completed_lessons: completedLessons,
    learning_path_dailies: thisLPDailies,
    next_learning_path_dailies: nextLPDailies,
    next_learning_path_id: nextLearningPathId,
    previous_learning_path_dailies: previousLPDailies,
    previous_learning_path_id: previousLearningPathId,
  }
}

/**
 * For an array of contentIds, fetch any content progress with state=completed,
 * including other learning paths and a la carte progress.
 *
 * @param {number[]} contentIds The array of content IDs within the learning path
 * @returns {Promise<number[]>} Array with completed content IDs
 */
export async function fetchLearningPathProgressCheckLessons(
  contentIds: number[]
): Promise<number[]> {
  let query = await getAllCompletedByIds(contentIds)
  let completedProgress = query.data.map((progress) => progress.content_id)
  return contentIds.filter((contentId) => completedProgress.includes(contentId))
}

interface completeMethodIntroVideo {
  intro_video_response: SyncWriteDTO<ContentProgress, any> | null
  active_path_response: ActiveLearningPathResponse
}
/**
 * Handles completion of method intro video and other related actions.
 * @param introVideoId - The method intro video content ID.
 * @param brand
 * @returns {Promise<Array>} response - The response object.
 * @returns {Promise<Object|null>} response.intro_video_response - The intro video completion response or null if already completed.
 * @returns {Promise<Object>} response.active_path_response - The set active learning path response.
 */
export async function completeMethodIntroVideo(
  introVideoId: number,
  brand: string
): Promise<completeMethodIntroVideo> {
  let response = {} as completeMethodIntroVideo

  const [intro_video_response, methodStructure] = await Promise.all([
    completeIfNotCompleted(introVideoId),
    fetchMethodV2Structure(brand)
  ])
  response.intro_video_response = intro_video_response

  const firstLearningPathId = methodStructure.learning_paths[0].id

  response.active_path_response = await methodIntroVideoCompleteActions(
    brand,
    firstLearningPathId,
    new Date()
  )

  return response
}

async function methodIntroVideoCompleteActions(brand: string, learningPathId: number, userDate: Date) {
  const dateWithTimezone = formatLocalDateTime(userDate)
  const url: string = `${LEARNING_PATHS_PATH}/method-intro-video-complete-actions`
  const body = { brand: brand, learningPathId: learningPathId, userDate: dateWithTimezone }
  return (await POST(url, body)) as DailySessionResponse
}

interface completeLearningPathIntroVideo {
  intro_video_response: SyncWriteDTO<ContentProgress, any> | null
  learning_path_reset_response: SyncWriteDTO<ContentProgress, any> | null
  lesson_import_response: SyncWriteDTO<ContentProgress, any> | null
  update_dailies_response: DailySessionResponse | null
}
/**
 * Handles completion of learning path intro video and other related actions.
 * @param introVideoId - The learning path intro video content ID.
 * @param learningPathId - The content_id of the learning path that this learning path intro video belongs to.
 * @param lessonsToImport - content ids for all lessons with progress found during intro video progress check. empty if user chose not to keep learning path progress.
 * @param brand
 * @returns {Promise<Array>} response - The response object.
 * @returns {Promise<Object|null>} response.intro_video_response - The intro video completion response or null if already completed.
 * @returns {Promise<void>} response.learning_path_reset_response - The reset learning path response.
 * @returns {Promise<Object[]>} response.lesson_import_response - The responses for completing each content_id within the learning path.
 * @returns {Promise<Object|null>} response.update_dailies_response - The updated daily session if it was changed.
 */
export async function completeLearningPathIntroVideo(
  introVideoId: number,
  learningPathId: number,
  lessonsToImport: number[] | null,
  brand: string
) {
  let response = {} as completeLearningPathIntroVideo
  const collection: CollectionObject = { id: learningPathId, type: COLLECTION_TYPE.LEARNING_PATH }

  if (!lessonsToImport) {
    response.learning_path_reset_response = await resetIfPossible(learningPathId, collection)
  } else {
    response.lesson_import_response = await contentStatusCompletedMany(lessonsToImport, collection)

    const activePath = await getActivePath(brand)
    if (activePath.active_learning_path_id === learningPathId) {
      response.update_dailies_response = await updateDailySession(brand, new Date(), true)
    }
  }

  response.intro_video_response = await completeIfNotCompleted(introVideoId)

  return response
}

async function completeIfNotCompleted(
  contentId: number
): Promise<SyncWriteDTO<ContentProgress, any> | null> {
  const introVideoStatus = await getProgressState(contentId)

  return introVideoStatus !== 'completed' ? await contentStatusCompleted(contentId) : null
}

async function resetIfPossible(
  contentId: number,
  collection: CollectionParameter = null
): Promise<SyncWriteDTO<ContentProgress, any> | null> {
  const status = await getProgressState(contentId, collection)

  return status !== '' ? await contentStatusReset(contentId, collection) : null
}

export async function onContentCompletedLearningPathActions(
  contentId: number,
  collection: CollectionObject | null
) {
  if (collection?.type !== COLLECTION_TYPE.LEARNING_PATH) return
  if (contentId !== collection?.id) return

  const learningPathId = contentId
  const learningPath = await getEnrichedLearningPath(learningPathId)

  const brand = learningPath.brand
  const activeLearningPath = await getActivePath(brand)

  if (activeLearningPath.active_learning_path_id !== learningPathId) return

  const method = await fetchMethodV2Structure(brand)
  const now = new Date()
  //only want to set next LP active if it's available
  const publishedLearningPaths = method.learning_paths.filter((lp) => lp.published_on && new Date(lp.published_on) <= now)

  const currentIndex = publishedLearningPaths.findIndex((lp) => lp.id === learningPathId)
  if (currentIndex === -1) {
    return
  }
  const nextLearningPath = publishedLearningPaths[currentIndex + 1]
  if (!nextLearningPath) {
    return
  }

  await startLearningPath(brand, nextLearningPath.id)
  const nextLearningPathData = await getEnrichedLearningPath(nextLearningPath.id)

  await contentStatusReset(
    nextLearningPathData.intro_video.id,
    { id: COLLECTION_ID_SELF, type: COLLECTION_TYPE.SELF },
    { skipPush: false })
}

export async function mapContentsThatWereLastProgressedFromMethod(objects: any[]) {
  if (!objects || objects.length === 0) return objects

  const validIds = objects
    .filter((obj) => ['skill-pack-lesson', 'song-tutorial-lesson'].includes(obj.type))
    .map((obj) => obj.id) as number[]

  const trueIds = await getIdsWhereLastAccessedFromMethod(validIds)

  if (trueIds.length === 0) return objects

  let filtered = objects.filter((obj) => trueIds.includes(obj.id))

  filtered = await mapLearningPathParentsTo(filtered, {type: true, parent_id: true})

  // Map each filtered item back into the total contents object
  objects = objects.map((item) => {
    return filtered.find((f) => f.id === item.id) || item
  })

  return objects

}

export async function mapLearningPathParentsTo(objects: any[], fieldsToMap?: {type?: boolean, parent_id?: boolean}): Promise<object[]> {
  const ids = objects.map((obj: any) => obj.id) as number[]
  const hierarchy = await fetchParentChildRelationshipsFor(ids, COLLECTION_TYPE.LEARNING_PATH)

  const parentMap = new Map<number, number>()
  hierarchy.forEach((relation) => {
    relation.children.forEach((childId) => {
      parentMap.set(childId, Number(relation.railcontent_id))
    })
  })

  return objects.map((obj) => {
    const parent_id = parentMap.get(obj.id) ?? undefined
    return mapContentToParent(obj, {
      lessonType: fieldsToMap.type ? LEARNING_PATH_LESSON : undefined,
      parentContentId: fieldsToMap.parent_id ? parent_id : undefined
    })
  })
}