/**
 * PR-37 Delivery Planning — Sprint Planner (Part F)
 *
 * Consumes SF-02 output (epics, features, userStories, dependency graph,
 * priority matrix, MVP scope) and generates a sprint plan.
 *
 * Algorithm:
 *   1. Filter Must Have stories from priority matrix
 *   2. Topological sort by dependency graph
 *   3. Assign stories to sprints based on capacity + dependencies + priority
 *   4. Compute sprint-level critical path and risks
 *   5. Aggregate sprint metrics
 *
 * @module pr-37-delivery-planning/sprint-planner
 * @since PR-37
 */

/**
 * Default sprint planning configuration.
 *
 * @param {object} [cfg]
 * @param {number} [cfg.sprintDurationWeeks=2] — Length of each sprint in weeks
 * @param {number} [cfg.teamVelocity=20] — Team velocity in story points per sprint
 * @param {number} [cfg.maxStoryPointsPerSprint=25] — Hard cap on story points per sprint
 * @param {number} [cfg.teamSize=5] — Team size (informational)
 * @returns {object} Normalized config
 */
export function defaultSprintConfig(cfg = {}) {
  return {
    sprintDurationWeeks: cfg.sprintDurationWeeks ?? 2,
    teamVelocity: cfg.teamVelocity ?? 20,
    maxStoryPointsPerSprint: cfg.maxStoryPointsPerSprint ?? 25,
    teamSize: cfg.teamSize ?? 5,
  };
}

/**
 * Build a dependency map { storyId → set of dependency storyIds }.
 *
 * @param {object[]} stories — User stories with optional .dependencies array
 * @param {object} graph — Dependency graph { nodes, edges } from SF-02
 * @returns {Map<string, Set<string>>}
 */
export function buildDependencyMap(stories, graph) {
  const depMap = new Map();

  // Initialize every story with an empty set
  for (const s of stories) {
    if (!depMap.has(s.id)) depMap.set(s.id, new Set());
  }

  // Process explicit story .dependencies arrays
  for (const s of stories) {
    if (s.dependencies && Array.isArray(s.dependencies)) {
      const deps = depMap.get(s.id) || new Set();
      for (const depId of s.dependencies) {
        deps.add(depId);
      }
      depMap.set(s.id, deps);
    }
  }

  // Process edges from dependency graph (story → story edges)
  // In SF-02 edge semantics: edge.from depends on edge.to
  // (from = the thing that REQUIRES, to = the PREREQUISITE)
  if (graph && graph.edges) {
    for (const edge of graph.edges) {
      // edge.from depends on edge.to → add edge.to as a dependency of edge.from
      const deps = depMap.get(edge.from) || new Set();
      deps.add(edge.to);
      depMap.set(edge.from, deps);
    }
  }

  return depMap;
}

/**
 * Compute dependency levels (L0 = no deps, L1 = 1 level deep, etc.)
 * using Kahn's algorithm topological sort but also computing depth.
 *
 * @param {string[]} storyIds — Stories to sort
 * @param {Map<string, Set<string>>} depMap — Dependency map
 * @returns {string[]} Topologically sorted story IDs
 */
export function topologicalSort(storyIds, depMap) {
  const sorted = [];
  const visited = new Set();
  const visiting = new Set();

  function visit(id) {
    if (visited.has(id)) return;
    if (visiting.has(id)) {
      // Circular dependency detected; resolve by including anyway
      return;
    }
    visiting.add(id);
    const deps = depMap.get(id);
    if (deps) {
      for (const depId of deps) {
        visit(depId);
      }
    }
    visiting.delete(id);
    visited.add(id);
    sorted.push(id);
  }

  for (const id of storyIds) {
    if (!visited.has(id)) visit(id);
  }

  return sorted;
}

/**
 * Compute dependency level (0 = no unresolved upstream deps) for each story.
 *
 * @param {string[]} sortedIds — Topologically sorted IDs
 * @param {Map<string, Set<string>>} depMap
 * @returns {Map<string, number>} storyId → dependency level
 */
export function computeDependencyLevels(sortedIds, depMap) {
  const levels = new Map();

  for (const id of sortedIds) {
    const deps = depMap.get(id);
    if (!deps || deps.size === 0) {
      levels.set(id, 0);
    } else {
      let maxDepLevel = 0;
      for (const depId of deps) {
        const dl = levels.get(depId);
        if (dl !== undefined && dl + 1 > maxDepLevel) {
          maxDepLevel = dl + 1;
        }
      }
      levels.set(id, maxDepLevel);
    }
  }

  return levels;
}

/**
 * Find the longest dependency chain within a set of story IDs.
 *
 * @param {string[]} storyIds — IDs in a sprint
 * @param {Map<string, Set<string>>} depMap — Full dependency map
 * @param {Map<string, number>} depLevels — Computed dependency levels
 * @returns {string[]} Chain of story IDs (longest path found)
 */
export function findCriticalPath(storyIds, depMap, depLevels) {
  if (storyIds.length === 0) return [];

  // Build reverse map: depId → set of dependents (in this sprint)
  const dependents = new Map();
  const idSet = new Set(storyIds);
  for (const id of storyIds) {
    const deps = depMap.get(id);
    if (deps) {
      for (const depId of deps) {
        if (idSet.has(depId)) {
          if (!dependents.has(depId)) dependents.set(depId, []);
          dependents.get(depId).push(id);
        }
      }
    }
  }

  // Find root nodes (no deps in this sprint) and do DFS for longest path
  const roots = storyIds.filter(id => {
    const deps = depMap.get(id);
    return !deps || ![...deps].some(d => idSet.has(d));
  });

  let longestPath = [];
  function dfs(currentId, path) {
    const newPath = [...path, currentId];
    const kids = dependents.get(currentId) || [];
    if (kids.length === 0) {
      if (newPath.length > longestPath.length) {
        longestPath = newPath;
      }
      return;
    }
    for (const kid of kids) {
      dfs(kid, newPath);
    }
  }

  for (const root of roots) {
    dfs(root, []);
  }

  return longestPath.length > 0 ? longestPath : storyIds.slice(0, 1);
}

/**
 * Identify risk stories — those with dependencies on stories outside this sprint
 * that aren't yet completed in earlier sprints.
 *
 * @param {string[]} sprintStoryIds — Stories assigned to this sprint
 * @param {Map<string, Set<string>>} depMap — Full dependency map
 * @param {Set<string>} completedIds — Stories assigned to earlier sprints
 * @returns {string[]} Story IDs with unresolved external dependencies
 */
export function identifyRisks(sprintStoryIds, depMap, completedIds) {
  const risks = [];
  for (const id of sprintStoryIds) {
    const deps = depMap.get(id);
    if (deps) {
      for (const depId of deps) {
        if (!completedIds.has(depId)) {
          risks.push(id);
          break;
        }
      }
    }
  }
  return risks;
}

/**
 * Generate a full sprint plan from SF-02 output.
 *
 * @param {object} opts
 * @param {object[]} opts.stories — User stories (from SF-02 userStories)
 * @param {object} opts.dependencies — Dependency graph { nodes, edges }
 * @param {object[]} opts.priorities — Priority matrix entries (from SF-02)
 * @param {object} [opts.config] — Sprint planning config
 * @returns {object} Sprint plan
 *
 * @example
 * ```js
 * const plan = generateSprintPlan({
 *   stories: myStories,
 *   dependencies: myGraph,
 *   priorities: myPriorities,
 *   config: { sprintDurationWeeks: 2, teamVelocity: 20, maxStoryPointsPerSprint: 25 }
 * });
 * ```
 */
export function generateSprintPlan({ stories, dependencies, priorities, config }) {
  const cfg = defaultSprintConfig(config);
  const warnings = [];
  const unplannedStories = [];

  // ── 1. Input validation ──
  if (!stories || stories.length === 0) {
    return {
      sprints: [],
      metrics: { totalSprints: 0, totalPoints: 0, avgPointsPerSprint: 0, estimatedDurationWeeks: 0 },
      unplannedStories: [],
      warnings: ["No stories provided"],
    };
  }

  const graph = dependencies && dependencies.graph ? dependencies.graph : (dependencies || { nodes: [], edges: [] });

  // ── 2. Build story map ──
  const storyMap = new Map();
  for (const s of stories) storyMap.set(s.id, s);

  // ── 3. Filter Must Have stories from priorities ──
  const priorityMap = new Map();
  for (const p of (priorities || [])) {
    if (p.itemType === "story") {
      priorityMap.set(p.itemId, p.category);
    }
  }

  const mustHaveIds = new Set();
  const shouldHaveIds = new Set();
  const couldHaveIds = new Set();

  for (const s of stories) {
    const cat = priorityMap.get(s.id) || s.priority || "Could Have";
    if (cat === "Must Have") mustHaveIds.add(s.id);
    else if (cat === "Should Have") shouldHaveIds.add(s.id);
    else if (cat === "Could Have") couldHaveIds.add(s.id);
  }

  // ── 4. Build dependency map ──
  const allDepMap = buildDependencyMap(stories, graph);

  // ── 5. Topological sort Must Have stories ──
  const mustHaveList = [...mustHaveIds];
  const sortedMustHave = topologicalSort(mustHaveList, allDepMap);

  // Also include Should Have and Could Have for later allocation
  const shouldHaveList = [...shouldHaveIds];
  const couldHaveList = [...couldHaveIds];
  const allSorted = [
    ...sortedMustHave,
    ...topologicalSort(shouldHaveList, allDepMap).filter(id => !mustHaveIds.has(id)),
    ...topologicalSort(couldHaveList, allDepMap).filter(id => !mustHaveIds.has(id) && !shouldHaveIds.has(id)),
  ];

  // ── 6. Compute dependency levels ──
  const depLevels = computeDependencyLevels(allSorted, allDepMap);

  // ── 7. Assign stories to sprints ──
  const sprints = [];
  const allocated = new Set();
  const allocatedInSprints = new Set();
  let sprintNumber = 0;

  // Sort all stories by: dependency level (ascending), then priority
  const queue = allSorted
    .filter(id => storyMap.has(id))
    .sort((a, b) => {
      // Base sort: lower dep level first, then Must > Should > Could
      const levelA = depLevels.get(a) || 0;
      const levelB = depLevels.get(b) || 0;
      if (levelA !== levelB) return levelA - levelB;
      // Priority tiebreaker
      const priA = priorityMap.get(a) || "Could Have";
      const priB = priorityMap.get(b) || "Could Have";
      const priOrder = { "Must Have": 0, "Should Have": 1, "Could Have": 2 };
      return (priOrder[priA] ?? 3) - (priOrder[priB] ?? 3);
    });

  let idx = 0;
  while (idx < queue.length) {
    sprintNumber++;
    const sprintCapacity = Math.min(cfg.maxStoryPointsPerSprint, cfg.teamVelocity);
    const sprintStories = [];
    let sprintPoints = 0;

    while (idx < queue.length && sprintPoints < sprintCapacity) {
      const sid = queue[idx];
      const story = storyMap.get(sid);
      if (!story) { idx++; continue; }

      const points = story.storyPoints || 1;

      // Check all dependencies are allocated to earlier sprints or this sprint
      const deps = allDepMap.get(sid);
      const allDepsResolved = !deps || [...deps].every(depId => allocatedInSprints.has(depId) || depId === sid);

      // Don't include stories whose deps aren't resolved yet
      if (!allDepsResolved) {
        idx++;
        continue;
      }

      // Check capacity
      if (sprintPoints + points > sprintCapacity) {
        // Try next story (could be smaller)
        idx++;
        continue;
      }

      sprintStories.push(sid);
      sprintPoints += points;
      allocated.add(sid);
      allocatedInSprints.add(sid);
      idx++;
    }

    if (sprintStories.length === 0) {
      // No stories fit in this sprint — break to avoid infinite loop
      break;
    }

    const sprintGoal = buildSprintGoal(sprintStories, stories, sprintNumber);

    // Determine epics and features covered
    const epicIds = new Set();
    const featureIds = new Set();
    for (const sid of sprintStories) {
      const st = storyMap.get(sid);
      if (st) {
        if (st.epicId) epicIds.add(st.epicId);
        if (st.featureId) featureIds.add(st.featureId);
      }
    }

    // Critical path within this sprint
    const criticalPath = findCriticalPath(sprintStories, allDepMap, depLevels);

    // Identify risks
    const completedIds = new Set();
    for (let i = 0; i < sprints.length; i++) {
      for (const sid of sprints[i].storyIds) completedIds.add(sid);
    }
    const risks = identifyRisks(sprintStories, allDepMap, completedIds);

    const startWeek = 1 + (sprintNumber - 1) * cfg.sprintDurationWeeks;
    const endWeek = startWeek + cfg.sprintDurationWeeks - 1;

    sprints.push({
      sprintNumber,
      sprintGoal,
      startDate: `Week ${startWeek}`,
      endDate: `Week ${endWeek}`,
      storyIds: sprintStories,
      totalStoryPoints: sprintPoints,
      epicIds: [...epicIds],
      featureIds: [...featureIds],
      criticalPath,
      risks,
    });

    if (sprintPoints > cfg.teamVelocity) {
      warnings.push(`Sprint ${sprintNumber} exceeds team velocity (${sprintPoints} > ${cfg.teamVelocity} pts)`);
    }
  }

  // ── 8. Identify unplanned stories ──
  for (const id of allSorted) {
    if (!allocated.has(id) && storyMap.has(id)) {
      unplannedStories.push(id);
    }
  }

  // Also check for stories not in the sorted queue at all
  for (const s of stories) {
    if (!allocated.has(s.id) && !unplannedStories.includes(s.id)) {
      unplannedStories.push(s.id);
    }
  }

  // ── 9. Calculate metrics ──
  const totalPoints = sprints.reduce((acc, sp) => acc + sp.totalStoryPoints, 0);
  const totalSprints = sprints.length;
  const estimatedDurationWeeks = totalSprints * cfg.sprintDurationWeeks;

  return {
    sprints,
    metrics: {
      totalSprints,
      totalPoints,
      avgPointsPerSprint: totalSprints > 0 ? Math.round(totalPoints / totalSprints) : 0,
      estimatedDurationWeeks,
    },
    unplannedStories,
    warnings,
  };
}

/**
 * Build a human-readable sprint goal from the assigned stories.
 *
 * @param {string[]} storyIds — Stories in this sprint
 * @param {object[]} stories — All stories
 * @param {number} sprintNumber — Sprint number
 * @returns {string} Sprint goal description
 */
function buildSprintGoal(storyIds, stories, sprintNumber) {
  const storyMap = new Map();
  for (const s of stories) storyMap.set(s.id, s);

  // Extract the most common goal themes
  const goals = [];
  const seen = new Set();
  for (const id of storyIds) {
    const s = storyMap.get(id);
    if (s && s.goal) {
      const shortGoal = s.goal.length > 40 ? s.goal.slice(0, 40) + "…" : s.goal;
      if (!seen.has(shortGoal)) {
        seen.add(shortGoal);
        goals.push(shortGoal);
      }
    }
  }

  const topGoals = goals.slice(0, 3);
  return `Sprint ${sprintNumber}: ${topGoals.join(" / ")}`;
}

export default { generateSprintPlan, defaultSprintConfig, buildDependencyMap, topologicalSort };
