import { assert } from "../../../shared/src/asserts.js";
import { mergeConstraints } from "./planner-constraint.js";
import { omitFanout } from "./planner-node.js";
function translateConstraintsForFlippedJoin(incomingConstraint, parentConstraint, childConstraint) {
  if (!incomingConstraint) return void 0;
  const parentKeys = Object.keys(parentConstraint);
  const childKeys = Object.keys(childConstraint);
  const translated = {};
  for (const [key, value] of Object.entries(incomingConstraint)) {
    const index = parentKeys.indexOf(key);
    if (index !== -1) {
      translated[childKeys[index]] = value;
    }
  }
  return Object.keys(translated).length > 0 ? translated : void 0;
}
class PlannerJoin {
  kind = "join";
  #parent;
  #child;
  #parentConstraint;
  #childConstraint;
  #flippable;
  planId;
  #output;
  // Set once during graph construction
  // Reset between planning attempts
  #type;
  #initialType;
  constructor(parent, child, parentConstraint, childConstraint, flippable, planId, initialType = "semi") {
    this.#type = initialType;
    this.#initialType = initialType;
    this.#parent = parent;
    this.#child = child;
    this.#childConstraint = childConstraint;
    this.#parentConstraint = parentConstraint;
    this.#flippable = flippable;
    this.planId = planId;
  }
  setOutput(node) {
    this.#output = node;
  }
  get output() {
    assert(this.#output !== void 0, "Output not set");
    return this.#output;
  }
  closestJoinOrSource() {
    return "join";
  }
  flipIfNeeded(input) {
    if (input === this.#child) {
      this.flip();
    } else {
      assert(
        input === this.#parent,
        "Can only flip a join from one of its inputs"
      );
    }
  }
  flip() {
    assert(this.#type === "semi", "Can only flip a semi-join");
    if (!this.#flippable) {
      throw new UnflippableJoinError(
        "Cannot flip a non-flippable join (e.g., NOT EXISTS)"
      );
    }
    this.#type = "flipped";
  }
  get type() {
    return this.#type;
  }
  isFlippable() {
    return this.#flippable;
  }
  /**
   * Propagate unlimiting when this join is flipped.
   * When a join is flipped:
   * 1. Child becomes outer loop → produces all rows (unlimited)
   * 2. Parent is fetched once per child row → effectively unlimited
   *
   * Example: If child produces 896 rows, parent is fetched 896 times.
   * Even if each fetch returns 1 row, parent produces 896 total rows.
   *
   * Propagation rules:
   * - Connection: call unlimit()
   * - Semi-join: continue to parent (outer loop)
   * - Flipped join: stop (already unlimited when it was flipped)
   * - Fan-out/Fan-in: propagate to all inputs
   */
  propagateUnlimit() {
    assert(this.#type === "flipped", "Can only unlimit a flipped join");
    this.#child.propagateUnlimitFromFlippedJoin();
  }
  /**
   * Called when a parent join is flipped and this join is part of its child subgraph.
   * Continue propagation to parent (the outer loop).
   * If we are hitting a semi-join, the parent drives.
   * If we are hitting a flip-join, well now we have to unlimit its parent too!
   */
  propagateUnlimitFromFlippedJoin() {
    this.#parent.propagateUnlimitFromFlippedJoin();
  }
  propagateConstraints(branchPattern, constraint, from, planDebugger) {
    planDebugger?.log({
      type: "node-constraint",
      nodeType: "join",
      node: this.getName(),
      branchPattern,
      constraint,
      from: from ? getNodeName(from) : "unknown"
    });
    if (this.#type === "semi") {
      this.#child.propagateConstraints(
        branchPattern,
        this.#childConstraint,
        this,
        planDebugger
      );
      this.#parent.propagateConstraints(
        branchPattern,
        constraint,
        this,
        planDebugger
      );
    } else if (this.#type === "flipped") {
      const translatedConstraint = translateConstraintsForFlippedJoin(
        constraint,
        this.#parentConstraint,
        this.#childConstraint
      );
      this.#child.propagateConstraints(
        branchPattern,
        translatedConstraint,
        this,
        planDebugger
      );
      this.#parent.propagateConstraints(
        branchPattern,
        mergeConstraints(constraint, this.#parentConstraint),
        this,
        planDebugger
      );
    }
  }
  reset() {
    this.#type = this.#initialType;
  }
  estimateCost(downstreamChildSelectivity, branchPattern, planDebugger) {
    const child = this.#child.estimateCost(1, branchPattern, planDebugger);
    const fanoutFactor = child.fanout(Object.keys(this.#childConstraint));
    const scaledChildSelectivity = 1 - Math.pow(1 - child.selectivity, fanoutFactor.fanout);
    const parent = this.#parent.estimateCost(
      // Selectivity flows up the graph from child to parent
      // so we can determine the total selectivity of all ANDed exists checks.
      this.#type === "flipped" ? 1 * downstreamChildSelectivity : scaledChildSelectivity * downstreamChildSelectivity,
      branchPattern,
      planDebugger
    );
    let costEstimate;
    if (this.type === "semi") {
      costEstimate = {
        startupCost: parent.startupCost,
        scanEst: parent.limit === void 0 ? parent.returnedRows : Math.min(
          parent.returnedRows,
          downstreamChildSelectivity === 0 ? 0 : parent.limit / downstreamChildSelectivity
        ),
        cost: parent.cost + parent.scanEst * (child.startupCost + child.cost + child.scanEst),
        returnedRows: parent.returnedRows * child.selectivity,
        selectivity: child.selectivity * parent.selectivity,
        limit: parent.limit,
        fanout: parent.fanout
      };
    } else {
      costEstimate = {
        startupCost: child.startupCost,
        scanEst: parent.limit === void 0 ? parent.returnedRows * child.returnedRows : Math.min(
          parent.returnedRows * child.returnedRows,
          downstreamChildSelectivity === 0 ? 0 : parent.limit / downstreamChildSelectivity
        ),
        cost: child.cost + child.scanEst * (parent.startupCost + parent.cost + parent.scanEst),
        // the child selectivity is not relevant here because it has already been taken into account via the flipping.
        // I.e., `child.returnedRows` is the estimated number of rows produced by the child _after_ taking filtering into account.
        returnedRows: parent.returnedRows * child.returnedRows,
        selectivity: parent.selectivity * child.selectivity,
        limit: parent.limit,
        fanout: parent.fanout
      };
    }
    if (planDebugger) {
      planDebugger.log({
        type: "node-cost",
        nodeType: "join",
        node: this.getName(),
        branchPattern,
        downstreamChildSelectivity,
        costEstimate: omitFanout(costEstimate),
        joinType: this.#type
      });
    }
    return costEstimate;
  }
  /**
   * Get a human-readable name for this join for debugging.
   * Format: "parentName ⋈ childName"
   */
  getName() {
    const parentName = getNodeName(this.#parent);
    const childName = getNodeName(this.#child);
    return `${parentName} ⋈ ${childName}`;
  }
  /**
   * Get debug information about this join's state.
   */
  getDebugInfo() {
    return {
      name: this.getName(),
      type: this.#type,
      planId: this.planId
    };
  }
}
class UnflippableJoinError extends Error {
  constructor(message) {
    super(message);
    this.name = "UnflippableJoinError";
  }
}
function getNodeName(node) {
  switch (node.kind) {
    case "connection":
      return node.name;
    case "join":
      return node.getName();
    case "fan-out":
      return "FO";
    case "fan-in":
      return "FI";
    case "terminus":
      return "terminus";
  }
}
export {
  PlannerJoin,
  UnflippableJoinError
};
//# sourceMappingURL=planner-join.js.map
