import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
import { FoundationAiAssistant } from './main';

// Hold a reference so the custom-element registration isn't tree-shaken.
FoundationAiAssistant;

// GENC-1508 — image files become IMAGE attachments, not text.
//
// `processFiles` read every accepted file with `readAsText`, so an accepted .png
// arrived as mojibake the model "read" as prose — an accepted image was worse than a
// rejected one. The layout-apply flow attaches the picked mockup's PNG beside its
// spec, which is what forced the branch: images now read as base64 and ship as
// `{kind:'image'}`, the shape the transports render as real image blocks.
//
// Unconnected elements (createElement upgrades but does not connect), same as
// blocked-state.test.ts — processFiles touches no session state.

const Suite = createLogicSuite('FoundationAiAssistant file attachments');

type ProcessFiles = {
  processFiles(
    files: File[],
    existingBytes?: number,
  ): Promise<{ attachments: unknown[]; errors: string[] }>;
};

function element(acceptedFiles: string): ProcessFiles {
  const el = document.createElement('foundation-ai-assistant') as FoundationAiAssistant;
  el.chatConfig = { ui: { acceptedFiles } };
  return el as unknown as ProcessFiles;
}

/** A tiny valid PNG header is unnecessary — processFiles never parses pixels. */
const PNG_BYTES = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);

Suite('an accepted image file becomes a kind:image attachment with raw base64', async () => {
  const el = element('.md,image/*');
  const { attachments, errors } = await el.processFiles([
    new File([PNG_BYTES], 'layout-mockup.png', { type: 'image/png' }),
  ]);
  assert.equal(errors, []);
  const [img] = attachments as Array<{
    kind?: string;
    name: string;
    mimeType: string;
    data?: string;
  }>;
  assert.is(img.kind, 'image');
  assert.is(img.name, 'layout-mockup.png');
  assert.is(img.mimeType, 'image/png');
  // RAW base64 — a `data:` prefix left on would be sent to the vendor inside the payload.
  assert.ok(img.data && !img.data.startsWith('data:'), 'data must be bare base64');
  assert.is(atob(img.data!).length, PNG_BYTES.length);
});

Suite('a text file on the same submit stays a text attachment', async () => {
  const el = element('.md,image/*');
  const { attachments } = await el.processFiles([
    new File(['# spec'], 'layout-spec.md', { type: 'text/markdown' }),
    new File([PNG_BYTES], 'layout-mockup.png', { type: 'image/png' }),
  ]);
  const [text, img] = attachments as Array<{ kind?: string; content?: string; data?: string }>;
  assert.is(text.kind, undefined);
  assert.is(text.content, '# spec');
  assert.is(img.kind, 'image');
});

Suite('an image type the host did not accept is still refused', async () => {
  const el = element('.md,.txt');
  const { attachments, errors } = await el.processFiles([
    new File([PNG_BYTES], 'layout-mockup.png', { type: 'image/png' }),
  ]);
  assert.equal(attachments, []);
  assert.is(errors.length, 1);
  assert.match(errors[0], /not an accepted file type/);
});

// A ~70KB image: the base64 loop's chunk size is 32768, so this crosses the boundary twice.
// The 8-byte fixture above executes the loop body exactly once, which would not notice an
// off-by-one in the subarray bounds.
Suite('a multi-chunk image round-trips byte-for-byte across the base64 loop', async () => {
  const big = new Uint8Array(70_000);
  // A non-trivial pattern — an all-zero buffer survives most encoding mistakes.
  for (let i = 0; i < big.length; i += 1) big[i] = (i * 31 + (i >> 8)) & 0xff;
  const el = element('image/*');
  const { attachments, errors } = await el.processFiles([
    new File([big], 'wide.png', { type: 'image/png' }),
  ]);
  assert.equal(errors, []);
  const [img] = attachments as Array<{ data: string }>;
  const back = atob(img.data);
  assert.is(back.length, big.length);
  let mismatch = -1;
  for (let i = 0; i < big.length; i += 1) {
    if (back.charCodeAt(i) !== big[i]) {
      mismatch = i;
      break;
    }
  }
  assert.is(mismatch, -1, 'every byte must survive the chunked encode');
});

Suite(
  'an image over the size cap is refused by NAME and size, not sent to the vendor',
  async () => {
    // Refused here rather than at the API: a vendor 400 lands after the user has waited out the
    // turn, and the encoded blob is persisted meanwhile — one oversized image can blow the storage
    // quota and silently stop the session autosaving.
    const el = element('image/*');
    const { attachments, errors } = await el.processFiles([
      new File([new Uint8Array(4_000_000)], 'huge.png', { type: 'image/png' }),
    ]);
    assert.equal(attachments, []);
    assert.is(errors.length, 1);
    assert.match(errors[0], /huge\.png/);
    assert.match(errors[0], /limited to/);
  },
);

Suite(
  'an image type the vendors do not accept falls back to TEXT, never a failed turn',
  async () => {
    // `image/*` covers HEIC, BMP, TIFF, SVG — none of which the APIs take, and sending one fails
    // the whole turn. SVG especially: it IS text, so the text branch gives the model something it
    // can actually read.
    const el = element('image/*');
    const { attachments, errors } = await el.processFiles([
      new File(['<svg xmlns="http://www.w3.org/2000/svg"><rect /></svg>'], 'icon.svg', {
        type: 'image/svg+xml',
      }),
      new File([new Uint8Array([0, 1, 2, 3])], 'photo.heic', { type: 'image/heic' }),
    ]);
    assert.equal(errors, []);
    const [svg, heic] = attachments as Array<{ kind?: string; content?: string; mimeType: string }>;
    assert.is(svg.kind, undefined, 'SVG stays text — a model can read it');
    assert.match(svg.content!, /<rect/);
    assert.is(heic.kind, undefined, 'an unsupported binary degrades rather than failing the turn');
  },
);

Suite('every supported spelling takes the image branch, including image/jpg', async () => {
  const el = element('image/*');
  const { attachments } = await el.processFiles(
    ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp'].map(
      (type, i) => new File([PNG_BYTES], `f${i}`, { type }),
    ),
  );
  assert.equal(
    (attachments as Array<{ kind?: string }>).map((a) => a.kind),
    ['image', 'image', 'image', 'image', 'image'],
  );
});

// The TOTAL, not just each file. `loadSelectedFiles` appends across successive picks and the
// per-file check is blind to what is already attached, so ten files each just under the per-image
// ceiling used to be ten passes and one ~34MB request — assembled entirely out of files that each
// passed. Reviewer catch on #2474.

Suite('the file that crosses the total is refused; the ones that fit are kept', async () => {
  const el = element('image/*');
  const mb = (n: number) => new Uint8Array(n * 1_000_000);
  const { attachments, errors } = await el.processFiles([
    new File([mb(3)], 'a.png', { type: 'image/png' }),
    new File([mb(3)], 'b.png', { type: 'image/png' }),
    new File([mb(3)], 'c.png', { type: 'image/png' }), // 9MB total — past the 8MB ceiling
  ]);
  assert.is(attachments.length, 2, 'the two that fit are accepted');
  assert.is(errors.length, 1);
  assert.match(errors[0], /c\.png/);
  assert.match(errors[0], /attachment limit/);
});

Suite('the budget counts what is ALREADY attached, not just this pick', async () => {
  // The picker's second pick has to see the first one's bytes, or "under the limit" is measured
  // against zero every time and the limit means nothing.
  const el = element('image/*');
  const fresh = await el.processFiles([
    new File([new Uint8Array(3_000_000)], 'a.png', { type: 'image/png' }),
  ]);
  assert.is(fresh.attachments.length, 1, 'fits on its own');

  const withHistory = await el.processFiles(
    [new File([new Uint8Array(3_000_000)], 'b.png', { type: 'image/png' })],
    6_000_000,
  );
  assert.is(
    withHistory.attachments.length,
    0,
    'the same file is refused once 6MB is already committed',
  );
  assert.match(withHistory.errors[0], /6\.0MB already attached/);
});

Suite('a refusal skips only the offending file — a later one that still fits is kept', async () => {
  // Budgeted synchronously off `file.size`, in the user's own pick order, before anything is read
  // (deciding inside the parallel reads would hand the last slot to whichever finished first).
  // A file that does not fit is refused and the budget carries on, so a smaller one behind it is
  // not punished for its neighbour.
  const el = element('image/*');
  const mb = (n: number) => new Uint8Array(n * 1_000_000);
  const { attachments, errors } = await el.processFiles([
    new File([mb(3)], 'a.png', { type: 'image/png' }), // running 3
    new File([mb(3)], 'b.png', { type: 'image/png' }), // running 6
    new File([mb(3)], 'big.png', { type: 'image/png' }), // 9 > 8 — refused
    new File([mb(1)], 'small.png', { type: 'image/png' }), // 7 — still fits
  ]);
  assert.equal(
    (attachments as Array<{ name: string }>).map((a) => a.name),
    ['a.png', 'b.png', 'small.png'],
  );
  assert.is(errors.length, 1);
  assert.match(errors[0], /big\.png/);
});

Suite.run();
