/**
 * Parity test for the two Spring route extractors (#2078 maintainer request,
 * #2138 follow-up).
 *
 * GitNexus parses Spring `@(Get|Post|...)Mapping` annotations in TWO places:
 *   - ingestion `route-extractors/spring.ts` → `extractSpringRoutes` (produces
 *     graph `Route` nodes)
 *   - group `http-patterns/java.ts` → `JAVA_HTTP_PLUGIN.scan` (produces
 *     cross-repo HTTP contracts)
 *
 * They serve different layers and stay separate, but they MUST agree on the
 * set of provider (method, path) routes they recognise for the same source —
 * otherwise the graph under-covers what the group scan sees, which is exactly
 * the divergence behind the #2265 array-form gap (the group query matched
 * `@GetMapping({"/a","/b"})`, ingestion's didn't). This test runs one shared
 * fixture through both and asserts the provider sets are identical for the
 * shapes ingestion claims to cover: bare, named-arg, and array-form method
 * routes under a *scalar* (or absent) class prefix.
 *
 * Known, deliberate divergence (NOT covered by the equality assertions): a
 * method-level array route nested under a class-level *array-form*
 * @RequestMapping. Ingestion suppresses it (it can't resolve which of several
 * class prefixes to apply, and a dropped-prefix route is a wrong signal), while
 * the group layer emits the full cross-product. The last test pins this so the
 * suppression can't silently regress into emitting wrong routes; full
 * class-array support is tracked in #2280.
 */
import { describe, it, expect } from 'vitest';
import Parser from 'tree-sitter';
import Java from 'tree-sitter-java';
import { extractSpringRoutes } from '../../../src/core/ingestion/route-extractors/spring.js';
import { JAVA_HTTP_PLUGIN } from '../../../src/core/group/extractors/http-patterns/java.js';

function parse(src: string): Parser.Tree {
  const p = new Parser();
  p.setLanguage(Java);
  return p.parse(src);
}

/** Canonical `METHOD /a/b` form so prefix-join / slash / case differences wash out. */
function canon(method: string, ...segments: string[]): string {
  const path = `/${segments.join('/').split('/').filter(Boolean).join('/')}`;
  return `${method.toUpperCase()} ${path.toLowerCase()}`;
}

/** ingestion side: join the class prefix + method path the way the routes phase does. */
function ingestionProviders(src: string): Set<string> {
  return new Set(
    extractSpringRoutes(parse(src), 'X.java').map((r) =>
      canon(r.httpMethod, r.prefix ?? '', r.routePath),
    ),
  );
}

/** group side: provider detections (path already prefix-joined by the plugin). */
function groupProviders(src: string): Set<string> {
  return new Set(
    JAVA_HTTP_PLUGIN.scan(parse(src))
      .filter((d) => d.role === 'provider')
      .map((d) => canon(d.method, d.path)),
  );
}

function groupConsumers(src: string): Set<string> {
  return new Set(
    JAVA_HTTP_PLUGIN.scan(parse(src))
      .filter((d) => d.role === 'consumer' && d.framework === 'openfeign')
      .map((d) => canon(d.method, d.path)),
  );
}

describe('Spring route extractor parity — ingestion spring.ts vs group java.ts', () => {
  it('agree on bare, named-arg, and array-form method routes under a class prefix', () => {
    const src = `package com.example;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/orders")
public class OrderController {
  @GetMapping("/list") public Object list() { return null; }
  @PostMapping(path = "/make") public Object make() { return null; }
  @PutMapping(value = "/update") public Object update() { return null; }
  @GetMapping({"/a", "/b"}) public Object multi() { return null; }
}
`;
    const ingestion = ingestionProviders(src);
    const group = groupProviders(src);

    // The array form is the regression that motivated this: both must see all four.
    expect(group).toEqual(
      new Set([
        'GET /api/orders/list',
        'POST /api/orders/make',
        'PUT /api/orders/update',
        'GET /api/orders/a',
        'GET /api/orders/b',
      ]),
    );
    expect(ingestion).toEqual(group);
  });

  it('agree on a no-prefix controller with a positional array', () => {
    const src = `package com.example;
import org.springframework.web.bind.annotation.*;

@RestController
public class PlainController {
  @GetMapping("/solo") public Object solo() { return null; }
  @DeleteMapping({"/x", "/y", "/z"}) public Object many() { return null; }
}
`;
    expect(ingestionProviders(src)).toEqual(groupProviders(src));
  });

  it('agree on named-arg ARRAY forms (path = {...} / value = {...})', () => {
    // Guards the spring.ts named-array query branch specifically: positional
    // arrays alone would not exercise it.
    const src = `package com.example;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api")
public class NamedArrayController {
  @PutMapping(value = {"/update", "/modify"}) public Object upd() { return null; }
  @DeleteMapping(path = {"/x", "/y"}) public Object del() { return null; }
}
`;
    const group = groupProviders(src);
    expect(group).toEqual(
      new Set(['PUT /api/update', 'PUT /api/modify', 'DELETE /api/x', 'DELETE /api/y']),
    );
    expect(ingestionProviders(src)).toEqual(group);
  });

  it('agree on method-level @RequestMapping verbs and wildcard mappings', () => {
    const src = `package com.example;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api")
public class LegacyController {
  @RequestMapping("/bare") public Object bare() { return null; }
  @RequestMapping(value = "/named") public Object named() { return null; }
  @RequestMapping(value = "/save", method = RequestMethod.POST)
  public Object save() { return null; }
  @RequestMapping(path = "/multi", method = {RequestMethod.GET, RequestMethod.HEAD})
  public Object multi() { return null; }
  @RequestMapping(path = {"/one", "/two"}, method = {RequestMethod.GET, RequestMethod.POST})
  public Object crossProduct() { return null; }
  @RequestMapping(path = "/empty", method = {})
  public Object empty() { return null; }
  @RequestMapping(path = "/spaced", method = RequestMethod . PUT)
  public Object spaced() { return null; }
  @RequestMapping(path = "/commented", method = {
    RequestMethod.GET, // read
    /* write */ RequestMethod.POST,
  })
  public Object commented() { return null; }
  @org.springframework.web.bind.annotation.RequestMapping(
    path = "/fqn",
    method = org.springframework.web.bind.annotation.RequestMethod.DELETE
  )
  public Object fqn() { return null; }
}
`;
    const expected = new Set([
      '* /api/bare',
      '* /api/named',
      'POST /api/save',
      'GET /api/multi',
      'HEAD /api/multi',
      'GET /api/one',
      'POST /api/one',
      'GET /api/two',
      'POST /api/two',
      '* /api/empty',
      'PUT /api/spaced',
      'GET /api/commented',
      'POST /api/commented',
      'DELETE /api/fqn',
    ]);

    expect(groupProviders(src)).toEqual(expected);
    expect(ingestionProviders(src)).toEqual(expected);
  });

  it('intersects class verbs and emits pathless method mappings at the class prefix', () => {
    const src = `package com.example;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping(path = "/api", method = RequestMethod.GET)
public class ConstrainedController {
  @RequestMapping("/wild") public Object wildcard() { return null; }
  @RequestMapping(method = RequestMethod.GET) public Object root() { return null; }
  @GetMapping public Object shortcut() { return null; }
  @PostMapping("/blocked") public Object blocked() { return null; }
}
`;

    const expected = new Set(['GET /api/wild', 'GET /api']);
    expect(groupProviders(src)).toEqual(expected);
    expect(ingestionProviders(src)).toEqual(expected);
  });

  it('uses GET for verb-less Feign RequestMapping and rejects multi-method contracts', () => {
    const src = `package com.example;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;

@FeignClient(name = "orders")
@RequestMapping("/api")
public interface OrdersClient {
  @RequestMapping("/default") Object defaultCall();
  @RequestMapping(path = "/multi", method = {RequestMethod.GET, RequestMethod.POST})
  Object ambiguous();
}
`;

    expect(groupConsumers(src)).toEqual(new Set(['GET /api/default']));
  });
  it('fails closed when @RequestMapping method is not statically resolvable', () => {
    const src = `package com.example;
import org.springframework.web.bind.annotation.*;

@RestController
public class DynamicController {
  private static final RequestMethod VERB = RequestMethod.POST;
  @RequestMapping(path = "/dynamic", method = VERB)
  public Object dynamic() { return null; }
}
`;

    expect(groupProviders(src)).toEqual(new Set());
    expect(ingestionProviders(src)).toEqual(new Set());
  });

  it('do not leak non-route arrays (consumes/produces) as routes — array analogue', () => {
    // The scalar `produces` anti-regression already exists in the route tests;
    // this is its array form. `consumes`/`produces` arrays must never surface as
    // provider routes; only the path value does.
    const src = `package com.example;
import org.springframework.web.bind.annotation.*;

@RestController
public class ContentTypeController {
  @GetMapping(value = "/v", consumes = {"application/json", "application/xml"}, produces = {"application/json"})
  public Object v() { return null; }
  @PostMapping(consumes = {"application/json"})
  public Object noPath() { return null; }
}
`;
    const ingestion = ingestionProviders(src);
    const group = groupProviders(src);
    // Non-route arrays never become paths; the pathless POST binds to root.
    expect(group).toEqual(new Set(['GET /v', 'POST /']));
    expect(ingestion).toEqual(group);

    // Pure consumes/produces members still describe pathless root mappings;
    // their media-type values must never be misread as route paths.
    const consumesOnly = `package com.example;
import org.springframework.web.bind.annotation.*;

@RestController
public class ConsumesOnlyController {
  @PostMapping(consumes = {"application/json", "application/xml"})
  public Object a() { return null; }
  @PutMapping(produces = {"application/json"})
  public Object b() { return null; }
}
`;
    expect(groupProviders(consumesOnly)).toEqual(new Set(['POST /', 'PUT /']));
    expect(ingestionProviders(consumesOnly)).toEqual(new Set(['POST /', 'PUT /']));
  });

  it('pins the deliberate class-array divergence: ingestion suppresses, group emits cross-product (#2280)', () => {
    // A method-level array under a class-level ARRAY-form @RequestMapping. There
    // is no single class prefix to apply, so ingestion suppresses the route
    // rather than emit it unprefixed (a wrong signal). The group layer emits the
    // full cross-product. This is a KNOWN gap (#2280), pinned here so the
    // suppression can't silently regress into emitting wrong unprefixed routes.
    const src = `package com.example;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping({"/base/one", "/base/two"})
public class MultiPrefixController {
  @GetMapping({"/primary", "/alias"}) public Object x() { return null; }
}
`;
    const ingestion = ingestionProviders(src);
    const group = groupProviders(src);

    // group: correct cross-product of the two class prefixes × two method paths.
    expect(group).toEqual(
      new Set([
        'GET /base/one/primary',
        'GET /base/two/primary',
        'GET /base/one/alias',
        'GET /base/two/alias',
      ]),
    );
    // ingestion: suppressed — emits NO route for the array method (never a wrong
    // unprefixed `GET /primary` / `GET /alias`).
    expect(ingestion).toEqual(new Set());
    // And the divergence is asymmetric-by-design: ingestion ⊊ group here.
    expect(ingestion).not.toEqual(group);
  });
});
