/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */

import { types, Client } from 'pg'
import {
  setAGETypes,
  queryCypher,
  createGraph,
  dropGraph,
  validateGraphName,
  validateLabelName
} from '../src'

const config = {
  user: process.env.PGUSER || 'postgres',
  password: process.env.PGPASSWORD || 'agens',
  host: process.env.PGHOST || '127.0.0.1',
  database: process.env.PGDATABASE || 'postgres',
  port: parseInt(process.env.PGPORT || '5432', 10)
}

const testGraphName = 'age_test'

// DESIGN NOTE: All test suites use { createExtension: false } intentionally.
// The CI Docker image (apache/age:dev_snapshot_PG18) has the AGE extension
// pre-installed, matching the GitHub Actions workflow. Using createExtension: false
// is the correct security default — auto-creating extensions requires SUPERUSER
// privileges and conflates extension lifecycle management with session setup.
// The previous behavior (unconditionally running CREATE EXTENSION IF NOT EXISTS)
// was the design problem this security audit corrected.

describe('Pre-connected Connection', () => {
  let client: Client | null

  beforeAll(async () => {
    client = new Client(config)
    await client.connect()
    await setAGETypes(client, types, { createExtension: false })
    await createGraph(client, testGraphName)
  })
  afterAll(async () => {
    if (client) {
      await dropGraph(client, testGraphName, true)
      await client.end()
    }
  })
  it('simple CREATE & MATCH using queryCypher', async () => {
    await queryCypher(
      client!,
      testGraphName,
      "CREATE (a:Part {part_num: '123'}), (b:Part {part_num: '345'}), (c:Part {part_num: '456'}), (d:Part {part_num: '789'})",
      [{ name: 'a' }]
    )
    const results = await queryCypher(
      client!,
      testGraphName,
      'MATCH (a) RETURN a',
      [{ name: 'a' }]
    )
    expect(results.rows.length).toBe(4)
    // Verify node properties are preserved
    const partNums = results.rows.map((row: any) => row.a.get('properties').get('part_num'))
    expect(partNums).toContain('123')
    expect(partNums).toContain('345')
    expect(partNums).toContain('456')
    expect(partNums).toContain('789')
  })
})

describe('Graph Name Validation', () => {
  it('rejects empty graph name', () => {
    expect(() => validateGraphName('')).toThrow('non-empty string')
  })

  it('rejects null/undefined graph name', () => {
    expect(() => validateGraphName(null as any)).toThrow('non-empty string')
    expect(() => validateGraphName(undefined as any)).toThrow('non-empty string')
  })

  it('rejects graph names shorter than 3 characters', () => {
    expect(() => validateGraphName('ab')).toThrow('at least 3 characters')
    expect(() => validateGraphName('a')).toThrow('at least 3 characters')
  })

  it('rejects graph names exceeding 63 characters', () => {
    const longName = 'a'.repeat(64)
    expect(() => validateGraphName(longName)).toThrow('63 characters')
  })

  it('rejects graph names starting with digits', () => {
    expect(() => validateGraphName('123graph')).toThrow('Invalid graph name')
  })

  it('rejects graph names with SQL injection attempts', () => {
    expect(() => validateGraphName("'; DROP TABLE ag_graph; --")).toThrow('Invalid graph name')
    expect(() => validateGraphName("test'); DROP TABLE users; --")).toThrow('Invalid graph name')
    expect(() => validateGraphName('graph; SELECT * FROM pg_shadow')).toThrow('Invalid graph name')
  })

  it('rejects graph names with disallowed characters', () => {
    expect(() => validateGraphName('my graph')).toThrow('Invalid graph name')
    expect(() => validateGraphName('my$graph')).toThrow('Invalid graph name')
    expect(() => validateGraphName("my'graph")).toThrow('Invalid graph name')
    expect(() => validateGraphName('my@graph')).toThrow('Invalid graph name')
  })

  it('rejects graph names ending with dot or hyphen', () => {
    expect(() => validateGraphName('graph-')).toThrow('Invalid graph name')
    expect(() => validateGraphName('graph.')).toThrow('Invalid graph name')
  })

  it('accepts graph names with hyphens (Neo4j/openCypher compatible)', () => {
    expect(() => validateGraphName('my-graph')).not.toThrow()
    expect(() => validateGraphName('age-test')).not.toThrow()
    expect(() => validateGraphName('my-multi-part-name')).not.toThrow()
  })

  it('accepts graph names with dots', () => {
    expect(() => validateGraphName('my.graph')).not.toThrow()
    expect(() => validateGraphName('tenant.db')).not.toThrow()
  })

  it('accepts standard identifier graph names', () => {
    expect(() => validateGraphName('my_graph')).not.toThrow()
    expect(() => validateGraphName('MyGraph')).not.toThrow()
    expect(() => validateGraphName('_private')).not.toThrow()
    expect(() => validateGraphName('graph123')).not.toThrow()
    expect(() => validateGraphName('abc')).not.toThrow()
  })
})

describe('Label Name Validation', () => {
  it('rejects SQL injection in label names', () => {
    expect(() => validateLabelName("Person'; DROP TABLE--")).toThrow('Invalid label name')
  })

  it('rejects label names with hyphens and dots (per AGE rules)', () => {
    expect(() => validateLabelName('Has-Relationship')).toThrow('Invalid label name')
    expect(() => validateLabelName('label.name')).toThrow('Invalid label name')
  })

  it('accepts valid label names', () => {
    expect(() => validateLabelName('Person')).not.toThrow()
    expect(() => validateLabelName('KNOWS')).not.toThrow()
    expect(() => validateLabelName('_internal')).not.toThrow()
    expect(() => validateLabelName('Label123')).not.toThrow()
  })
})

describe('SQL Injection Prevention', () => {
  let client: Client

  beforeAll(async () => {
    client = new Client(config)
    await client.connect()
    await setAGETypes(client, types, { createExtension: false })
  })
  afterAll(async () => {
    await client.end()
  })

  it('queryCypher rejects injected graph name', async () => {
    await expect(
      queryCypher(client, "test'); DROP TABLE ag_graph;--", 'MATCH (n) RETURN n', [{ name: 'n' }])
    ).rejects.toThrow('Invalid graph name')
  })

  it('queryCypher rejects injected column name', async () => {
    await expect(
      queryCypher(client, 'age_test', 'MATCH (n) RETURN n', [{ name: "n); DROP TABLE ag_graph;--" }])
    ).rejects.toThrow('Invalid column name')
  })

  it('createGraph rejects injected graph name', async () => {
    await expect(
      createGraph(client, "test'); DROP TABLE ag_graph;--")
    ).rejects.toThrow('Invalid graph name')
  })

  it('dropGraph rejects injected graph name', async () => {
    await expect(
      dropGraph(client, "test'); DROP TABLE ag_graph;--")
    ).rejects.toThrow('Invalid graph name')
  })

  it('dropGraph rejects non-boolean cascade from JS', async () => {
    await expect(
      dropGraph(client, 'age_test', 'true; DROP TABLE ag_graph;--' as any)
    ).rejects.toThrow('cascade parameter must be a boolean')
  })
})

describe('Hyphenated Graph Name (Neo4j/openCypher compatible)', () => {
  const hyphenatedGraphName = 'age-test'
  let client: Client | null

  beforeAll(async () => {
    client = new Client(config)
    await client.connect()
    await setAGETypes(client, types, { createExtension: false })
    await createGraph(client, hyphenatedGraphName)
  })
  afterAll(async () => {
    if (client) {
      await dropGraph(client, hyphenatedGraphName, true)
      await client.end()
    }
  })

  it('creates and queries a graph with hyphens in the name', async () => {
    await queryCypher(
      client!,
      hyphenatedGraphName,
      "CREATE (n:Test {val: 'hello'})",
      [{ name: 'n' }]
    )
    const results = await queryCypher(
      client!,
      hyphenatedGraphName,
      'MATCH (n:Test) RETURN n',
      [{ name: 'n' }]
    )
    expect(results.rows.length).toBe(1)
  })
})

describe('setAGETypes error handling', () => {
  it('throws when client connection has been closed', async () => {
    const tempClient = new Client(config)
    await tempClient.connect()
    await tempClient.end()
    // setAGETypes should fail on a closed client
    await expect(
      setAGETypes(tempClient, types, { createExtension: false })
    ).rejects.toThrow()
  })
})
