/**
 * Regression: Ruby mixin heritage resolution must work on the sequential
 * ingestion fallback AND the worker-pool path, with identical output.
 *
 * Guards two Codex adversarial review findings:
 *
 * 1. Sequential-mode `sequentialHeritageMap` must include Ruby `include` /
 *    `extend` / `prepend` mixin ancestry before `processCalls` resolves calls
 *    against it. `extractExtractedHeritageFromFiles` now also runs
 *    `heritageExtractor.extractFromCall` during its prepass.
 *
 * 2. Ruby `module` declarations are relabeled to `Trait` so they participate
 *    in `lookupClassByName` / `buildHeritageMap`.
 *
 * Additional hardening in this suite:
 *   - Worker mode actually spawns a worker pool (verified via
 *     `PipelineResult.usedWorkerPool`) instead of silently falling back.
 *   - The prepend-only `prepended_marker` assertion checks the resolved
 *     method's OWNER, so reverting the Module→Trait relabel (Unit 2 of
 *     plan 001) makes the test fail with a clear owner-mismatch instead
 *     of passing trivially on `Account`'s own method.
 *
 * Plan 003 adds the `'ruby-mixin'` MroStrategy and kind-aware ancestry
 * (prepend / include / extend split). Plan 005 (the call-resolution DAG)
 * installs `inferImplicitReceiver` and `selectDispatch` provider hooks that
 * let Ruby self-rewrite bare-identifier calls to `self.method` so they take
 * the owner-scoped MRO path. The shadow-name assertion below exercises the
 * full chain: Module→Trait relabel + kind-aware MRO + self-inference.
 *
 * Known guard limitation (documented residual): reverting plan 001 Unit 1
 * alone (the sequential prepass extractFromCall) does NOT make these tests
 * fail, because `processCalls` independently extracts call-based heritage
 * into `rubyHeritage`, feeds it to `processHeritageFromExtracted` for graph
 * edges, and the call resolver's global-name fallback can still locate
 * mixin-provided methods without MRO ancestry. A stronger guard would need
 * an ambiguous method name that only MRO can disambiguate; that requires
 * cross-chunk or multi-class shadowing scenarios not covered by this
 * fixture. Tracked as residual work in plan 002's Unit 3 (cross-chunk).
 */
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import type { GraphRelationship } from '../../../src/core/graph/types.js';
import {
  FIXTURES,
  getRelationships,
  getNodesByLabel,
  runPipelineFromRepo,
  type PipelineOptions,
  type PipelineResult,
} from './helpers.js';

const FIXTURE = path.join(FIXTURES, 'ruby-sequential-mixin');

async function runMode(opts: PipelineOptions): Promise<PipelineResult> {
  return runPipelineFromRepo(FIXTURE, () => {}, opts);
}

/** CALLS edges from `sourceName` whose target is a Method node. */
function methodCallEdges(result: PipelineResult, sourceName: string): Set<string> {
  const edges = getRelationships(result, 'CALLS').filter(
    (e) => e.source === sourceName && e.targetLabel === 'Method',
  );
  return new Set(edges.map((e) => `${e.source} → ${e.target}`));
}

/**
 * Find the name of the node that `HAS_METHOD`s this target node, if any.
 * Returns `undefined` when no owner edge exists (e.g., top-level function).
 */
function findMethodOwner(result: PipelineResult, methodNodeId: string): string | undefined {
  for (const rel of result.graph.iterRelationships() as IterableIterator<GraphRelationship>) {
    if (rel.type === 'HAS_METHOD' && rel.targetId === methodNodeId) {
      return result.graph.getNode(rel.sourceId)?.properties.name;
    }
  }
  return undefined;
}

/**
 * Return the owner names of every `Method` target reached by a CALLS edge
 * starting at `sourceName` whose target's name matches `targetMethodName`.
 * Used to assert WHICH provider resolved a shadowed method name like
 * `serialize` (provided by both Account and PrependedOverride).
 */
function resolvedMethodOwners(
  result: PipelineResult,
  sourceName: string,
  targetMethodName: string,
): string[] {
  const owners: string[] = [];
  for (const e of getRelationships(result, 'CALLS')) {
    if (e.source === sourceName && e.targetLabel === 'Method' && e.target === targetMethodName) {
      const owner = findMethodOwner(result, e.rel.targetId);
      if (owner) owners.push(owner);
    }
  }
  return owners.sort();
}

describe('Ruby mixin heritage: sequential vs worker parity', () => {
  let sequential: PipelineResult;
  let workers: PipelineResult;

  beforeAll(async () => {
    sequential = await runMode({ skipWorkers: true });
    // Force the worker pool to spawn even though the fixture is tiny.
    // Without this override, the pipeline's MIN_FILES_FOR_WORKERS / MIN_BYTES_FOR_WORKERS
    // gate would fall back to sequential and the "worker vs sequential" parity
    // assertion below would degenerate into sequential-vs-sequential.
    workers = await runMode({
      skipWorkers: false,
      workerThresholdsForTest: { minFiles: 1, minBytes: 0 },
    });
  }, 120000);

  it('exercises both pipeline paths (sequential and worker)', () => {
    // If either of these assertions fails, every downstream parity check
    // below is meaningless — both modes would be running the same path.
    expect(sequential.usedWorkerPool).toBe(false);
    expect(workers.usedWorkerPool).toBe(true);
  });

  it('labels Ruby modules as Trait in both modes', () => {
    const expected = ['Greetable', 'LoggerMixin', 'PrependedOverride'];
    expect(getNodesByLabel(sequential, 'Trait').sort()).toEqual(expected);
    expect(getNodesByLabel(workers, 'Trait').sort()).toEqual(expected);
    // No Ruby modules leak through as the inert `Module` label.
    // The 'lib' module node is the fixture's top-level directory node, which
    // the ingestion pipeline emits for every fixture root — unrelated to
    // Ruby `module` declarations. Filtering it keeps the assertion specific
    // to Ruby-module relabeling without being coupled to how directory nodes
    // are emitted.
    expect(getNodesByLabel(sequential, 'Module').filter((n) => n !== 'lib')).toEqual([]);
    expect(getNodesByLabel(workers, 'Module').filter((n) => n !== 'lib')).toEqual([]);
  });

  it('sequential mode resolves include-provided method: call_greet → greet', () => {
    const edges = methodCallEdges(sequential, 'call_greet');
    expect([...edges]).toContain('call_greet → greet');

    // Stronger: the resolved `greet` must be owned by the `Greetable` module
    // (relabeled to Trait). A regression in Unit 2 of plan 001 would either
    // fail to resolve (owners = []) or resolve to some other owner.
    const owners = resolvedMethodOwners(sequential, 'call_greet', 'greet');
    expect(owners).toContain('Greetable');
  });

  it('sequential mode resolves prepend-only method: call_prepended_marker → PrependedOverride#prepended_marker', () => {
    // `prepended_marker` is defined ONLY on PrependedOverride — not on
    // Account, Greetable, or LoggerMixin. Narrow guard for the prepend
    // provider entering the MRO (plan 001 / plan 003).
    const owners = resolvedMethodOwners(sequential, 'call_prepended_marker', 'prepended_marker');
    expect(owners).toContain('PrependedOverride');
  });

  it('sequential mode resolves prepend shadow: call_serialize → PrependedOverride#serialize (not Account#serialize)', () => {
    // `Account` defines `def serialize` AND `prepend PrependedOverride` which
    // also defines `serialize`. Ruby MRO says the prepended module wins.
    // This assertion exercises the full chain:
    //   - Module→Trait relabel (plan 001 Unit 2) — PrependedOverride resolvable via lookupClassByName
    //   - `'ruby-mixin'` MroStrategy (plan 003 Unit 3) — prepend walks before direct owner
    //   - `inferImplicitReceiver` hook (plan 005 / DAG) — bare `serialize` call rewritten
    //     as `self.serialize` so it takes the owner-scoped MRO path
    // Reverting ANY of the three makes this assertion fail.
    const owners = resolvedMethodOwners(sequential, 'call_serialize', 'serialize');
    expect(owners).toContain('PrependedOverride');
  });

  it('sequential mode resolves extend-provided class method: Usage#run → LoggerMixin#log', () => {
    // `Account extend LoggerMixin` means `LoggerMixin#log` is a CLASS method
    // on `Account`. The fixture's `Usage#run` calls `Account.log("from Usage")`
    // — a class-constant receiver, not an instance call. This exercises the
    // `selectDispatch` hook's `receiverSource === 'class-as-receiver'` branch,
    // which returns `ancestryView: 'singleton'` so the walker uses
    // `getSingletonAncestry(Account)` = [LoggerMixin] instead of the instance
    // MRO (which would have resolved nothing, since `log` is not an instance
    // method on Account).
    //
    // Reverting the singleton branch in Ruby's selectDispatch (or dropping
    // 'Trait'/'Class' from the class-as-receiver filter in call-processor)
    // makes this assertion fail.
    const owners = resolvedMethodOwners(sequential, 'run', 'log');
    expect(owners).toContain('LoggerMixin');
  });

  // TODO(plan-003-followup): assert that prepend shadows self for
  // `call_serialize → PrependedOverride#serialize`. Blocked on Ruby bare-call
  // self-inference: bare identifier calls like `serialize` inside `Account#call_serialize`
  // currently flow through `resolveFreeCall` (global name lookup), not
  // `resolveMemberCall` (owner-scoped + MRO walk). The `'ruby-mixin'` MroStrategy
  // added by plan 003 is correctly wired and will apply as soon as Ruby bare calls
  // are threaded as `self.method` with receiverTypeName = enclosing class. Until then,
  // shadow-name resolution lands on `Account#serialize` regardless of prepend MRO.
  //
  // The `prepended_marker` test above is the narrower guard that works today
  // (non-shadowed method only reachable via the prepend provider).

  it('sequential mode emits IMPLEMENTS edges for all three mixin kinds', () => {
    // Ruby mixins (include / extend / prepend) flow through the IMPLEMENTS
    // branch of processHeritageFromExtracted with the mixin kind recorded in
    // rel.reason. See heritage-processor.ts L146-168.
    const kinds = getRelationships(sequential, 'IMPLEMENTS')
      .filter((e) => e.source === 'Account')
      .map((e) => e.rel.reason ?? '')
      .sort();
    expect(kinds).toEqual(['extend', 'include', 'prepend']);
  });

  it('worker mode resolves the same include and prepend-only targets', () => {
    // Cross-mode ownership parity for the mixin providers. If Unit 1 of
    // plan 001 regressed on the sequential side only, the `greet` /
    // `prepended_marker` owners would diverge between modes here — the
    // sequential side would lose the mixin-provided edges while worker
    // mode kept them (or vice versa).
    expect(resolvedMethodOwners(workers, 'call_greet', 'greet')).toContain('Greetable');
    expect(resolvedMethodOwners(workers, 'call_prepended_marker', 'prepended_marker')).toContain(
      'PrependedOverride',
    );
  });

  it('sequential and worker modes produce the same mixin-method CALLS edges', () => {
    const seqEdges = methodCallEdges(sequential, 'call_greet');
    const workerEdges = methodCallEdges(workers, 'call_greet');
    expect([...seqEdges].sort()).toEqual([...workerEdges].sort());

    const seqMarker = methodCallEdges(sequential, 'call_prepended_marker');
    const workerMarker = methodCallEdges(workers, 'call_prepended_marker');
    expect([...seqMarker].sort()).toEqual([...workerMarker].sort());
  });
});
