import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import {
  resolveAppPath,
  isWriteWithoutConfirm,
  isAppBridgeRequest,
  detectAppEntry,
  parseAppManifest,
  resolveAppEntry,
  deriveAppList,
} from './app-bridge';

describe('resolveAppPath (client-side path guard)', () => {
  it('accepts a clean relative path and returns it normalized', () => {
    expect(resolveAppPath('foo', 'output/report.txt')).toBe('output/report.txt');
    expect(resolveAppPath('foo', 'apps/foo/data/state.json')).toBe('apps/foo/data/state.json');
  });

  it('collapses ./ and redundant slashes', () => {
    expect(resolveAppPath('foo', './output/./a//b.txt')).toBe('output/a/b.txt');
  });

  it('rejects absolute paths', () => {
    expect(() => resolveAppPath('foo', '/etc/passwd')).toThrow();
  });

  it('rejects ".." traversal in any segment', () => {
    expect(() => resolveAppPath('foo', '../secret')).toThrow();
    expect(() => resolveAppPath('foo', 'output/../../escape')).toThrow();
    expect(() => resolveAppPath('foo', 'a/b/../../../c')).toThrow();
  });

  it('rejects backslashes and drive-letter paths', () => {
    expect(() => resolveAppPath('foo', 'output\\evil')).toThrow();
    expect(() => resolveAppPath('foo', 'C:/Windows/system32')).toThrow();
  });

  it('rejects empty / non-string / root-only', () => {
    expect(() => resolveAppPath('foo', '')).toThrow();
    expect(() => resolveAppPath('foo', '   ')).toThrow();
    expect(() => resolveAppPath('foo', 42 as unknown as string)).toThrow();
    expect(() => resolveAppPath('foo', '/')).toThrow();
    expect(() => resolveAppPath('foo', './.')).toThrow();
  });

  it('rejects control characters / NUL', () => {
    expect(() => resolveAppPath('foo', 'output/a\x00b')).toThrow();
  });
});

describe('isWriteWithoutConfirm (silent-write policy)', () => {
  it('allows under output/ without confirm', () => {
    expect(isWriteWithoutConfirm('foo', 'output')).toBe(true);
    expect(isWriteWithoutConfirm('foo', 'output/report.html')).toBe(true);
    expect(isWriteWithoutConfirm('foo', 'output/sub/x.txt')).toBe(true);
  });

  it('allows under apps/{appName}/data/ without confirm', () => {
    expect(isWriteWithoutConfirm('foo', 'apps/foo/data')).toBe(true);
    expect(isWriteWithoutConfirm('foo', 'apps/foo/data/state.json')).toBe(true);
  });

  it('requires confirm for other paths', () => {
    expect(isWriteWithoutConfirm('foo', 'notes.txt')).toBe(false);
    expect(isWriteWithoutConfirm('foo', 'input/x.txt')).toBe(false);
    // another app's data dir is NOT silently writable by this app
    expect(isWriteWithoutConfirm('foo', 'apps/bar/data/x.json')).toBe(false);
    // app's own non-data subtree (e.g. overwriting its own index.html) requires confirm
    expect(isWriteWithoutConfirm('foo', 'apps/foo/index.html')).toBe(false);
    // prefix-collision guard: "outputs/" must NOT match "output"
    expect(isWriteWithoutConfirm('foo', 'outputs/x.txt')).toBe(false);
  });
});

describe('isAppBridgeRequest (message shape guard)', () => {
  it('accepts known request types with id + type', () => {
    expect(isAppBridgeRequest({ id: 1, type: 'readFile', path: 'output/x' })).toBe(true);
    expect(isAppBridgeRequest({ id: 'a', type: 'writeFile', path: 'output/x', content: 'y' })).toBe(true);
    expect(isAppBridgeRequest({ id: 2, type: 'listFiles' })).toBe(true);
    expect(isAppBridgeRequest({ id: 3, type: 'deleteFile', path: 'output/x' })).toBe(true);
  });

  it('rejects foreign / malformed messages', () => {
    expect(isAppBridgeRequest(null)).toBe(false);
    expect(isAppBridgeRequest('hello')).toBe(false);
    expect(isAppBridgeRequest({ type: 'readFile' })).toBe(false); // no id
    expect(isAppBridgeRequest({ id: 1 })).toBe(false); // no type
    expect(isAppBridgeRequest({ id: 1, type: 'exec' })).toBe(false); // unknown type
    expect(isAppBridgeRequest({ id: 1, type: 'eval' })).toBe(false);
  });
});

describe('detectAppEntry (launch affordance detection)', () => {
  it('matches apps/{name}/index.html', () => {
    expect(detectAppEntry('apps/invoice-gen/index.html')).toEqual({
      appName: 'invoice-gen',
      entryPath: 'apps/invoice-gen/index.html',
    });
  });

  it('does not match non-app or nested html', () => {
    expect(detectAppEntry('output/report.html')).toBeNull();
    expect(detectAppEntry('apps/foo/sub/index.html')).toBeNull();
    expect(detectAppEntry('apps/foo/main.html')).toBeNull();
    expect(detectAppEntry('index.html')).toBeNull();
  });
});

describe('parseAppManifest (lenient app.json parse)', () => {
  it('parses a full valid manifest and trims string fields', () => {
    expect(parseAppManifest('{"title":" Invoice ","description":" gen ","entry":"main.html"}')).toEqual({
      title: 'Invoice',
      description: 'gen',
      entry: 'main.html',
    });
  });

  it('returns an empty object when fields are absent (still a valid object)', () => {
    expect(parseAppManifest('{}')).toEqual({});
  });

  it('ignores non-string / empty fields', () => {
    expect(parseAppManifest('{"title":42,"description":"","entry":null}')).toEqual({});
  });

  it('falls back to null on missing / invalid / non-object input', () => {
    expect(parseAppManifest(null)).toBeNull();
    expect(parseAppManifest(undefined)).toBeNull();
    expect(parseAppManifest('')).toBeNull();
    expect(parseAppManifest('   ')).toBeNull();
    expect(parseAppManifest('{not json')).toBeNull();
    expect(parseAppManifest('"a string"')).toBeNull();
    expect(parseAppManifest('[1,2,3]')).toBeNull();
    expect(parseAppManifest('123')).toBeNull();
  });
});

describe('resolveAppEntry (manifest entry validation)', () => {
  it('defaults to apps/{name}/index.html when no entry given', () => {
    expect(resolveAppEntry('foo')).toBe('apps/foo/index.html');
    expect(resolveAppEntry('foo', '')).toBe('apps/foo/index.html');
    expect(resolveAppEntry('foo', '   ')).toBe('apps/foo/index.html');
    expect(resolveAppEntry('foo', null)).toBe('apps/foo/index.html');
  });

  it('resolves a folder-relative entry under apps/{name}/', () => {
    expect(resolveAppEntry('foo', 'main.html')).toBe('apps/foo/main.html');
    expect(resolveAppEntry('foo', './ui/app.html')).toBe('apps/foo/ui/app.html');
  });

  it('accepts an explicit apps/{name}/... entry for the same app', () => {
    expect(resolveAppEntry('foo', 'apps/foo/index.html')).toBe('apps/foo/index.html');
  });

  it('rejects traversal / escaping entries and falls back to default', () => {
    expect(resolveAppEntry('foo', '../bar/index.html')).toBe('apps/foo/index.html');
    expect(resolveAppEntry('foo', '../../etc/passwd')).toBe('apps/foo/index.html');
    expect(resolveAppEntry('foo', '/etc/passwd')).toBe('apps/foo/index.html');
    expect(resolveAppEntry('foo', 'C:/Windows')).toBe('apps/foo/index.html');
    expect(resolveAppEntry('foo', 'sub\\evil.html')).toBe('apps/foo/index.html');
    // another app's folder is out of bounds
    expect(resolveAppEntry('foo', 'apps/bar/index.html')).toBe('apps/foo/index.html');
    // an entry that normalizes back to the app folder root is rejected
    expect(resolveAppEntry('foo', '.')).toBe('apps/foo/index.html');
  });
});

describe('deriveAppList (apps-list derivation)', () => {
  it('keeps only folders that contain an index.html', () => {
    const apps = deriveAppList(['has-index', 'no-index'], name => name === 'has-index');
    expect(apps.map(a => a.name)).toEqual(['has-index']);
    expect(apps[0]).toMatchObject({
      name: 'has-index',
      entryPath: 'apps/has-index/index.html',
      title: 'has-index',
    });
  });

  it('uses manifest title/description/entry when present', () => {
    const apps = deriveAppList(
      ['note'],
      () => true,
      () => '{"title":"メモ帳","description":"output を編集","entry":"main.html"}',
    );
    expect(apps[0]).toEqual({
      name: 'note',
      title: 'メモ帳',
      description: 'output を編集',
      entryPath: 'apps/note/main.html',
    });
  });

  it('falls back to folder name when manifest is missing or invalid', () => {
    const apps = deriveAppList(
      ['a', 'b'],
      () => true,
      name => (name === 'a' ? '{bad json' : null),
    );
    expect(apps.find(x => x.name === 'a')).toMatchObject({ title: 'a', entryPath: 'apps/a/index.html' });
    expect(apps.find(x => x.name === 'b')).toMatchObject({ title: 'b', entryPath: 'apps/b/index.html' });
    expect(apps.find(x => x.name === 'a')?.description).toBeUndefined();
  });

  it('sorts by display title', () => {
    const apps = deriveAppList(
      ['z-app', 'a-app'],
      () => true,
      name => (name === 'z-app' ? '{"title":"AAA"}' : '{"title":"ZZZ"}'),
    );
    // z-app has title AAA → sorts first; a-app has title ZZZ → last
    expect(apps.map(a => a.title)).toEqual(['AAA', 'ZZZ']);
  });
});

// Source-level assertion: the iframe must be sandboxed WITHOUT allow-same-origin.
// (DOM render tests fail in this sandbox env; source-level is the robust check
// per the spec — "source-level or render".) This is the single most important
// security invariant of the AppRunner.
describe('AppRunner iframe sandbox invariant', () => {
  const src = readFileSync(
    join(dirname(fileURLToPath(import.meta.url)), 'AppRunner.tsx'),
    'utf-8',
  );

  // Extract the literal sandbox attribute value(s) from the JSX (ignores
  // comments, which legitimately mention allow-same-origin to explain WHY it's
  // absent). There must be exactly one and it must be exactly "allow-scripts".
  const sandboxValues = [...src.matchAll(/\bsandbox=["']([^"']*)["']/g)].map(m => m[1]);

  it('sets sandbox to exactly "allow-scripts" (opaque origin)', () => {
    expect(sandboxValues.length).toBeGreaterThan(0);
    for (const v of sandboxValues) expect(v).toBe('allow-scripts');
  });

  it('sandbox attribute NEVER includes allow-same-origin (would break the security model)', () => {
    for (const v of sandboxValues) {
      expect(v).not.toContain('allow-same-origin');
    }
  });

  it('validates message source identity (event.source === iframe.contentWindow)', () => {
    expect(src).toMatch(/event\.source\s*!==\s*frameWin/);
  });

  it('uses srcDoc (opaque-origin injection), not a same-origin src URL', () => {
    expect(src).toMatch(/srcDoc=\{srcDoc\}/);
  });

  // The CSP injected into the app HTML must block external network so a
  // sandboxed app cannot exfiltrate workspace file contents it was handed.
  it("injects a CSP with connect-src 'none' (blocks fetch/XHR/beacon exfil)", () => {
    expect(src).toMatch(/connect-src 'none'/);
    expect(src).toMatch(/default-src 'none'/);
    // and the CSP is actually applied to the loaded HTML
    expect(src).toMatch(/injectCsp\(/);
  });
});
