// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed 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.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========

import { describe, expect, it } from 'vitest'
import { IOResult } from '../../../io/types.ts'
import { RAMResource } from '../../../resource/ram/ram.ts'
import { MountMode, PathSpec } from '../../../types.ts'
import { getTestParser } from '../../fixtures/workspace_fixture.ts'
import { Workspace } from '../../workspace/workspace.ts'
import { PolicyDenied } from '../../../policy/errors.ts'
import { ArithError } from '../../../shell/errors.ts'
import { Session } from '../../session/session.ts'
import { sessionView } from '../../session/state.ts'
import {
  IDENTIFIER_RE,
  absPath,
  arithRefusal,
  expandOperands,
  fail,
  finish,
  isCountWord,
  isValidName,
  ok,
  operandText,
  readonlyRefusal,
  refusal,
  splitFlags,
  splitValueFlags,
  viewOf,
} from './shared.ts'

const DEC = new TextDecoder()

function stderrOf(io: IOResult): string {
  return io.stderr === null ? '' : DEC.decode(io.stderr as Uint8Array)
}

describe('builtins/shared: the result triple', () => {
  it('ok carries stdout and a clean node', () => {
    const [out, io, node] = ok('ln', new TextEncoder().encode('x\n'))
    expect(out === null ? '' : DEC.decode(out as Uint8Array)).toBe('x\n')
    expect(io.exitCode).toBe(0)
    expect(node.command).toBe('ln')
    expect(node.exitCode).toBe(0)
    expect(node.stderr).toEqual(new Uint8Array())
  })

  it('fail puts the message on both the IOResult and the node', () => {
    const [out, io, node] = fail('chmod', 'chmod: missing operand\n', 2)
    expect(out).toBeNull()
    expect(io.exitCode).toBe(2)
    expect(stderrOf(io)).toBe('chmod: missing operand\n')
    expect(node.exitCode).toBe(2)
    expect(DEC.decode(node.stderr)).toBe('chmod: missing operand\n')
  })

  it('finish with no errors keeps the carried writes and exits 0', () => {
    const io = new IOResult({ writes: { '/data/f.txt': new Uint8Array() } })
    const [out, resultIo, node] = finish('touch', [], io)
    expect(out).toBeNull()
    expect(resultIo.exitCode).toBe(0)
    expect(Object.keys(resultIo.writes)).toEqual(['/data/f.txt'])
    expect(node.exitCode).toBe(0)
    expect(node.stderr).toEqual(new Uint8Array())
  })

  it('finish joins the operand errors and exits 1', () => {
    const [, io, node] = finish('chown', ['a\n', 'b\n'])
    expect(io.exitCode).toBe(1)
    expect(stderrOf(io)).toBe('a\nb\n')
    expect(DEC.decode(node.stderr)).toBe('a\nb\n')
  })
})

describe('builtins/shared: operands and flags', () => {
  it('operandText unwraps a PathSpec the classifier wrapped', () => {
    expect(operandText(PathSpec.fromStrPath('/data/644'))).toBe('/data/644')
    expect(operandText('644')).toBe('644')
  })

  it('absPath resolves a relative operand against the cwd', () => {
    expect(absPath(PathSpec.fromStrPath('/data/f.txt'), '/tmp')).toBe('/data/f.txt')
    expect(absPath('f.txt', '/data')).toBe('/data/f.txt')
  })

  it('splitFlags collects known letters', () => {
    const [flags, operands] = splitFlags(['-sf', 'a', 'b'], 'sfnv')
    expect([...flags].sort()).toEqual(['f', 's'])
    expect(operands).toEqual(['a', 'b'])
  })

  it('splitFlags keeps a token with an unknown letter as an operand', () => {
    const [flags, operands] = splitFlags(['-q', 'a'], 'sfnv')
    expect(flags.size).toBe(0)
    expect(operands).toEqual(['-q', 'a'])
  })

  it('splitFlags stops parsing at --', () => {
    const [flags, operands] = splitFlags(['-s', '--', '-f'], 'sfnv')
    expect([...flags]).toEqual(['s'])
    expect(operands).toEqual(['-f'])
  })

  it('splitValueFlags takes a detached value', () => {
    const { flags, values, operands, bad } = splitValueFlags(
      ['-c', '-t', '202601021530', 'f.txt'],
      'acmh',
      'tdr',
    )
    expect(bad).toBeNull()
    expect([...flags]).toEqual(['c'])
    expect(values.get('t')).toBe('202601021530')
    expect(operands).toEqual(['f.txt'])
  })

  it('splitValueFlags takes an attached value', () => {
    const { values, operands, bad } = splitValueFlags(['-t202601021530', 'f'], 'acmh', 'tdr')
    expect(bad).toBeNull()
    expect(values.get('t')).toBe('202601021530')
    expect(operands).toEqual(['f'])
  })

  it('splitValueFlags reports an unknown letter', () => {
    const { bad } = splitValueFlags(['-q', 'f'], 'Rvf', '')
    expect(bad).toBe('q')
  })
})

describe('builtins/shared: expandOperands', () => {
  it('expands a pattern per mount and passes a plain path through', async () => {
    const parser = await getTestParser()
    const ws = new Workspace(
      { '/data': new RAMResource() },
      { mode: MountMode.WRITE, shellParser: parser },
    )
    await ws.execute('echo a > /data/a.txt && echo b > /data/b.txt')
    const globSpec = new PathSpec({
      virtual: '/data/*.txt',
      directory: '/data/',
      pattern: '*.txt',
      resolved: false,
      resourcePath: '*.txt',
    })
    const expanded = await expandOperands(ws.namespace, [globSpec, '/data/c.md'])
    expect(expanded.map((p) => p.virtual).sort()).toEqual([
      '/data/a.txt',
      '/data/b.txt',
      '/data/c.md',
    ])
    await ws.close()
  })
})

describe('builtins/shared: the session helpers', () => {
  it('viewOf threads the caller view and falls back to an ungated one', () => {
    const session = new Session({ sessionId: 's1' })
    const view = sessionView(session)
    expect(viewOf(session, view)).toBe(view)
    expect(viewOf(session, null)).not.toBeNull()
  })

  it('refusal speaks in the builtin voice', () => {
    const [out, io, node] = refusal('export', new PolicyDenied('X: refused', 'X'))
    expect(out).toBeNull()
    expect(io.exitCode).toBe(1)
    expect(stderrOf(io)).toBe('X: refused\n')
    expect(node.command).toBe('export')
  })

  it('readonlyRefusal names the variable', () => {
    const [out, io, node] = readonlyRefusal('read', 'X')
    expect(out).toBeNull()
    expect(io.exitCode).toBe(1)
    expect(stderrOf(io)).toBe('bash: X: readonly variable\n')
    expect(node.command).toBe('read')
  })

  it('arithRefusal prefixes the builtin', () => {
    const [out, io, node] = arithRefusal('let', new ArithError('1+: syntax error'))
    expect(out).toBeNull()
    expect(io.exitCode).toBe(1)
    expect(stderrOf(io)).toBe('bash: let: 1+: syntax error\n')
    expect(node.command).toBe('let')
  })

  it('isValidName / isCountWord', () => {
    expect(isValidName('_x9')).toBe(true)
    expect(isValidName('9x')).toBe(false)
    expect(isValidName('a-b')).toBe(false)
    expect(isValidName('')).toBe(false)
    expect(IDENTIFIER_RE.test('abc')).toBe(true)
    expect(isCountWord('3')).toBe(true)
    expect(isCountWord('-3')).toBe(true)
    expect(isCountWord('+3')).toBe(true)
    expect(isCountWord('x')).toBe(false)
    expect(isCountWord('-')).toBe(false)
  })
})
