// Licensed to the Software Freedom Conservancy (SFC) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The SFC 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.

// Unit tests for the schema projector + validators.
// Mocha test; `describe`/`it` are mocha globals (run via the Bazel mocha target).
// The completeness test is the "compare input to output independent of
// generation" gate — it re-derives expected methods from the raw AST, not the model.
import assert from 'node:assert/strict'
import { projectSchema, checkSchema, checkCompleteness } from './project_bidi_schema.mjs'

const lit = (v) => ({ Type: 'literal', Value: v, Unwrapped: false })
const ref = (v) => ({ Type: 'group', Value: v, Unwrapped: false })
const field = (name, type, occ = { n: 1, m: 1 }) => ({ Name: name, Occurrence: occ, Type: type, Comments: [] })
const group = (name, props) => ({ Type: 'group', Name: name, Properties: props, IsChoiceAddition: false, Comments: [] })
const leaf = (cddlName, method, paramsRef) =>
  group(cddlName, [field('method', [lit(method)]), field('params', [ref(paramsRef)])])

// A tiny but representative AST + model.
const AST = [
  leaf('network.SetCacheBehavior', 'network.setCacheBehavior', 'network.SetCacheBehaviorParameters'),
  group('network.SetCacheBehaviorParameters', [field('cacheBehavior', [lit('default'), lit('bypass')])]),
  group('session.Caps', [field('extra', { Type: 'group', Name: '', Properties: [field('webSocketUrl', ['bool'])] })]),
  group('x.OpenMap', [field('text', ['any'], { n: 0, m: null })]),
]
const MODEL = {
  network: {
    commands: [
      {
        method: 'network.setCacheBehavior',
        name: 'setCacheBehavior',
        params: 'network.SetCacheBehaviorParameters',
        result: null,
      },
    ],
    events: [],
  },
}

describe('projectSchema', () => {
  const schema = projectSchema(AST, MODEL)

  it('emits a clean enum for an inline string-literal union, tagged with its origin', () => {
    assert.deepEqual(schema.types['network.SetCacheBehaviorParametersCacheBehavior'], {
      kind: 'enum',
      values: ['default', 'bypass'],
      synthetic: true,
      owner: 'network.SetCacheBehaviorParameters',
      label: 'CacheBehavior',
    })
    assert.deepEqual(schema.types['network.SetCacheBehaviorParameters'].fields[0].type, {
      ref: 'network.SetCacheBehaviorParametersCacheBehavior',
    })
  })

  it('hoists an inline record so the field is a plain ref (no inline records), tagged with its origin', () => {
    assert.deepEqual(schema.types['session.Caps'].fields[0].type, { ref: 'session.CapsExtra' })
    const extra = schema.types['session.CapsExtra']
    assert.ok(extra, 'inline record was hoisted to a named type')
    assert.equal(extra.synthetic, true)
    assert.equal(extra.owner, 'session.Caps')
    assert.equal(extra.label, 'Extra')
  })

  it('marks `* text => any` extensible instead of emitting a phantom field', () => {
    const open = schema.types['x.OpenMap']
    assert.equal(open.extensible, true)
    assert.equal(open.fields.length, 0)
  })

  it('passes both validators on a well-formed schema', () => {
    assert.deepEqual(checkSchema(schema), [])
    assert.deepEqual(checkCompleteness(AST, schema), [])
  })

  it('recovers command params from the envelope when the model dropped an inline params object', () => {
    // The command declares an inline `params: { count }` (not a named ref); the model
    // builder records params: null, but the normalizer hoists it to x.FooCommandParams
    // and the envelope record points at it — so the command entry must surface it.
    const ast = [
      group('x.FooCommand', [
        field('method', [lit('x.foo')]),
        field('params', { Type: 'group', Name: '', Properties: [field('count', ['uint'])] }),
      ]),
    ]
    const model = { x: { commands: [{ method: 'x.foo', name: 'foo', params: null, result: null }], events: [] } }
    const out = projectSchema(ast, model)
    assert.deepEqual(out.commands[0].params, { ref: 'x.FooCommandParams' })
    assert.deepEqual(checkSchema(out), [])
  })
})

describe('projectType (list / union / alias defs)', () => {
  const anon = (v) => ({ Name: '', Occurrence: { n: 1, m: 1 }, Type: ref(v), Comments: [] })
  const ast = [
    {
      Type: 'array',
      Name: 'x.Items',
      Values: [{ Name: '', Occurrence: { n: 0, m: null }, Type: [ref('x.Item')], Comments: [] }],
    },
    group('x.Item', [field('a', ['text'])]),
    group('x.Other', [field('b', ['text'])]),
    {
      Type: 'group',
      Name: 'x.Choice',
      Properties: [[anon('x.Item'), anon('x.Other')]],
      IsChoiceAddition: false,
      Comments: [],
    },
    { Type: 'group', Name: 'x.FooEvent', Properties: [anon('x.Item')], IsChoiceAddition: false, Comments: [] },
  ]
  const schema = projectSchema(ast, {})

  it('projects a top-level array def as an alias to a list (keeps the element type)', () => {
    assert.deepEqual(schema.types['x.Items'], { kind: 'alias', type: { list: { ref: 'x.Item' } } })
  })
  it('fails closed (unknown, not null) when an array element type is missing', () => {
    const s = projectSchema([{ Type: 'array', Name: 'x.Bad', Values: [] }], {})
    assert.deepEqual(s.types['x.Bad'], { kind: 'alias', type: { list: { primitive: 'unknown' } } })
    assert.ok(
      checkSchema(s).some((e) => /unknown primitive/.test(e)),
      'a missing element type must trip the unknown-primitive guard',
    )
  })
  it('projects a multi-member choice group as a union with a structural selector', () => {
    assert.deepEqual(schema.types['x.Choice'], {
      kind: 'union',
      variants: ['x.Item', 'x.Other'],
      selector: {
        ordered: [
          { ref: 'x.Item', requires: ['a'] },
          { ref: 'x.Other', requires: ['b'] },
        ],
      },
    })
  })
  it('projects a single-member dispatch choice group as an alias to its ref', () => {
    assert.deepEqual(schema.types['x.FooEvent'], { kind: 'alias', type: { ref: 'x.Item' } })
  })

  it('projects an integer range as integer and a float range as number', () => {
    const s = projectSchema(
      [
        {
          Type: 'variable',
          Name: 'x.U',
          PropertyType: [{ Type: 'range', Value: { Min: { Value: 0 }, Max: { Value: 100 } } }],
        },
        {
          Type: 'variable',
          Name: 'x.F',
          PropertyType: [{ Type: 'range', Value: { Min: { Value: 0.1 }, Max: { Value: 2 } } }],
        },
      ],
      {},
    )
    assert.deepEqual(s.types['x.U'], { kind: 'alias', type: { primitive: 'integer' } })
    assert.deepEqual(s.types['x.F'], { kind: 'alias', type: { primitive: 'number' } })
  })

  it('unwraps a control-operator (.default / .ge) wrapped field type to its inner type', () => {
    const wrapped = {
      Name: 'n',
      Occurrence: { n: 1, m: 1 },
      Type: [{ Type: ref('x.Inner'), Operator: { Type: 'default', Value: lit('a') } }],
      Comments: [],
    }
    const s = projectSchema([group('x.R', [wrapped]), group('x.Inner', [field('z', ['text'])])], {})
    assert.deepEqual(s.types['x.R'].fields[0].type, { ref: 'x.Inner' })
  })

  it('promotes a union whose arms are inline groups wrapping refs (LocalValue date/regexp arms)', () => {
    const inlineArm = {
      Type: 'group',
      Name: '',
      Properties: [{ Name: '', Occurrence: { n: 1, m: 1 }, Type: [ref('x.B')], Comments: [] }],
    }
    const s = projectSchema(
      [
        { Type: 'variable', Name: 'x.U', PropertyType: [ref('x.A'), inlineArm] },
        group('x.A', [field('a', ['text'])]),
        group('x.B', [field('b', ['text'])]),
      ],
      {},
    )
    // The inline-group arm resolves to x.B, so x.U is a first-class union (not an
    // alias-to-union) and both arms are reachable variants.
    assert.deepEqual(s.types['x.U'].kind, 'union')
    assert.deepEqual(s.types['x.U'].variants, ['x.A', 'x.B'])
  })
})

describe('unionSelector', () => {
  const recAst = (name, typeConst, extra = []) => ({
    Type: 'group',
    Name: name,
    IsChoiceAddition: false,
    Comments: [],
    Properties: [{ Name: 'type', Occurrence: { n: 1, m: 1 }, Type: [lit(typeConst)], Comments: [] }, ...extra],
  })
  const union = (name, refs) => ({
    Type: 'variable',
    Name: name,
    IsChoiceAddition: false,
    Comments: [],
    PropertyType: refs.map(ref),
  })

  it('discriminates on a constant key, flattening a tagged sub-union and recording an untyped default', () => {
    // x.Value = ( x.Prim / x.Date / x.Reference ); x.Prim is itself type-tagged,
    // x.Reference has no `type` (dispatched structurally) → it is the default.
    const ast = [
      union('x.Value', ['x.Prim', 'x.Date', 'x.Reference']),
      union('x.Prim', ['x.StringValue', 'x.NullValue']),
      recAst('x.StringValue', 'string'),
      recAst('x.NullValue', 'null'),
      recAst('x.Date', 'date'),
      group('x.Reference', [field('refId', ['text'])]),
    ]
    const s = projectSchema(ast, {})
    const sel = s.types['x.Value'].selector
    assert.equal(sel.by, 'type')
    assert.equal(sel.default, 'x.Reference')
    // NullValue's `type` is the string literal "null" (its discriminator tag), so it
    // dispatches on the string "null" — not the JSON null type.
    assert.deepEqual(s.types['x.NullValue'].fields[0].type, { const: 'null' })
    assert.deepEqual(
      new Map(sel.variants.map((v) => [JSON.stringify(v.value), v.ref])),
      new Map([
        ['"string"', 'x.StringValue'],
        ['"null"', 'x.NullValue'],
        ['"date"', 'x.Date'],
      ]),
    )
  })

  it('uses an open base-type arm as the discriminator default (log.Entry shape)', () => {
    const ast = [
      union('x.Entry', ['x.Generic', 'x.Console']),
      group('x.Generic', [field('type', ['text'])]), // open `type` → catch-all
      recAst('x.Console', 'console'),
    ]
    const sel = projectSchema(ast, {}).types['x.Entry'].selector
    assert.equal(sel.by, 'type')
    assert.equal(sel.default, 'x.Generic')
    assert.deepEqual(sel.variants, [{ value: 'console', ref: 'x.Console' }])
  })

  it('falls back to an ordered structural selector when no shared discriminator exists', () => {
    const ast = [
      union('x.Ref', ['x.Shared', 'x.Remote']),
      group('x.Shared', [
        field('sharedId', ['text']),
        { Name: 'handle', Occurrence: { n: 0, m: 1 }, Type: ['text'], Comments: [] },
      ]),
      group('x.Remote', [field('handle', ['text'])]),
    ]
    const sel = projectSchema(ast, {}).types['x.Ref'].selector
    assert.deepEqual(sel, {
      ordered: [
        { ref: 'x.Shared', requires: ['sharedId'] },
        { ref: 'x.Remote', requires: ['handle'] },
      ],
    })
  })

  // The response envelope is a record pairing a `result` union with the request `id`.
  const envelope = (resultRef) => group('x.CommandResponse', [field('id', ['uint']), field('result', [ref(resultRef)])])

  it('marks an undispatchable result-grouping union (reached via `result`) as correlated', () => {
    // CommandResponse.result -> x.ResultData, a union of result records that cannot
    // be told apart from the payload (no required fields): it is dispatched by
    // request id, so it carries no selector.
    const ast = [
      envelope('x.ResultData'),
      union('x.ResultData', ['x.FooResult', 'x.BarResult']),
      group('x.FooResult', []),
      group('x.BarResult', []),
    ]
    assert.deepEqual(projectSchema(ast, {}).types['x.ResultData'].selector, { correlated: true })
  })

  it('keeps a structurally-dispatchable result reached via `result` (not correlated)', () => {
    // Distinguishable result records (each has its own required field) stay
    // payload-dispatched even though they are reached through the envelope.
    const ast = [
      envelope('x.ResultData'),
      union('x.ResultData', ['x.FooResult', 'x.BarResult']),
      group('x.FooResult', [field('foo', ['text'])]),
      group('x.BarResult', [field('bar', ['text'])]),
    ]
    assert.deepEqual(projectSchema(ast, {}).types['x.ResultData'].selector, {
      ordered: [
        { ref: 'x.FooResult', requires: ['foo'] },
        { ref: 'x.BarResult', requires: ['bar'] },
      ],
    })
  })

  it('does not mark a discriminated result reached via `result` as correlated (e.g. EvaluateResult)', () => {
    const ast = [
      envelope('x.EvalResult'),
      union('x.EvalResult', ['x.Success', 'x.Failure']),
      recAst('x.Success', 'success'),
      recAst('x.Failure', 'exception'),
    ]
    const sel = projectSchema(ast, {}).types['x.EvalResult'].selector
    assert.equal(sel.by, 'type') // payload-dispatched, selector preserved
  })

  it('does not correlate a union reached via a non-envelope `result` field (no request id)', () => {
    // A plain payload type has a `result` field but no request `id`, so it is not the
    // response envelope; its union must keep its payload selector, not be correlated.
    const ast = [
      group('x.EvaluateResultSuccess', [field('result', [ref('x.ResultData')]), field('realm', ['text'])]),
      union('x.ResultData', ['x.FooResult', 'x.BarResult']),
      group('x.FooResult', []),
      group('x.BarResult', []),
    ]
    const sel = projectSchema(ast, {}).types['x.ResultData'].selector
    assert.ok(sel.ordered, 'a non-envelope `result` field must not request-correlate its union')
    assert.equal(sel.correlated, undefined)
  })

  it('does not treat a record with an optional id/result as the response envelope', () => {
    // The real envelope has a REQUIRED request id and result; an optional id means
    // this is not the response envelope, so its union must keep its payload selector.
    const optional = { n: 0, m: 1 }
    const ast = [
      group('x.CommandResponse', [field('id', ['uint'], optional), field('result', [ref('x.ResultData')])]),
      union('x.ResultData', ['x.FooResult', 'x.BarResult']),
      group('x.FooResult', []),
      group('x.BarResult', []),
    ]
    const sel = projectSchema(ast, {}).types['x.ResultData'].selector
    assert.equal(sel.correlated, undefined)
    assert.ok(sel.ordered)
  })

  it('resolves an alias variant to its leaf record when building structural requires', () => {
    // x.Alias is a single-ref alias to x.A; its ordered `requires` must reflect
    // x.A's required fields, not be left empty (which would always match).
    const ast = [
      union('x.U', ['x.Alias', 'x.B']),
      { Type: 'variable', Name: 'x.Alias', IsChoiceAddition: false, Comments: [], PropertyType: [ref('x.A')] },
      group('x.A', [field('a', ['text'])]),
      group('x.B', [field('b', ['text'])]),
    ]
    assert.deepEqual(projectSchema(ast, {}).types['x.U'].selector, {
      ordered: [
        { ref: 'x.Alias', requires: ['a'] },
        { ref: 'x.B', requires: ['b'] },
      ],
    })
  })
})

describe('checkCompleteness (input vs output, generator-independent)', () => {
  it('fails when a command/event present in the AST is missing from the schema', () => {
    const astWithExtra = [
      ...AST,
      leaf('network.DroppedCmd', 'network.droppedCmd', 'network.SetCacheBehaviorParameters'),
    ]
    const schema = projectSchema(AST, MODEL) // model does NOT know about droppedCmd
    const errors = checkCompleteness(astWithExtra, schema)
    assert.deepEqual(errors, ['dropped from schema: network.droppedCmd'])
  })

  it('does not fail for a known-incomplete (allowlisted) drop', () => {
    const astWithKnown = [
      ...AST,
      leaf('bluetooth.X', 'bluetooth.characteristicEventGenerated', 'network.SetCacheBehaviorParameters'),
    ]
    assert.deepEqual(checkCompleteness(astWithKnown, projectSchema(AST, MODEL)), [])
  })

  it('flags an allowlisted method as stale once it is emitted', () => {
    const schema = projectSchema(AST, MODEL)
    schema.events.push({
      domain: 'bluetooth',
      method: 'bluetooth.characteristicEventGenerated',
      name: 'characteristicEventGenerated',
      params: null,
    })
    assert.deepEqual(checkCompleteness(AST, schema), [
      'stale KNOWN_INCOMPLETE entry (now emitted, remove it): bluetooth.characteristicEventGenerated',
    ])
  })
})

describe('checkSchema (referential integrity)', () => {
  it('catches an unresolved ref nested inside a record field', () => {
    const schema = {
      schemaVersion: 1,
      commands: [],
      events: [],
      types: {
        'x.T': { kind: 'record', fields: [{ name: 'a', wire: 'a', required: true, type: { ref: 'x.Missing' } }] },
      },
    }
    assert.deepEqual(checkSchema(schema), ['x.T.a: unresolved type x.Missing'])
  })

  it('catches an unresolved ref inside an alias', () => {
    const schema = {
      schemaVersion: 1,
      commands: [],
      events: [],
      types: { 'x.A': { kind: 'alias', type: { ref: 'x.Missing' } } },
    }
    assert.deepEqual(checkSchema(schema), ['x.A: unresolved type x.Missing'])
  })

  it('catches an unresolved ref inside a record map', () => {
    const schema = {
      schemaVersion: 1,
      commands: [],
      events: [],
      types: { 'x.T': { kind: 'record', fields: [], map: { ref: 'x.Missing' } } },
    }
    assert.deepEqual(checkSchema(schema), ['x.T.*: unresolved type x.Missing'])
  })

  it('flags a field that projected to an unknown primitive (unhandled CDDL type)', () => {
    const schema = {
      schemaVersion: 1,
      commands: [],
      events: [],
      types: {
        'x.T': { kind: 'record', fields: [{ name: 'a', wire: 'a', required: true, type: { primitive: 'unknown' } }] },
      },
    }
    assert.deepEqual(checkSchema(schema), ['x.T.a: projected to an unknown primitive (unhandled CDDL type)'])
  })

  it('flags a synthetic type whose owner does not resolve', () => {
    const schema = {
      schemaVersion: 1,
      commands: [],
      events: [],
      types: {
        'x.E': { kind: 'enum', values: ['a', 'b'], synthetic: true, owner: 'x.Gone', label: 'E' },
      },
    }
    assert.deepEqual(checkSchema(schema), ['x.E: synthetic owner x.Gone does not resolve'])
  })

  it('flags an empty inline record in a union arm (dropped type reference)', () => {
    const schema = {
      schemaVersion: 1,
      commands: [],
      events: [],
      types: {
        'x.U': { kind: 'alias', type: { union: [{ ref: 'x.A' }, { record: [] }] } },
        'x.A': { kind: 'record', fields: [] }, // a legitimately-empty top-level record is NOT flagged
      },
    }
    assert.deepEqual(checkSchema(schema), ['x.U: projected an empty inline record (dropped type reference)'])
  })

  it('rejects a structural selector whose arm shadows a later arm (subset requires)', () => {
    const schema = {
      schemaVersion: 1,
      commands: [],
      events: [],
      types: {
        'x.U': {
          kind: 'union',
          variants: ['x.A', 'x.B'],
          selector: {
            ordered: [
              { ref: 'x.A', requires: ['data'] },
              { ref: 'x.B', requires: ['data', 'more'] },
            ],
          },
        },
        'x.A': { kind: 'record', fields: [] },
        'x.B': { kind: 'record', fields: [] },
      },
    }
    assert.deepEqual(checkSchema(schema), ['x.U: structural selector arm x.A shadows x.B (requires is a subset)'])
  })

  it('rejects a structural selector arm with no required fields to dispatch on', () => {
    const schema = {
      schemaVersion: 1,
      commands: [],
      events: [],
      types: {
        'x.U': {
          kind: 'union',
          variants: ['x.A', 'x.B'],
          selector: {
            ordered: [
              { ref: 'x.A', requires: [] },
              { ref: 'x.B', requires: ['b'] },
            ],
          },
        },
        'x.A': { kind: 'record', fields: [] },
        'x.B': { kind: 'record', fields: [] },
      },
    }
    assert.deepEqual(checkSchema(schema), ['x.U: structural selector arm x.A has no required fields to dispatch on'])
  })

  it('accepts a correlated selector (resolved by request id, not the payload)', () => {
    const schema = {
      schemaVersion: 1,
      commands: [],
      events: [],
      types: {
        'x.ResultData': { kind: 'union', variants: ['x.A'], selector: { correlated: true } },
        'x.A': { kind: 'record', fields: [] },
      },
    }
    assert.deepEqual(checkSchema(schema), [])
  })

  it('exempts the envelope `result` position but flags any other correlated reference', () => {
    const make = (fieldName, withId = true) => ({
      schemaVersion: 1,
      commands: [],
      events: [],
      types: {
        Envelope: {
          kind: 'record',
          fields: [
            ...(withId ? [{ name: 'id', wire: 'id', required: true, type: { primitive: 'integer' } }] : []),
            { name: fieldName, wire: fieldName, required: true, type: { ref: 'x.ResultData' } },
          ],
        },
        'x.ResultData': { kind: 'union', variants: ['x.A'], selector: { correlated: true } },
        'x.A': { kind: 'record', fields: [] },
      },
    })
    assert.deepEqual(checkSchema(make('result')), []) // the envelope's correlation point — allowed
    assert.deepEqual(checkSchema(make('payload')), [
      'Envelope.payload: correlated union x.ResultData is reachable as a value (needs a payload selector)',
    ])
    // A `result` field on a record that is NOT the envelope (no request id) gets no
    // free pass — otherwise a too-broad envelope match could ship silently.
    assert.deepEqual(checkSchema(make('result', false)), [
      'Envelope.result: correlated union x.ResultData is reachable as a value (needs a payload selector)',
    ])
  })

  it('flags a correlated union used as a variant of a non-correlated union', () => {
    const schema = {
      schemaVersion: 1,
      commands: [],
      events: [],
      types: {
        'x.Value': {
          kind: 'union',
          variants: ['x.ResultData'],
          selector: { ordered: [{ ref: 'x.ResultData', requires: ['k'] }] },
        },
        'x.ResultData': { kind: 'union', variants: ['x.A'], selector: { correlated: true } },
        'x.A': { kind: 'record', fields: [{ name: 'k', wire: 'k', required: true, type: { primitive: 'string' } }] },
      },
    }
    assert.deepEqual(checkSchema(schema), [
      'x.Value: correlated union x.ResultData is reachable as a value (needs a payload selector)',
    ])
  })

  it('flags a command that emits null params while its envelope requires them', () => {
    const schema = {
      schemaVersion: 1,
      commands: [{ domain: 'x', method: 'x.foo', name: 'foo', params: null, result: null }],
      events: [],
      types: {
        'x.FooCommand': {
          kind: 'record',
          fields: [
            { name: 'method', wire: 'method', required: true, type: { const: 'x.foo' } },
            { name: 'params', wire: 'params', required: true, type: { ref: 'x.FooParams' } },
          ],
        },
        'x.FooParams': { kind: 'record', fields: [] },
      },
    }
    assert.deepEqual(checkSchema(schema), ['x.foo: params null does not match required envelope params x.FooParams'])
  })
})
