import { createHash } from 'node:crypto'
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { unzipSync, zipSync } from 'fflate'
import { createWebCadFacade } from '../src/facade/mockFacade'
import { decodeFcstdPropertyValue } from '../src/facade/fcstd'
import { encodeFreecadPropertyStatus } from '../src/facade/propertyStatus'
import type { DocumentSnapshot, FacadeEvent, IncludedFileValue, ObjectPropertySnapshot } from '../src/facade/types'

const hosts: Array<{ id: string; typeId: string; properties: Array<{ name: string; status?: 'ReadOnly' | 'PropOutput' }> }> = [
  { id: 'IncludedProbe', typeId: 'App::DocumentObjectFileIncluded', properties: [{ name: 'File' }] },
  { id: 'VrmlProbe', typeId: 'App::VRMLObject', properties: [{ name: 'VrmlFile' }] },
  { id: 'ImagePlaneProbe', typeId: 'Image::ImagePlane', properties: [{ name: 'ImageFile' }] },
  { id: 'RobotProbe', typeId: 'Robot::RobotObject', properties: [{ name: 'RobotKinematicFile' }, { name: 'RobotVrmlFile' }] },
  { id: 'SketchFlatProbe', typeId: 'Sketcher::SketchObjectSF', properties: [{ name: 'SketchFlatFile' }] },
  { id: 'ComplexSectionProbe', typeId: 'TechDraw::DrawComplexSection', properties: [{ name: 'PatIncluded', status: 'ReadOnly' }, { name: 'SvgIncluded', status: 'ReadOnly' }] },
  { id: 'ComplexSectionPythonProbe', typeId: 'TechDraw::DrawComplexSectionPython', properties: [{ name: 'PatIncluded', status: 'ReadOnly' }, { name: 'SvgIncluded', status: 'ReadOnly' }] },
  { id: 'GeomHatchProbe', typeId: 'TechDraw::DrawGeomHatch', properties: [{ name: 'PatIncluded' }] },
  { id: 'HatchProbe', typeId: 'TechDraw::DrawHatch', properties: [{ name: 'SvgIncluded' }] },
  { id: 'SvgTemplateProbe', typeId: 'TechDraw::DrawSVGTemplate', properties: [{ name: 'PageResult', status: 'PropOutput' }] },
  { id: 'TileWeldProbe', typeId: 'TechDraw::DrawTileWeld', properties: [{ name: 'SymbolIncluded' }] },
  { id: 'TileWeldPythonProbe', typeId: 'TechDraw::DrawTileWeldPython', properties: [{ name: 'SymbolIncluded' }] },
  { id: 'ViewImageProbe', typeId: 'TechDraw::DrawViewImage', properties: [{ name: 'ImageIncluded' }] },
  { id: 'ViewSectionProbe', typeId: 'TechDraw::DrawViewSection', properties: [{ name: 'PatIncluded', status: 'ReadOnly' }, { name: 'SvgIncluded', status: 'ReadOnly' }] },
  { id: 'ViewSectionPythonProbe', typeId: 'TechDraw::DrawViewSectionPython', properties: [{ name: 'PatIncluded', status: 'ReadOnly' }, { name: 'SvgIncluded', status: 'ReadOnly' }] },
]

const property = (name: string, value: IncludedFileValue | null, status?: 'ReadOnly' | 'PropOutput'): ObjectPropertySnapshot => ({
  name,
  label: name,
  group: 'Resource',
  scope: 'data',
  type: 'App::PropertyFileIncluded',
  value,
  ...(status ? { nativeStatus: encodeFreecadPropertyStatus([status]) } : {}),
  recompute: true,
})

const fixture = (value?: (objectId: string, propertyName: string, index: number) => IncludedFileValue | null): DocumentSnapshot => {
  let index = 0
  return {
    id: 'property-fileincluded-facade',
    label: 'PropertyFileIncluded Facade',
    version: 1,
    dirty: false,
    readOnly: false,
    units: 'mm',
    tree: hosts.map(({ id }) => ({ id, label: id, type: 'feature', state: 'valid' })),
    objects: hosts.map((host) => ({ id: host.id, typeId: host.typeId, properties: host.properties.map(({ name, status }) => property(name, value?.(host.id, name, index++) ?? null, status)) })),
    dependencies: [],
    recompute: { generation: 0, status: 'idle', objectStates: Object.fromEntries(hosts.map(({ id }) => [id, 'up-to-date'])), dirtyObjects: [], order: [], errors: [] },
  }
}

test('App::PropertyFileIncluded stores authoritative bytes before committing a structured Facade value', async () => {
  const facade = createWebCadFacade({ initialDocument: fixture(), initialSelectedObjectIds: ['IncludedProbe'], runtimeMode: 'mock' })
  const events: FacadeEvent[] = []
  facade.subscribe((event) => {
    if (event.type === 'property.before-change' || event.type === 'property.changed' || event.type === 'transaction.committed') events.push(event)
  })
  const bytes = new Uint8Array([0, 255, 17, 42, 99])
  const first = await facade.app.document.setIncludedFile({ objectId: 'IncludedProbe', propertyName: 'File', fileName: 'payload.bin', bytes })
  bytes[0] = 88
  assert.match(first.resourceHash, /^[a-f0-9]{64}$/)
  assert.deepEqual(first, { schemaVersion: 1, archiveName: 'payload.bin', resourceHash: first.resourceHash, byteLength: 5, mediaType: 'application/octet-stream' })
  assert.deepEqual(facade.app.document.getObject('IncludedProbe')?.properties[0].value, first)
  assert.deepEqual([...(await facade.project.resource.get(first.resourceHash))!], [0, 255, 17, 42, 99])
  assert.deepEqual(events.map((event) => event.type), ['property.before-change', 'property.changed', 'transaction.committed'])

  const second = await facade.app.document.setIncludedFile({ objectId: 'RobotProbe', propertyName: 'RobotKinematicFile', fileName: 'payload.bin', bytes: new Uint8Array([1, 2, 3]), mediaType: 'text/csv' })
  assert.equal(second.archiveName, 'payload1.bin')
  assert.equal(second.mediaType, 'text/csv')

  const stable = JSON.stringify(facade.app.document.getActive())
  assert.throws(() => facade.app.document.setProperty({ objectId: 'IncludedProbe', propertyName: 'File', value: null }), /cannot be cleared/)
  assert.throws(() => facade.app.document.setProperty({ objectId: 'IncludedProbe', propertyName: 'File', value: { ...first, archiveName: '../payload.bin' } }), /safe file basename/)
  assert.throws(() => facade.app.document.setProperty({ objectId: 'IncludedProbe', propertyName: 'File', value: { ...first, resourceHash: 'not-a-hash' } }), /SHA-256/)
  assert.throws(() => facade.app.document.setProperty({ objectId: 'IncludedProbe', propertyName: 'File', value: { ...first, byteLength: -1 } }), /byteLength/)
  assert.equal(JSON.stringify(facade.app.document.getActive()), stable)
  await assert.rejects(facade.app.document.setIncludedFile({ objectId: 'ComplexSectionProbe', propertyName: 'PatIncluded', fileName: 'pattern.pat', bytes: new Uint8Array([1]) }), /read-only/)
  await facade.project.dispose()
})

test('App::PropertyFileIncluded writes, inspects, extracts and stores all 20 native host properties', async () => {
  const payload = new Uint8Array([60, 115, 118, 103, 62, 0, 255, 60, 47, 115, 118, 103, 62])
  const resourceHash = createHash('sha256').update(payload).digest('hex')
  const document = fixture((_objectId, propertyName, index) => ({ schemaVersion: 1, archiveName: `${String(index).padStart(2, '0')}-${propertyName}.bin`, resourceHash, byteLength: payload.byteLength, mediaType: 'application/octet-stream' }))
  const facade = createWebCadFacade({ initialDocument: document, runtimeMode: 'mock' })
  const stored = await facade.project.resource.put(payload, 'application/octet-stream')
  assert.equal(stored.hash, resourceHash)
  assert.throws(() => facade.project.fcstd.serializeMetadata(document), /missing project resource bytes/)

  const archive = await facade.project.fcstd.serializeMetadataWithResources(document)
  const files = unzipSync(archive)
  const documentXml = new TextDecoder().decode(files['Document.xml'])
  assert.equal((documentXml.match(/type="App::PropertyFileIncluded"/g) ?? []).length, 20)
  assert.equal((documentXml.match(/<FileIncluded file="[^"]+"\/>/g) ?? []).length, 20)
  for (const match of documentXml.matchAll(/<Property[^>]+type="App::PropertyFileIncluded"[^>]*>/g)) assert.doesNotMatch(match[0], /\s(?:group|doc|attr|ro|hide)=/)
  const archiveNames = document.objects.flatMap((object) => object.properties.map((candidate) => (candidate.value as IncludedFileValue).archiveName))
  assert.equal(new Set(archiveNames).size, 20)
  for (const archiveName of archiveNames) assert.deepEqual([...files[archiveName]], [...payload])

  const inspection = facade.project.fcstd.inspect(archive)
  const summaries = inspection.objects.flatMap((object) => object.properties.filter((candidate) => candidate.typeId === 'App::PropertyFileIncluded'))
  assert.equal(summaries.length, 20)
  assert.ok(summaries.every((summary) => summary.element === 'FileIncluded' && summary.includedFileResource?.byteLength === payload.byteLength && summary.includedFileResource.contentHash.length === 8))
  assert.equal(decodeFcstdPropertyValue(summaries[0]).decoded, false)
  const extracted = facade.project.fcstd.extractIncludedFiles(archive)
  assert.equal(extracted.length, 20)
  assert.ok(extracted.every((resource) => resource.references.length === 1 && resource.byteLength === payload.byteLength && resource.bytes.every((byte, index) => byte === payload[index])))
  const imported = await facade.project.fcstd.storeIncludedFiles(archive)
  assert.equal(imported.length, 20)
  assert.ok(imported.every((resource) => resource.hash === resourceHash && resource.value.resourceHash === resourceHash && resource.value.archiveName === resource.path))

  const missing = { ...files }
  delete missing[archiveNames[0]]
  assert.throws(() => facade.project.fcstd.inspect(zipSync(missing)), /references missing resource/)
  const emptyDocument = fixture()
  const emptyArchive = facade.project.fcstd.serializeMetadata(emptyDocument)
  const emptySummary = facade.project.fcstd.inspect(emptyArchive).objects[0].properties.find((candidate) => candidate.typeId === 'App::PropertyFileIncluded')!
  assert.match(new TextDecoder().decode(unzipSync(emptyArchive)['Document.xml']), /<FileIncluded file=""\/>/)
  assert.deepEqual(decodeFcstdPropertyValue(emptySummary), { value: null, decoded: true })
  await facade.project.dispose()
})

test('App::PropertyFileIncluded rewrites one native resource through project resource identities', async () => {
  const initialBytes = new Uint8Array([0, 17, 34, 51])
  const targetBytes = new Uint8Array([255, 128, 64, 32, 16])
  const initialHash = createHash('sha256').update(initialBytes).digest('hex')
  const document = fixture((objectId, propertyName) => objectId === 'IncludedProbe' && propertyName === 'File' ? { schemaVersion: 1, archiveName: 'payload.bin', resourceHash: initialHash, byteLength: initialBytes.byteLength, mediaType: 'application/octet-stream' } : null)
  const facade = createWebCadFacade({ initialDocument: document, runtimeMode: 'mock' })
  const initialResource = await facade.project.resource.put(initialBytes, 'application/octet-stream')
  const targetResource = await facade.project.resource.put(targetBytes, 'application/octet-stream')
  const archive = await facade.project.fcstd.serializeMetadataWithResources(document)
  const before = facade.project.fcstd.inspect(archive).objects.find((object) => object.name === 'IncludedProbe')!.properties.find((candidate) => candidate.name === 'File')!
  const expectedValue = document.objects[0].properties[0].value as IncludedFileValue
  const value: IncludedFileValue = { schemaVersion: 1, archiveName: 'payload.bin', resourceHash: targetResource.hash, byteLength: targetBytes.byteLength, mediaType: 'application/octet-stream' }
  const rewritten = await facade.project.fcstd.rewriteIncludedFile(archive, { objectName: 'IncludedProbe', propertyName: 'File', value, expectedValue })
  const files = unzipSync(rewritten)
  const after = facade.project.fcstd.inspect(rewritten).objects.find((object) => object.name === 'IncludedProbe')!.properties.find((candidate) => candidate.name === 'File')!
  assert.deepEqual([...files['payload.bin']], [...targetBytes])
  assert.equal(before.includedFileResource?.byteLength, initialBytes.byteLength)
  assert.equal(after.includedFileResource?.byteLength, targetBytes.byteLength)
  assert.equal(after.resourcePath, 'payload.bin')
  assert.match(new TextDecoder().decode(files['Document.xml']), /<Object(?=[^>]*name="IncludedProbe")(?=[^>]*Touched="1")[^>]*\/>/)
  await assert.rejects(facade.project.fcstd.rewriteIncludedFile(archive, { objectName: 'IncludedProbe', propertyName: 'File', value: { ...value, archiveName: 'renamed.bin' }, expectedValue }), /cannot change the native archive name/)
  await assert.rejects(facade.project.fcstd.rewriteIncludedFile(rewritten, { objectName: 'IncludedProbe', propertyName: 'File', value, expectedValue }), /does not match expectedValue/)
  await facade.project.resource.release(initialResource.hash)
  await facade.project.resource.release(targetResource.hash)
  await facade.project.dispose()
})
