#!/usr/bin/env node

import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';

const PERMISSIONS = [
  'readContext',
  'sendMessage',
  'setDraft',
  'openTopic',
  'uploadFile',
  'generateImages',
  'wallet',
  'gptPro',
  'invokeServices',
  'boards',
  'polls',
];
const CAPABILITIES = ['message.text'];
const VISIBILITIES = ['private', 'shared', 'public'];
const RESERVED_APP_IDS = ['calculator', 'design-studio', 'mind-map', 'polls', 'caro-live', 'gptpro', 'deltax-hub'];
const REQUIRED_FRAME_ANCESTORS = ['https://textxt.com', 'https://textxt-14209.web.app'];
const REQUIRED_FIELDS = [
  'manifestVersion',
  'appId',
  'name',
  'description',
  'startUrl',
  'allowedOrigins',
  'permissions',
  'bridgeVersion',
  'visibility',
  'tags',
  'version',
  'privacyPolicyUrl',
  'supportUrl',
];
const KNOWN_FIELDS = new Set([
  ...REQUIRED_FIELDS,
  'iconUrl',
  'capabilities',
  'minimumHostVersion',
  'requiredHostCapabilities',
  'releaseNotes',
]);
const APP_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$/;
const TAG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const SEMVER_PATTERN =
  /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;

const args = process.argv.slice(2);
const allowLocalhost = args.includes('--local');
const checkRemote = args.includes('--remote');
const fileArg = args.find((arg) => !arg.startsWith('-'));

if (!fileArg || args.includes('--help') || args.includes('-h')) {
  console.log('Usage: node validate-manifest.mjs [--local] [--remote] <manifest.json>');
  console.log('  --local  Allow http://localhost and loopback URLs for local testing.');
  console.log('  --remote Fetch startUrl and check iframe-compatible response headers.');
  process.exit(fileArg ? 0 : 2);
}

const filePath = path.resolve(process.cwd(), fileArg);
const issues = [];
const add = (field, code, message) => issues.push({ field, code, message });
const isRecord = (value) => value && typeof value === 'object' && !Array.isArray(value);
const isLocal = (hostname) => {
  const value = hostname.toLowerCase();
  return value === 'localhost'
    || value === '127.0.0.1'
    || value === '0.0.0.0'
    || value === '[::1]'
    || value.endsWith('.localhost');
};
const parseUrl = (value, field) => {
  if (typeof value !== 'string' || !value.trim()) {
    add(field, 'url', `${field} must be a non-empty URL.`);
    return null;
  }
  try {
    const parsed = new URL(value.trim());
    const localHttp = allowLocalhost && parsed.protocol === 'http:' && isLocal(parsed.hostname);
    if (parsed.protocol !== 'https:' && !localHttp) {
      add(field, 'https_required', `${field} must use HTTPS.`);
      return null;
    }
    if (parsed.username || parsed.password) {
      add(field, 'credentials', `${field} cannot contain embedded credentials.`);
      return null;
    }
    if (parsed.hash) {
      add(field, 'fragment', `${field} cannot contain a URL fragment.`);
      return null;
    }
    return parsed;
  } catch {
    add(field, 'url', `${field} must be a valid URL.`);
    return null;
  }
};
const checkString = (manifest, field, min, max) => {
  const value = manifest[field];
  if (typeof value !== 'string') {
    add(field, 'type', `${field} must be a string.`);
    return '';
  }
  const trimmed = value.trim();
  if (trimmed.length < min) add(field, 'too_short', `${field} must contain at least ${min} characters.`);
  if (trimmed.length > max) add(field, 'too_long', `${field} must contain at most ${max} characters.`);
  return trimmed;
};
const checkList = (manifest, field, max, allowed) => {
  const value = manifest[field];
  if (!Array.isArray(value)) {
    add(field, 'type', `${field} must be an array.`);
    return [];
  }
  if (value.length > max) add(field, 'too_many', `${field} supports at most ${max} values.`);
  if (new Set(value).size !== value.length) add(field, 'duplicate', `${field} cannot contain duplicate values.`);
  value.forEach((item, index) => {
    if (typeof item !== 'string' || !item.trim()) {
      add(`${field}.${index}`, 'type', `${field}[${index}] must be a non-empty string.`);
    } else if (allowed && !allowed.includes(item)) {
      add(`${field}.${index}`, 'unsupported', `${item} is not supported.`);
    }
  });
  return value.filter((item) => typeof item === 'string').map((item) => item.trim());
};

let manifest;
try {
  manifest = JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (error) {
  console.error(JSON.stringify({
    ok: false,
    file: filePath,
    issues: [{
      field: '$',
      code: 'invalid_json',
      message: error instanceof Error ? error.message : 'Unable to read manifest JSON.',
    }],
  }, null, 2));
  process.exit(1);
}

if (!isRecord(manifest)) {
  add('$', 'type', 'Manifest must be a JSON object.');
} else {
  Object.keys(manifest).forEach((field) => {
    if (!KNOWN_FIELDS.has(field)) add(field, 'unknown_field', `${field} is not part of manifest version 1.`);
  });
  REQUIRED_FIELDS.forEach((field) => {
    if (typeof manifest[field] === 'undefined' || manifest[field] === null) {
      add(field, 'required', `${field} is required.`);
    }
  });
  if (manifest.manifestVersion !== 1) add('manifestVersion', 'unsupported_version', 'manifestVersion must be 1.');
  const appId = checkString(manifest, 'appId', 2, 64);
  if (appId && !APP_ID_PATTERN.test(appId)) add('appId', 'format', 'appId must use lowercase letters, numbers, and internal hyphens.');
  if (RESERVED_APP_IDS.includes(appId)) add('appId', 'reserved', 'This appId is reserved by Textxt.');
  checkString(manifest, 'name', 2, 60);
  checkString(manifest, 'description', 10, 500);
  const startUrl = parseUrl(manifest.startUrl, 'startUrl');
  if (Object.prototype.hasOwnProperty.call(manifest, 'iconUrl')) parseUrl(manifest.iconUrl, 'iconUrl');
  parseUrl(manifest.privacyPolicyUrl, 'privacyPolicyUrl');
  parseUrl(manifest.supportUrl, 'supportUrl');
  if (typeof manifest.releaseNotes !== 'undefined') checkString(manifest, 'releaseNotes', 0, 1000);
  const origins = checkList(manifest, 'allowedOrigins', 10);
  if (origins.length === 0) add('allowedOrigins', 'required', 'allowedOrigins must contain at least one origin.');
  const normalizedOrigins = [];
  origins.forEach((origin, index) => {
    const parsed = parseUrl(origin, `allowedOrigins.${index}`);
    if (!parsed) return;
    normalizedOrigins.push(parsed.origin);
    if (origin !== parsed.origin) add(`allowedOrigins.${index}`, 'origin_only', 'Allowed origins cannot contain a path.');
  });
  if (startUrl && !normalizedOrigins.includes(startUrl.origin)) {
    add('allowedOrigins', 'start_origin_missing', 'allowedOrigins must include the exact startUrl origin.');
  }
  checkList(manifest, 'permissions', 10, PERMISSIONS);
  checkList({ capabilities: manifest.capabilities ?? [] }, 'capabilities', 4, CAPABILITIES);
  if (manifest.bridgeVersion !== '1.0') add('bridgeVersion', 'unsupported_version', 'bridgeVersion must be 1.0.');
  if (typeof manifest.minimumHostVersion !== 'undefined') {
    const minimumHostVersion = checkString(manifest, 'minimumHostVersion', 1, 64);
    if (minimumHostVersion && !SEMVER_PATTERN.test(minimumHostVersion)) {
      add('minimumHostVersion', 'semver', 'minimumHostVersion must use semantic versioning.');
    }
  }
  const requiredHostCapabilities = checkList(
    { requiredHostCapabilities: manifest.requiredHostCapabilities ?? [] },
    'requiredHostCapabilities',
    12,
  );
  requiredHostCapabilities.forEach((capability, index) => {
    if (
      capability.length > 80
      || !/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/.test(capability)
    ) {
      add(
        `requiredHostCapabilities.${index}`,
        'format',
        'Host capabilities must use dotted lowercase names.',
      );
    }
  });
  if (!VISIBILITIES.includes(manifest.visibility)) add('visibility', 'unsupported', 'visibility must be private, shared, or public.');
  const tags = checkList(manifest, 'tags', 12);
  tags.forEach((tag, index) => {
    if (tag.length > 32 || !TAG_PATTERN.test(tag)) add(`tags.${index}`, 'format', 'Tags must be lowercase slugs up to 32 characters.');
  });
  const version = checkString(manifest, 'version', 1, 64);
  if (version && !SEMVER_PATTERN.test(version)) add('version', 'semver', 'version must use semantic versioning, for example 1.0.0.');
  if (startUrl && version) {
    let versionedPath = `${startUrl.pathname}${startUrl.search}`;
    try {
      versionedPath = decodeURIComponent(versionedPath);
    } catch {
      // Keep the encoded path if it contains malformed escape sequences.
    }
    const escapedVersion = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    if (!new RegExp(`(^|[^0-9])${escapedVersion}(?![0-9])`).test(versionedPath)) {
      add('startUrl', 'versioned_url_required', 'startUrl must include the exact release version in its path or query.');
    }
  }
}

let remote = null;
if (checkRemote && issues.length === 0) {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 10000);
  try {
    const response = await fetch(manifest.startUrl, {
      redirect: 'follow',
      signal: controller.signal,
      headers: { Accept: 'text/html,application/xhtml+xml' },
    });
    const contentType = response.headers.get('content-type') || '';
    const xFrameOptions = response.headers.get('x-frame-options') || '';
    const contentSecurityPolicy =
      response.headers.get('content-security-policy') || '';
    remote = {
      status: response.status,
      finalUrl: response.url,
      contentType,
      xFrameOptions: xFrameOptions || null,
      contentSecurityPolicy: contentSecurityPolicy || null,
    };
    if (!response.ok) {
      add('startUrl', 'remote_http', `startUrl returned HTTP ${response.status}.`);
    }
    if (new URL(response.url).origin !== new URL(manifest.startUrl).origin) {
      add('startUrl', 'cross_origin_redirect', 'startUrl redirects must remain on the declared origin.');
    }
    let finalVersionedPath = `${new URL(response.url).pathname}${new URL(response.url).search}`;
    try {
      finalVersionedPath = decodeURIComponent(finalVersionedPath);
    } catch {
      // Keep the encoded path if it contains malformed escape sequences.
    }
    const escapedVersion = manifest.version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    if (!new RegExp(`(^|[^0-9])${escapedVersion}(?![0-9])`).test(finalVersionedPath)) {
      add('startUrl', 'versioned_redirect_required', 'The final startUrl after redirects must retain the exact release version.');
    }
    if (!/^text\/html\b|^application\/xhtml\+xml\b/i.test(contentType)) {
      add('startUrl', 'remote_content_type', 'startUrl must return HTML content.');
    }
    if (/\bdeny\b|\bsameorigin\b/i.test(xFrameOptions)) {
      add(
        'startUrl',
        'frame_blocked',
        `X-Frame-Options ${xFrameOptions} prevents Textxt web from embedding this app.`,
      );
    }
    const frameAncestors = contentSecurityPolicy.match(
      /(?:^|;)\s*frame-ancestors\s+([^;]+)/i,
    )?.[1];
    if (!contentSecurityPolicy) {
      add('startUrl', 'csp_required', 'startUrl must send a Content-Security-Policy response header.');
    }
    if (!frameAncestors) {
      add('startUrl', 'frame_ancestors_required', 'CSP must declare frame-ancestors in the response header.');
    } else {
      const sources = frameAncestors.toLowerCase().split(/\s+/).filter(Boolean);
      if (sources.includes('*')) {
        add('startUrl', 'frame_ancestors_too_broad', 'CSP frame-ancestors cannot use a wildcard.');
      }
      const missingOrigins = REQUIRED_FRAME_ANCESTORS.filter(
        (origin) => !sources.includes(origin),
      );
      if (missingOrigins.length) {
        add(
          'startUrl',
          'frame_blocked',
          `CSP frame-ancestors must allow ${missingOrigins.join(' and ')}.`,
        );
      }
    }
    const defaultScriptSources = contentSecurityPolicy.match(
      /(?:^|;)\s*(?:script-src|default-src)\s+([^;]+)/i,
    )?.[1] || '';
    const elementScriptSources = contentSecurityPolicy.match(
      /(?:^|;)\s*script-src-elem\s+([^;]+)/i,
    )?.[1] || '';
    const executablePolicies = [defaultScriptSources, elementScriptSources].filter(Boolean);
    if (!defaultScriptSources) {
      add('startUrl', 'script_src_required', 'CSP must declare script-src or default-src.');
    }
    executablePolicies.forEach((scriptSources) => {
      if (/(?:^|\s)(?:\*|data:|blob:|filesystem:|https?:|'unsafe-eval'|'wasm-unsafe-eval'|'strict-dynamic')(?:\s|$)/i.test(scriptSources)) {
        add('startUrl', 'external_script_policy', 'CSP script policies cannot use wildcards, remote schemes, data/blob URLs, strict-dynamic, or unsafe evaluation.');
      }
      if (!/(?:^|\s)(?:'self'|'sha(?:256|384|512)-[^']+'|'nonce-[^']+')(?:\s|$)/i.test(scriptSources)) {
        add('startUrl', 'self_script_required', "Every CSP script policy must allow 'self', a hash, or a nonce.");
      }
    });
    const objectSources = contentSecurityPolicy.match(
      /(?:^|;)\s*object-src\s+([^;]+)/i,
    )?.[1]?.trim().toLowerCase() || '';
    if (objectSources !== "'none'") {
      add('startUrl', 'object_src_required', "CSP object-src must be 'none'.");
    }
  } catch (error) {
    add(
      'startUrl',
      'remote_unreachable',
      error instanceof Error
        ? `Unable to fetch startUrl: ${error.message}`
        : 'Unable to fetch startUrl.',
    );
  } finally {
    clearTimeout(timeout);
  }
}

const result = {
  ok: issues.length === 0,
  file: filePath,
  ...(remote ? { remote } : {}),
  issues,
};
console.log(JSON.stringify(result, null, 2));
process.exit(result.ok ? 0 : 1);
