'use strict';

/** @typedef {import('./validator-types').Issue} Issue */

/**
 * @typedef {Object} Edge
 * @property {string} target       Qualified target table name
 * @property {boolean} mandatory   true when source FK column is NOT NULL
 * @property {string} relationName
 *
 * @typedef {Object} Cycle
 * @property {string[]} path       Qualified table names in cycle order
 * @property {Edge[]} edges        Edges traversed, length === path.length (last edge closes the loop)
 */

/**
 * Detect cycles in the belongsTo relation graph and emit issues:
 *
 *   - `circular-mandatory-relation` (error)     all edges in the cycle are mandatory
 *                                               (or self-reference with NOT NULL FK)
 *   - `circular-relation-nullable`  (warning)   multi-node cycle with at least one nullable edge
 *
 * Nullable self-references are intentionally NOT reported — they are a common
 * pattern for tree/hierarchy data (e.g., employee.manager_id, comment.parent_id)
 * and root rows can insert with FK = NULL.
 *
 * MUST run after `validateCrossModel` because edge targets rely on the resolved
 * qualified `rel.target`.
 *
 * @param {Map<string, object>} models
 * @returns {Issue[]}
 */
function validateCircularRelations(models) {
  const graph = buildRelationGraph(models);
  const cycles = detectCycles(graph);

  const issues = [];
  for (const cycle of cycles) {
    const issue = buildIssue(cycle, models);
    if (issue) issues.push(issue);
  }
  return issues;
}

/**
 * @param {Map<string, object>} models
 * @returns {Map<string, Edge[]>}
 */
function buildRelationGraph(models) {
  const graph = new Map();
  for (const qualified of models.keys()) graph.set(qualified, []);

  for (const [sourceQualified, ir] of models) {
    for (const [relName, rel] of Object.entries(ir.relations)) {
      // Only belongsTo carries a FK column at the source. hasOne / hasMany
      // are inverse relations whose FK lives on the child.
      if (rel.type !== 'belongsTo') continue;

      const targetQualified = rel.target;
      if (typeof targetQualified !== 'string') continue;
      if (!models.has(targetQualified)) continue; // cross-model already reported

      const sourceField = ir.fields[rel.localKey];
      const mandatory = !!(sourceField && sourceField.notnull);

      graph.get(sourceQualified).push({
        target: targetQualified,
        mandatory,
        relationName: relName
      });
    }
  }
  return graph;
}

/**
 * @param {Map<string, Edge[]>} graph
 * @returns {Cycle[]}
 */
function detectCycles(graph) {
  const WHITE = 0;
  const GRAY = 1;
  const BLACK = 2;

  const color = new Map();
  for (const node of graph.keys()) color.set(node, WHITE);

  const cycles = [];
  const seenCanonical = new Set();

  // Sort entry points for deterministic output across runs.
  const entryPoints = [...graph.keys()].sort();

  for (const start of entryPoints) {
    if (color.get(start) !== WHITE) continue;

    const pathNodes = [];
    const pathEdges = [];
    dfs(start);

    function dfs(node) {
      color.set(node, GRAY);
      pathNodes.push(node);

      const outgoing = graph.get(node) || [];
      for (const edge of outgoing) {
        const target = edge.target;
        const targetColor = color.get(target);

        if (targetColor === GRAY) {
          // Back-edge: reconstruct cycle starting at the back-edge target.
          const startIdx = pathNodes.indexOf(target);
          if (startIdx < 0) continue;

          const cyclePath = pathNodes.slice(startIdx);
          const cycleEdges = pathEdges.slice(startIdx).concat(edge);
          const canonical = canonicalKey(cyclePath, cycleEdges);
          if (seenCanonical.has(canonical)) continue;
          seenCanonical.add(canonical);
          cycles.push({ path: cyclePath, edges: cycleEdges });
        } else if (targetColor === WHITE) {
          pathEdges.push(edge);
          dfs(target);
          pathEdges.pop();
        }
        // BLACK: already fully explored from elsewhere, no cycle through this path.
      }

      pathNodes.pop();
      color.set(node, BLACK);
    }
  }

  return cycles;
}

/**
 * Produce a stable, rotation-independent key for a cycle so DFS visits from
 * different starting nodes do not double-report the same cycle. Includes
 * edge relationNames so two parallel relations between the same pair of
 * tables (e.g., invoice → customer via billing_id AND shipping_id) are
 * treated as distinct cycles.
 */
function canonicalKey(path, edges) {
  // Build [node + ':' + outgoingRelationName] tokens, then rotate so the
  // lexicographically smallest token is first.
  const tokens = path.map((node, i) => `${node}->${edges[i].relationName}`);
  let minIdx = 0;
  for (let i = 1; i < tokens.length; i++) {
    if (tokens[i] < tokens[minIdx]) minIdx = i;
  }
  const rotated = tokens.slice(minIdx).concat(tokens.slice(0, minIdx));
  return rotated.join('|');
}

/**
 * @param {Cycle} cycle
 * @param {Map<string, object>} models
 * @returns {Issue | null}
 */
function buildIssue(cycle, models) {
  // Self-reference (cycle length 1): only mandatory case is reported.
  if (cycle.path.length === 1) {
    if (!cycle.edges[0].mandatory) return null;
    const node = cycle.path[0];
    const sourceIr = models.get(node);
    return decorate({
      severity: 'error',
      code: 'circular-mandatory-relation',
      message: `Circular dependency: ${node} → ${node} (self-reference, NOT NULL FK '${cycle.edges[0].relationName}' makes insert impossible)`
    }, sourceIr);
  }

  const allMandatory = cycle.edges.every((e) => e.mandatory);
  const pathStr = cycle.path.concat(cycle.path[0]).join(' → '); // close visually

  if (allMandatory) {
    return decorate({
      severity: 'error',
      code: 'circular-mandatory-relation',
      message: `Circular dependency: ${pathStr} (all relations mandatory, insert impossible)`
    }, models.get(cycle.path[0]));
  }

  return decorate({
    severity: 'warning',
    code: 'circular-relation-nullable',
    message: `Circular dependency: ${pathStr} (at least one nullable edge — insertable but consider whether the cycle is intentional)`
  }, models.get(cycle.path[0]));
}

function decorate(issue, ir) {
  const decorated = {
    severity: issue.severity,
    code: issue.code,
    message: issue.message
  };
  if (ir && ir._filePath) decorated.file = ir._filePath;
  if (ir && ir.qualifiedName) decorated.table = ir.qualifiedName;
  return decorated;
}

module.exports = { validateCircularRelations };
