/**
 * Tests for I-004 (query default deny) and I-009 (SPARQL graph scope bypass).
 *
 * Verifies that:
 * - Default query access is deny (not public)
 * - Explicit GRAPH clauses in remote SPARQL are rejected
 * - FROM/FROM NAMED clauses in remote SPARQL are rejected
 * - Standard context-graph-scoped queries still work correctly
 */
import { readFileSync } from 'node:fs';
import { describe, it, expect, beforeEach } from 'vitest';
import { OxigraphStore, type Quad } from '@origintrail-official/dkg-storage';
import { DKGQueryEngine } from '../src/dkg-query-engine.js';
import { QueryHandler } from '../src/query-handler.js';
import type { QueryRequest, QueryAccessConfig } from '../src/query-types.js';

const CONTEXT_GRAPH = 'test-security';
const OTHER_CONTEXT_GRAPH = 'other-secret';
const GRAPH = `did:dkg:context-graph:${CONTEXT_GRAPH}`;
const OTHER_GRAPH = `did:dkg:context-graph:${OTHER_CONTEXT_GRAPH}`;
const ENTITY_A = 'did:dkg:entity:alice';
const ENTITY_SECRET = 'did:dkg:entity:secret-data';
const SCHEMA_NAME = 'https://schema.org/name';
const SCHEMA_PERSON = 'https://schema.org/Person';
const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type';

function q(s: string, p: string, o: string, g: string): Quad {
  return { subject: s, predicate: p, object: o, graph: g };
}

function makeRequest(overrides: Partial<QueryRequest> = {}): QueryRequest {
  return {
    operationId: 'security-test',
    lookupType: 'SPARQL_QUERY',
    contextGraphId: CONTEXT_GRAPH,
    ...overrides,
  };
}

function makeNoExecuteBoundary(): { handler: QueryHandler; wasExecuted: () => boolean } {
  let executed = false;
  const noExecuteEngine = {
    query: async () => {
      executed = true;
      return { bindings: [] };
    },
    resolveKA: async () => {
      throw new Error('not used');
    },
  } as unknown as DKGQueryEngine;

  return {
    handler: new QueryHandler(noExecuteEngine, {
      defaultPolicy: 'deny',
      contextGraphs: {
        [CONTEXT_GRAPH]: { policy: 'public', sparqlEnabled: true },
      },
    }),
    wasExecuted: () => executed,
  };
}

describe('I-004: Default query access should be deny', () => {
  let store: OxigraphStore;
  let engine: DKGQueryEngine;

  beforeEach(async () => {
    store = new OxigraphStore();
    engine = new DKGQueryEngine(store);
    await store.insert([
      q(ENTITY_A, RDF_TYPE, SCHEMA_PERSON, GRAPH),
      q(ENTITY_A, SCHEMA_NAME, '"Alice"', GRAPH),
    ]);
  });

  it('deny-by-default blocks queries to unconfigured contextGraphs', async () => {
    const handler = new QueryHandler(engine, {
      defaultPolicy: 'deny',
    });

    const response = await handler.handle(
      makeRequest({
        lookupType: 'ENTITY_TRIPLES',
        entityUri: ENTITY_A,
      }),
      'peer-1',
    );

    expect(response.status).toBe('ACCESS_DENIED');
  });

  it('deny-by-default with explicit context graph allows queries', async () => {
    const handler = new QueryHandler(engine, {
      defaultPolicy: 'deny',
      contextGraphs: {
        [CONTEXT_GRAPH]: { policy: 'public' },
      },
    });

    const response = await handler.handle(
      makeRequest({
        lookupType: 'ENTITY_TRIPLES',
        entityUri: ENTITY_A,
      }),
      'peer-1',
    );

    expect(response.status).toBe('OK');
    expect(response.resultCount).toBe(2);
  });

  it('public policy allows queries without explicit context graph config', async () => {
    const handler = new QueryHandler(engine, {
      defaultPolicy: 'public',
    });

    const response = await handler.handle(
      makeRequest({
        lookupType: 'ENTITY_TRIPLES',
        entityUri: ENTITY_A,
      }),
      'peer-1',
    );

    expect(response.status).toBe('OK');
    expect(response.resultCount).toBe(2);
  });
});

describe('I-009: SPARQL graph scope bypass prevention', () => {
  let store: OxigraphStore;
  let engine: DKGQueryEngine;
  let handler: QueryHandler;

  beforeEach(async () => {
    store = new OxigraphStore();
    engine = new DKGQueryEngine(store);

    // Insert data into two different contextGraphs
    await store.insert([
      q(ENTITY_A, SCHEMA_NAME, '"Alice"', GRAPH),
      q(ENTITY_SECRET, SCHEMA_NAME, '"TopSecret"', OTHER_GRAPH),
    ]);

    handler = new QueryHandler(engine, {
      defaultPolicy: 'deny',
      contextGraphs: {
        [CONTEXT_GRAPH]: { policy: 'public', sparqlEnabled: true },
      },
    });
  });

  it('rejects SPARQL with explicit GRAPH clause', async () => {
    const response = await handler.handle(
      makeRequest({
        sparql: `SELECT ?s ?name WHERE { GRAPH <${OTHER_GRAPH}> { ?s <${SCHEMA_NAME}> ?name } }`,
      }),
      'peer-attacker',
    );

    expect(response.status).toBe('ERROR');
    expect(response.error).toContain('GRAPH clauses are not allowed');
  });

  it('rejects SPARQL with GRAPH variable pattern (bypass via ?var)', async () => {
    const response = await handler.handle(
      makeRequest({
        sparql: `SELECT ?s ?p ?o WHERE { GRAPH ?g { ?s ?p ?o } }`,
      }),
      'peer-attacker',
    );

    expect(response.status).toBe('ERROR');
    expect(response.error).toContain('GRAPH clauses are not allowed');
  });

  it('rejects token-adjacent GRAPH variables before executing remote SPARQL', async () => {
    const boundary = makeNoExecuteBoundary();

    const response = await boundary.handler.handle(
      makeRequest({
        sparql: `SELECT ?s WHERE { GRAPH?g { ?s <${SCHEMA_NAME}> ?name } }`,
      }),
      'peer-attacker',
    );

    expect(response.status).toBe('ERROR');
    expect(response.error).toContain('GRAPH clauses are not allowed');
    expect(boundary.wasExecuted()).toBe(false);
  });

  it('rejects SPARQL with GRAPH clause targeting the allowed context graph too', async () => {
    // Even queries targeting the "correct" graph should not use explicit GRAPH
    const response = await handler.handle(
      makeRequest({
        sparql: `SELECT ?name WHERE { GRAPH <${GRAPH}> { ?s <${SCHEMA_NAME}> ?name } }`,
      }),
      'peer-1',
    );

    expect(response.status).toBe('ERROR');
    expect(response.error).toContain('GRAPH clauses are not allowed');
  });

  it('rejects SPARQL with FROM clause', async () => {
    const response = await handler.handle(
      makeRequest({
        sparql: `SELECT ?name FROM <${OTHER_GRAPH}> WHERE { ?s <${SCHEMA_NAME}> ?name }`,
      }),
      'peer-attacker',
    );

    expect(response.status).toBe('ERROR');
    expect(response.error).toContain('FROM');
  });

  it('rejects token-adjacent FROM IRIs before executing remote SPARQL', async () => {
    const boundary = makeNoExecuteBoundary();

    const response = await boundary.handler.handle(
      makeRequest({
        sparql: `SELECT ?name FROM<${OTHER_GRAPH}> WHERE { ?s <${SCHEMA_NAME}> ?name }`,
      }),
      'peer-attacker',
    );

    expect(response.status).toBe('ERROR');
    expect(response.error).toContain('FROM');
    expect(boundary.wasExecuted()).toBe(false);
  });

  it('rejects SPARQL with FROM NAMED clause', async () => {
    const response = await handler.handle(
      makeRequest({
        sparql: `SELECT ?name FROM NAMED <${OTHER_GRAPH}> WHERE { GRAPH ?g { ?s <${SCHEMA_NAME}> ?name } }`,
      }),
      'peer-attacker',
    );

    expect(response.status).toBe('ERROR');
  });

  it('allows normal context-graph-scoped SPARQL without GRAPH clause', async () => {
    const response = await handler.handle(
      makeRequest({
        sparql: `SELECT ?name WHERE { ?s <${SCHEMA_NAME}> ?name }`,
      }),
      'peer-1',
    );

    expect(response.status).toBe('OK');
    const bindings = JSON.parse(response.bindings!);
    // Should only see data from the allowed context graph, not the secret one
    const names = bindings.map((b: Record<string, string>) => b['name']);
    expect(names.some((n: string) => n.includes('Alice'))).toBe(true);
    expect(names.some((n: string) => n.includes('TopSecret'))).toBe(false);
  });

  it('prevents cross-context-graph data access via case-variant GRAPH', async () => {
    const response = await handler.handle(
      makeRequest({
        sparql: `SELECT ?name WHERE { graph <${OTHER_GRAPH}> { ?s <${SCHEMA_NAME}> ?name } }`,
      }),
      'peer-attacker',
    );

    expect(response.status).toBe('ERROR');
    expect(response.error).toContain('GRAPH clauses are not allowed');
  });

  it('rejects SPARQL with prefixed IRI in GRAPH clause', async () => {
    const response = await handler.handle(
      makeRequest({
        sparql: `PREFIX ex: <${OTHER_GRAPH}/> SELECT ?name WHERE { GRAPH ex:data { ?s <${SCHEMA_NAME}> ?name } }`,
      }),
      'peer-attacker',
    );

    expect(response.status).toBe('ERROR');
    expect(response.error).toContain('GRAPH clauses are not allowed');
  });

  it('rejects SPARQL with prefixed IRI in FROM clause', async () => {
    const response = await handler.handle(
      makeRequest({
        sparql: `PREFIX ex: <${OTHER_GRAPH}/> SELECT ?name FROM ex:data WHERE { ?s <${SCHEMA_NAME}> ?name }`,
      }),
      'peer-attacker',
    );

    expect(response.status).toBe('ERROR');
    expect(response.error).toContain('FROM');
  });

  it('rejects SPARQL with prefixed IRI in FROM NAMED clause', async () => {
    const response = await handler.handle(
      makeRequest({
        sparql: `PREFIX ex: <${OTHER_GRAPH}/> SELECT ?name FROM NAMED ex:data WHERE { ?s <${SCHEMA_NAME}> ?name }`,
      }),
      'peer-attacker',
    );

    expect(response.status).toBe('ERROR');
    expect(response.error).toContain('FROM');
  });
});

describe('I-009: SPARQL keyword detection — no false positives on literals/comments', () => {
  let store: OxigraphStore;
  let engine: DKGQueryEngine;
  let handler: QueryHandler;

  beforeEach(async () => {
    store = new OxigraphStore();
    engine = new DKGQueryEngine(store);

    await store.insert([
      q(ENTITY_A, SCHEMA_NAME, '"Alice"', GRAPH),
    ]);

    handler = new QueryHandler(engine, {
      defaultPolicy: 'deny',
      contextGraphs: {
        [CONTEXT_GRAPH]: { policy: 'public', sparqlEnabled: true },
      },
    });
  });

  it('allows GRAPH keyword inside a string literal (not a real GRAPH clause)', async () => {
    const response = await handler.handle(
      makeRequest({
        sparql: `SELECT ?s WHERE { ?s <${SCHEMA_NAME}> "This describes a GRAPH structure" }`,
      }),
      'peer-1',
    );
    expect(response.status).toBe('OK');
  });

  it('allows FROM keyword inside a string literal (not a real FROM clause)', async () => {
    const response = await handler.handle(
      makeRequest({
        sparql: `SELECT ?s WHERE { ?s <${SCHEMA_NAME}> "data FROM multiple sources" }`,
      }),
      'peer-1',
    );
    expect(response.status).toBe('OK');
  });

  it('allows SERVICE keyword inside a string literal', async () => {
    const response = await handler.handle(
      makeRequest({
        sparql: `SELECT ?s WHERE { ?s <${SCHEMA_NAME}> "runs a SERVICE endpoint" }`,
      }),
      'peer-1',
    );
    expect(response.status).toBe('OK');
  });

  it('allows GRAPH keyword inside a SPARQL comment', async () => {
    const response = await handler.handle(
      makeRequest({
        sparql: `SELECT ?s WHERE {
# This comment mentions GRAPH and FROM clauses
  ?s <${SCHEMA_NAME}> ?name
}`,
      }),
      'peer-1',
    );
    expect(response.status).toBe('OK');
  });

  it('still rejects real GRAPH clause even when literals are present', async () => {
    const response = await handler.handle(
      makeRequest({
        sparql: `SELECT ?s WHERE { ?s <${SCHEMA_NAME}> "some text" . GRAPH <${OTHER_GRAPH}> { ?s ?p ?o } }`,
      }),
      'peer-attacker',
    );
    expect(response.status).toBe('ERROR');
    expect(response.error).toContain('GRAPH');
  });

  it('still rejects real FROM clause even when string literals are present', async () => {
    const response = await handler.handle(
      makeRequest({
        sparql: `SELECT ?s FROM <${OTHER_GRAPH}> WHERE { ?s <${SCHEMA_NAME}> "mentions FROM in text" }`,
      }),
      'peer-attacker',
    );
    expect(response.status).toBe('ERROR');
    expect(response.error).toContain('FROM');
  });

  it('rejects GRAPH clause after IRI containing # fragment (# inside <> is not a comment)', async () => {
    const response = await handler.handle(
      makeRequest({
        sparql: `SELECT ?s WHERE { ?s <http://example.org/vocab#name> ?name . GRAPH <${OTHER_GRAPH}> { ?s ?p ?o } }`,
      }),
      'peer-attacker',
    );
    expect(response.status).toBe('ERROR');
    expect(response.error).toContain('GRAPH');
  });

  it('rejects FROM clause after IRI containing # fragment', async () => {
    const response = await handler.handle(
      makeRequest({
        sparql: `SELECT ?s FROM <${OTHER_GRAPH}> WHERE { ?s <http://example.org/vocab#type> ?t }`,
      }),
      'peer-attacker',
    );
    expect(response.status).toBe('ERROR');
    expect(response.error).toContain('FROM');
  });

  it('allows query using IRIs with # fragments when no GRAPH/FROM clause present', async () => {
    const response = await handler.handle(
      makeRequest({
        sparql: `SELECT ?s WHERE { ?s <http://example.org/vocab#name> ?name }`,
      }),
      'peer-1',
    );
    expect(response.status).toBe('OK');
  });

  it('rejects GRAPH after comparison operator (< is not confused with IRI)', async () => {
    const response = await handler.handle(
      makeRequest({
        sparql: `SELECT ?s WHERE { ?s <${SCHEMA_NAME}> ?v FILTER(?v < 1) GRAPH <${OTHER_GRAPH}> { ?s ?p ?o } }`,
      }),
      'peer-attacker',
    );
    expect(response.status).toBe('ERROR');
    expect(response.error).toContain('GRAPH');
  });

  it('rejects FROM after comparison operator', async () => {
    const response = await handler.handle(
      makeRequest({
        sparql: `SELECT ?s FROM <${OTHER_GRAPH}> WHERE { ?s <${SCHEMA_NAME}> ?v FILTER(?v < 100) }`,
      }),
      'peer-attacker',
    );
    expect(response.status).toBe('ERROR');
    expect(response.error).toContain('FROM');
  });

  it('rejects GRAPH after variable comparison ?v<abc:def (< after variable is not IRI)', async () => {
    const response = await handler.handle(
      makeRequest({
        sparql: `SELECT ?s WHERE { ?s <${SCHEMA_NAME}> ?v FILTER(?v<abc:def) GRAPH <${OTHER_GRAPH}> { ?s ?p ?o } }`,
      }),
      'peer-attacker',
    );
    expect(response.status).toBe('ERROR');
    expect(response.error).toContain('GRAPH');
  });

  it('rejects FROM after variable comparison ?v<abc:def', async () => {
    const response = await handler.handle(
      makeRequest({
        sparql: `SELECT ?s FROM <${OTHER_GRAPH}> WHERE { ?s <${SCHEMA_NAME}> ?v FILTER(?v<abc:def) }`,
      }),
      'peer-attacker',
    );
    expect(response.status).toBe('ERROR');
    expect(response.error).toContain('FROM');
  });

  it('rejects GRAPH after spaceless comparison ?v<1 (not confused with IRI)', async () => {
    const response = await handler.handle(
      makeRequest({
        sparql: `SELECT ?s WHERE { ?s <${SCHEMA_NAME}> ?v FILTER(?v<1) GRAPH <${OTHER_GRAPH}> { ?s ?p ?o } }`,
      }),
      'peer-attacker',
    );
    expect(response.status).toBe('ERROR');
    expect(response.error).toContain('GRAPH');
  });

  it('rejects FROM after spaceless comparison ?v<1', async () => {
    const response = await handler.handle(
      makeRequest({
        sparql: `SELECT ?s FROM <${OTHER_GRAPH}> WHERE { ?s <${SCHEMA_NAME}> ?v FILTER(?v<1) }`,
      }),
      'peer-attacker',
    );
    expect(response.status).toBe('ERROR');
    expect(response.error).toContain('FROM');
  });

  it('rejects GRAPH after short IRI like <#frag> (# inside IRI not treated as comment)', async () => {
    const response = await handler.handle(
      makeRequest({
        sparql: `SELECT ?s WHERE { ?s <#type> ?t . GRAPH <${OTHER_GRAPH}> { ?s ?p ?o } }`,
      }),
      'peer-attacker',
    );
    expect(response.status).toBe('ERROR');
    expect(response.error).toContain('GRAPH');
  });

  it('recognizes IRI after = operator: FILTER(?x=<http://...#b>) does not mask GRAPH', async () => {
    const response = await handler.handle(
      makeRequest({
        sparql: `SELECT ?s WHERE { ?s <${SCHEMA_NAME}> ?x FILTER(?x=<http://example.org/a#b>) GRAPH <${OTHER_GRAPH}> { ?s ?p ?o } }`,
      }),
      'peer-attacker',
    );
    expect(response.status).toBe('ERROR');
    expect(response.error).toContain('GRAPH');
  });

  it('recognizes IRI after != operator: FILTER(?x!=<http://...#b>) does not mask FROM', async () => {
    const response = await handler.handle(
      makeRequest({
        sparql: `SELECT ?s FROM <${OTHER_GRAPH}> WHERE { ?s <${SCHEMA_NAME}> ?x FILTER(?x!=<http://example.org/a#b>) }`,
      }),
      'peer-attacker',
    );
    expect(response.status).toBe('ERROR');
    expect(response.error).toContain('FROM');
  });
});

describe('caller documentation for named graph scope', () => {
  it('documents that local GRAPH variables stay constrained by contextGraphId and view', () => {
    const readme = readFileSync(new URL('../README.md', import.meta.url), 'utf8');

    expect(readme).toContain('contextGraphId and view are authoritative');
    expect(readme).toContain('GRAPH ?g');
    expect(readme).toMatch(/constrained locally/i);
  });
});
