import dataclasses
from typing import List

@dataclasses.dataclass(frozen=True, kw_only=True)
class FileTestVector(object):
    filename: str
    mimetype: str | None
    charset: str | None
    url: str | None
    must_include: List[str]
    must_not_include: List[str]

GENERAL_TEST_VECTORS = [
    FileTestVector(
        filename="test.docx",
        mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
        charset=None,
        url=None,
        must_include=[
            "314b0a30-5b04-470b-b9f7-eed2c2bec74a",
            "49e168b7-d2ae-407f-a055-2167576f39a1",
            "## d666f1f7-46cb-42bd-9a39-9a39cf2a509f",
            "# Abstract",
            "# Introduction",
            "AutoGen: Enabling Next-Gen LLM Applications via Multi-Agent Conversation",
            "data:image/png;base64...",
        ],
        must_not_include=[
            "data:image/png;base64,iVBORw0KGgoAAAANSU",
        ],
    ),
    FileTestVector(
        filename="test.xlsx",
        mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        charset=None,
        url=None,
        must_include=[
            "## 09060124-b5e7-4717-9d07-3c046eb",
            "6ff4173b-42a5-4784-9b19-f49caff4d93d",
            "affc7dad-52dc-4b98-9b5d-51e65d8a8ad0",
        ],
        must_not_include=[],
    ),
    FileTestVector(
        filename="test.xls",
        mimetype="application/vnd.ms-excel",
        charset=None,
        url=None,
        must_include=[
            "## 09060124-b5e7-4717-9d07-3c046eb",
            "6ff4173b-42a5-4784-9b19-f49caff4d93d",
            "affc7dad-52dc-4b98-9b5d-51e65d8a8ad0",
        ],
        must_not_include=[],
    ),
    FileTestVector(
        filename="test.pptx",
        mimetype="application/vnd.openxmlformats-officedocument.presentationml.presentation",
        charset=None,
        url=None,
        must_include=[
            "2cdda5c8-e50e-4db4-b5f0-9722a649f455",
            "04191ea8-5c73-4215-a1d3-1cfb43aaaf12",
            "44bf7d06-5e7a-4a40-a2e1-a2e42ef28c8a",
            "1b92870d-e3b5-4e65-8153-919f4ff45592",
            "AutoGen: Enabling Next-Gen LLM Applications via Multi-Agent Conversation",
            "a3f6004b-6f4f-4ea8-bee3-3741f4dc385f",  # chart title
            "2003",  # chart value
            "![This phrase of the caption is Human-written.](Picture4.jpg)",
        ],
        must_not_include=["data:image/jpeg;base64,/9j/4AAQSkZJRgABAQE"],
    ),
    FileTestVector(
        filename="test_outlook_msg.msg",
        mimetype="application/vnd.ms-outlook",
        charset=None,
        url=None,
        must_include=[
            "# Email Message",
            "**From:** test.sender@example.com",
            "**To:** test.recipient@example.com",
            "**Subject:** Test Email Message",
            "## Content",
            "This is the body of the test email message",
        ],
        must_not_include=[],
    ),
    FileTestVector(
        filename="test.pdf",
        mimetype="application/pdf",
        charset=None,
        url=None,
        must_include=[
            "While there is contemporaneous exploration of multi-agent approaches"
        ],
        must_not_include=[],
    ),
    FileTestVector(
        filename="test_blog.html",
        mimetype="text/html",
        charset="utf-8",
        url="https://microsoft.github.io/autogen/blog/2023/04/21/LLM-tuning-math",
        must_include=[
            "Large language models (LLMs) are powerful tools that can generate natural language texts for various applications, such as chatbots, summarization, translation, and more. GPT-4 is currently the state of the art LLM in the world. Is model selection irrelevant? What about inference parameters?",
            "an example where high cost can easily prevent a generic complex",
        ],
        must_not_include=[],
    ),
    FileTestVector(
        filename="test_wikipedia.html",
        mimetype="text/html",
        charset="utf-8",
        url="https://en.wikipedia.org/wiki/Microsoft",
        must_include=[
            "Microsoft entered the operating system (OS) business in 1980 with its own version of [Unix]",
            'Microsoft was founded by [Bill Gates](/wiki/Bill_Gates "Bill Gates")',
        ],
        must_not_include=[
            "You are encouraged to create an account and log in",
            "154 languages",
            "move to sidebar",
        ],
    ),
    FileTestVector(
        filename="test_serp.html",
        mimetype="text/html",
        charset="utf-8",
        url="https://www.bing.com/search?q=microsoft+wikipedia",
        must_include=[
            "](https://en.wikipedia.org/wiki/Microsoft",
            "Microsoft Corporation is **an American multinational corporation and technology company headquartered** in Redmond",
            "1995–2007: Foray into the Web, Windows 95, Windows XP, and Xbox",
        ],
        must_not_include=[
            "https://www.bing.com/ck/a?!&&p=",
            "data:image/svg+xml,%3Csvg%20width%3D",
        ],
    ),
    FileTestVector(
        filename="test_mskanji.csv",
        mimetype="text/csv",
        charset="cp932",
        url=None,
        must_include=[
            "| 名前 | 年齢 | 住所 |",
            "| --- | --- | --- |",
            "| 佐藤太郎 | 30 | 東京 |",
            "| 三木英子 | 25 | 大阪 |",
            "| 髙橋淳 | 35 | 名古屋 |",
        ],
        must_not_include=[],
    ),
    FileTestVector(
        filename="test.json",
        mimetype="application/json",
        charset="ascii",
        url=None,
        must_include=[
            "5b64c88c-b3c3-4510-bcb8-da0b200602d8",
            "9700dc99-6685-40b4-9a3a-5e406dcb37f3",
        ],
        must_not_include=[],
    ),
    FileTestVector(
        filename="test_rss.xml",
        mimetype="text/xml",
        charset="utf-8",
        url=None,
        must_include=[
            "# The Official Microsoft Blog",
            "## Ignite 2024: Why nearly 70% of the Fortune 500 now use Microsoft 365 Copilot",
            "In the case of AI, it is absolutely true that the industry is moving incredibly fast",
        ],
        must_not_include=["<rss", "<feed"],
    ),
    FileTestVector(
        filename="test_notebook.ipynb",
        mimetype="application/json",
        charset="ascii",
        url=None,
        must_include=[
            "# Test Notebook",
            "```python",
            'print("markitdown")',
            "```",
            "## Code Cell Below",
        ],
        must_not_include=[
            "nbformat",
            "nbformat_minor",
        ],
    ),
    FileTestVector(
        filename="test_files.zip",
        mimetype="application/zip",
        charset=None,
        url=None,
        must_include=[
            "314b0a30-5b04-470b-b9f7-eed2c2bec74a",
            "49e168b7-d2ae-407f-a055-2167576f39a1",
            "## d666f1f7-46cb-42bd-9a39-9a39cf2a509f",
            "# Abstract",
            "# Introduction",
            "AutoGen: Enabling Next-Gen LLM Applications via Multi-Agent Conversation",
            "2cdda5c8-e50e-4db4-b5f0-9722a649f455",
            "04191ea8-5c73-4215-a1d3-1cfb43aaaf12",
            "44bf7d06-5e7a-4a40-a2e1-a2e42ef28c8a",
            "1b92870d-e3b5-4e65-8153-919f4ff45592",
            "## 09060124-b5e7-4717-9d07-3c046eb",
            "6ff4173b-42a5-4784-9b19-f49caff4d93d",
            "affc7dad-52dc-4b98-9b5d-51e65d8a8ad0",
            "Microsoft entered the operating system (OS) business in 1980 with its own version of [Unix]",
            'Microsoft was founded by [Bill Gates](/wiki/Bill_Gates "Bill Gates")',
        ],
        must_not_include=[],
    ),
    FileTestVector(
        filename="test.epub",
        mimetype="application/epub+zip",
        charset=None,
        url=None,
        must_include=[
            "**Authors:** Test Author",
            "A test EPUB document for MarkItDown testing",
            "# Chapter 1: Test Content",
            "This is a **test** paragraph with some formatting",
            "* A bullet point",
            "* Another point",
            "# Chapter 2: More Content",
            "*different* style",
            "> This is a blockquote for testing",
        ],
        must_not_include=[],
    ),
]

DATA_URI_TEST_VECTORS = [
    FileTestVector(
        filename="test.docx",
        mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
        charset=None,
        url=None,
        must_include=[
            "314b0a30-5b04-470b-b9f7-eed2c2bec74a",
            "49e168b7-d2ae-407f-a055-2167576f39a1",
            "## d666f1f7-46cb-42bd-9a39-9a39cf2a509f",
            "# Abstract",
            "# Introduction",
            "AutoGen: Enabling Next-Gen LLM Applications via Multi-Agent Conversation",
            "data:image/png;base64,iVBORw0KGgoAAAANSU",
        ],
        must_not_include=[
            "data:image/png;base64...",
        ],
    ),
    FileTestVector(
        filename="test.pptx",
        mimetype="application/vnd.openxmlformats-officedocument.presentationml.presentation",
        charset=None,
        url=None,
        must_include=[
            "2cdda5c8-e50e-4db4-b5f0-9722a649f455",
            "04191ea8-5c73-4215-a1d3-1cfb43aaaf12",
            "44bf7d06-5e7a-4a40-a2e1-a2e42ef28c8a",
            "1b92870d-e3b5-4e65-8153-919f4ff45592",
            "AutoGen: Enabling Next-Gen LLM Applications via Multi-Agent Conversation",
            "a3f6004b-6f4f-4ea8-bee3-3741f4dc385f",  # chart title
            "2003",  # chart value
            "![This phrase of the caption is Human-written.]",  # image caption
            "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQE",
        ],
        must_not_include=[
            "![This phrase of the caption is Human-written.](Picture4.jpg)",
        ],
    ),
]


#!/usr/bin/env node
// ponytail — Claude Code SessionStart activation hook
//
// Runs on every session start:
//   1. Writes flag file at $CLAUDE_CONFIG_DIR/.ponytail-active (defaults to ~/.claude; statusline reads this)
//   2. Emits ponytail ruleset as hidden SessionStart context
//   3. Detects missing statusline config and emits setup nudge

const fs = require('fs');
const path = require('path');
const { getDefaultMode, getClaudeDir, isShellSafe } = require('./ponytail-config');
const { getPonytailInstructions } = require('./ponytail-instructions');
const {
  clearMode,
  isCodex,
  isCopilot,
  setMode,
  writeHookOutput,
} = require('./ponytail-runtime');

const claudeDir = getClaudeDir();
const settingsPath = path.join(claudeDir, 'settings.json');

const mode = getDefaultMode();

// "off" mode — skip activation entirely, don't write flag or emit rules
if (mode === 'off') {
  clearMode();
  const hookOutput = (isCodex || isCopilot) ? '' : 'OK';
  writeHookOutput('SessionStart', 'off', hookOutput);
  process.exit(0);
}

// 1. Write flag file
try {
  setMode(mode);
} catch (e) {
  // Silent fail -- flag is best-effort, don't block the hook
}

// 2. Emit the ponytail ruleset, filtered to the active intensity level.
let output = getPonytailInstructions(mode);

// 3. Detect missing statusline config — nudge Claude to help set it up
if (!isCodex && !isCopilot) try {
  let hasStatusline = false;
  if (fs.existsSync(settingsPath)) {
    // Strip UTF-8 BOM some editors prepend on Windows (breaks JSON.parse)
    const raw = fs.readFileSync(settingsPath, 'utf8').replace(/^\uFEFF/, '');
    const settings = JSON.parse(raw);
    if (settings.statusLine) {
      hasStatusline = true;
    }
  }

  // Nudge at most once — the flag file marks that the user has already seen
  // (and implicitly declined) the statusline setup offer. Repeating it every
  // session start turns a helpful hint into a nag.
  const nudgeFlagPath = path.join(claudeDir, '.ponytail-statusline-nudged');
  if (!hasStatusline && !fs.existsSync(nudgeFlagPath)) {
    try { fs.writeFileSync(nudgeFlagPath, ''); } catch (e) { /* best-effort */ }
    const isWindows = process.platform === 'win32';
    const scriptName = isWindows ? 'ponytail-statusline.ps1' : 'ponytail-statusline.sh';
    const scriptPath = path.join(__dirname, scriptName);
    if (isShellSafe(scriptPath)) {
      const command = isWindows
        ? `powershell -ExecutionPolicy Bypass -File "${scriptPath}"`
        : `bash "${scriptPath}"`;
      const statusLineSnippet =
        '"statusLine": { "type": "command", "command": ' + JSON.stringify(command) + ' }';
      output += "\n\n" +
        "STATUSLINE SETUP NEEDED: The ponytail plugin includes a statusline badge showing active mode " +
        "(e.g. [PONYTAIL], [PONYTAIL:ULTRA]). It is not configured yet. " +
        "To enable, add this to " + settingsPath + ": " +
        statusLineSnippet + " " +
        "Proactively offer to set this up for the user on first interaction.";
    } else {
      // ponytail: install path has shell metacharacters — don't embed it in a
      // command snippet; have the agent wire it up by hand instead.
      output += "\n\n" +
        "STATUSLINE SETUP NEEDED: The ponytail plugin includes a statusline badge showing active mode. " +
        "Its install path contains characters unsafe to embed in a shell command, so configure it manually: " +
        "add a statusLine command of type \"command\" that runs " + scriptName +
        " from the plugin's hooks directory to " + settingsPath + ", quoting/escaping the path for your shell. " +
        "Proactively offer to set this up for the user on first interaction.";
    }
  }
} catch (e) {
  // Silent fail — don't block session start over statusline detection
}

try {
  writeHookOutput('SessionStart', mode, output);
} catch (e) {
  // Silent fail — stdout closed/EPIPE at hook exit must not surface as a hook failure
}


import io
import warnings
from typing import Any, BinaryIO, Optional
from bs4 import BeautifulSoup

from .._base_converter import DocumentConverter, DocumentConverterResult
from .._stream_info import StreamInfo
from ._markdownify import _CustomMarkdownify

ACCEPTED_MIME_TYPE_PREFIXES = [
    "text/html",
    "application/xhtml",
]

ACCEPTED_FILE_EXTENSIONS = [
    ".html",
    ".htm",
]

class HtmlConverter(DocumentConverter):
    """Anything with content type text/html"""

    def accepts(
        self,
        file_stream: BinaryIO,
        stream_info: StreamInfo,
        **kwargs: Any,  # Options to pass to the converter
    ) -> bool:
        mimetype = (stream_info.mimetype or "").lower()
        extension = (stream_info.extension or "").lower()

        if extension in ACCEPTED_FILE_EXTENSIONS:
            return True

        for prefix in ACCEPTED_MIME_TYPE_PREFIXES:
            if mimetype.startswith(prefix):
                return True

        return False

    def convert(
        self,
        file_stream: BinaryIO,
        stream_info: StreamInfo,
        **kwargs: Any,  # Options to pass to the converter
    ) -> DocumentConverterResult:
        # Pop our own keyword before forwarding the rest to markdownify.
        # strict=True raises RecursionError instead of falling back to plain text.
        strict: bool = kwargs.pop("strict", False)

        # Parse the stream
        encoding = "utf-8" if stream_info.charset is None else stream_info.charset
        soup = BeautifulSoup(file_stream, "html.parser", from_encoding=encoding)

        # Remove javascript and style blocks
        for script in soup(["script", "style"]):
            script.extract()

        # Print only the main content
        body_elm = soup.find("body")
        webpage_text = ""
        try:
            if body_elm:
                webpage_text = _CustomMarkdownify(**kwargs).convert_soup(body_elm)
            else:
                webpage_text = _CustomMarkdownify(**kwargs).convert_soup(soup)
        except RecursionError:
            if strict:
                raise
            # Large or deeply-nested HTML can exceed Python's recursion limit
            # during markdownify's recursive DOM traversal.  Fall back to
            # BeautifulSoup's iterative get_text() so the caller still gets
            # usable plain-text content instead of raw HTML.
            warnings.warn(
                "HTML document is too deeply nested for markdown conversion "
                "(RecursionError). Falling back to plain-text extraction.",
                stacklevel=2,
            )
            target = body_elm if body_elm else soup
            webpage_text = target.get_text("\n", strip=True)

        assert isinstance(webpage_text, str)

        # remove leading and trailing \n
        webpage_text = webpage_text.strip()

        return DocumentConverterResult(
            markdown=webpage_text,
            title=None if soup.title is None else soup.title.string,
        )

    def convert_string(
        self, html_content: str, *, url: Optional[str] = None, **kwargs
    ) -> DocumentConverterResult:
        """
        Non-standard convenience method to convert a string to markdown.
        Given that many converters produce HTML as intermediate output, this
        allows for easy conversion of HTML to markdown.
        """
        return self.convert(
            file_stream=io.BytesIO(html_content.encode("utf-8")),
            stream_info=StreamInfo(
                mimetype="text/html",
                extension=".html",
                charset="utf-8",
                url=url,
            ),
            **kwargs,
        )


//! llama.cpp structured output coverage, including the migrated example path.

use rig::completion::{Prompt, TypedPrompt};
use rig::prelude::*;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use crate::support::{
    STRUCTURED_OUTPUT_PROMPT, SmokeStructuredOutput, assert_contains_any_case_insensitive,
    assert_nonempty_response, assert_smoke_structured_output,
};

use super::support;

const WEATHER_PREAMBLE: &str = "You are a helpful weather assistant. Return ONLY JSON matching exactly this schema: \
     {\"city\": string, \"current\": {\"temperature_f\": number, \"humidity_pct\": integer, \"description\": string}}. \
     Every field is required. Use Fahrenheit for temperature_f. Do not omit keys. Do not wrap the JSON in markdown.";

#[derive(Debug, Deserialize, JsonSchema, Serialize)]
struct Conditions {
    temperature_f: f64,
    humidity_pct: u8,
    description: String,
}

#[derive(Debug, Deserialize, JsonSchema, Serialize)]
struct WeatherForecast {
    city: String,
    current: Conditions,
}

fn assert_weather_forecast(forecast: &WeatherForecast, expected_city: &[&str]) {
    assert_nonempty_response(&forecast.city);
    assert_contains_any_case_insensitive(&forecast.city, expected_city);
    assert_nonempty_response(&forecast.current.description);
    assert!(
        forecast.current.temperature_f.is_finite(),
        "temperature should be finite"
    );
    assert!(
        (-100.0..=150.0).contains(&forecast.current.temperature_f),
        "temperature should be in a plausible Fahrenheit range, got {}",
        forecast.current.temperature_f
    );
    assert!(
        forecast.current.humidity_pct <= 100,
        "humidity should be within 0..=100, got {}",
        forecast.current.humidity_pct
    );
}

#[tokio::test]
#[ignore = "requires a local llama.cpp OpenAI-compatible server"]
async fn structured_output_smoke() {
    let client = support::completions_client();
    let agent = client.agent(support::model_name()).build();

    let response: SmokeStructuredOutput = agent
        .prompt_typed(STRUCTURED_OUTPUT_PROMPT)
        .await
        .expect("structured output prompt should succeed");

    assert_smoke_structured_output(&response);
}

#[tokio::test]
#[ignore = "requires a local llama.cpp OpenAI-compatible server"]
async fn prompt_typed_structured_output() {
    let client = support::completions_client();
    let model = support::model_name();
    let agent = client
        .agent(model)
        .preamble(WEATHER_PREAMBLE)
        .temperature(0.0)
        .build();

    let forecast: WeatherForecast = agent
        .prompt_typed(
            "Return JSON weather data for New York City today with fields city, current.temperature_f, current.humidity_pct, and current.description.",
        )
        .await
        .expect("prompt_typed should succeed");
    assert_weather_forecast(&forecast, &["new york", "nyc"]);
}

#[tokio::test]
#[ignore = "requires a local llama.cpp OpenAI-compatible server"]
async fn prompt_typed_extended_details_structured_output() {
    let client = support::completions_client();
    let model = support::model_name();
    let agent = client
        .agent(model)
        .preamble(WEATHER_PREAMBLE)
        .temperature(0.0)
        .build();

    let extended = agent
        .prompt_typed::<WeatherForecast>(
            "Return JSON weather data for Los Angeles with fields city, current.temperature_f, current.humidity_pct, and current.description.",
        )
        .extended_details()
        .await
        .expect("extended prompt_typed should succeed");
    assert_weather_forecast(&extended.output, &["los angeles", "la"]);
    assert!(extended.usage.total_tokens > 0, "usage should be populated");
}

#[tokio::test]
#[ignore = "requires a local llama.cpp OpenAI-compatible server"]
async fn output_schema_structured_output() {
    let client = support::completions_client();
    let model = support::model_name();
    let agent_with_schema = client
        .agent(model)
        .preamble(WEATHER_PREAMBLE)
        .temperature(0.0)
        .output_schema::<WeatherForecast>()
        .build();
    let response = agent_with_schema
        .prompt(
            "Return JSON weather data for Chicago with fields city, current.temperature_f, current.humidity_pct, and current.description.",
        )
        .await
        .expect("output schema prompt should succeed");
    let parsed: WeatherForecast =
        serde_json::from_str(&response).expect("schema response should deserialize");
    assert_weather_forecast(&parsed, &["chicago"]);
}


// End-to-end: dry-run installer prints expected file plan without touching disk.

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const INSTALLER = path.resolve(HERE, '..', '..', 'bin', 'install.js');

function freshTmpDir() {
  return fs.mkdtempSync(path.join(os.tmpdir(), 'cm-dryrun-'));
}

test('dry-run --only claude prints plan and writes nothing', () => {
  const cfg = freshTmpDir();
  const r = spawnSync('node', [INSTALLER,
    // --with-hooks: since #392/#393 the default only wires standalone hooks
    // when the plugin install fails. Force the hook-planning path so the
    // "would install / would merge" assertions below are exercised.
    '--dry-run', '--only', 'claude', '--with-hooks', '--no-mcp-shrink', '--non-interactive',
    '--config-dir', cfg,
  ], { encoding: 'utf8', env: { ...process.env, CLAUDE_CONFIG_DIR: cfg } });
  assert.equal(r.status, 0);
  // Only fires if `claude` is on PATH on the test runner. If not, this assertion
  // is a no-op (the installer just prints "nothing detected" and exits 0).
  if (/Claude Code detected/.test(r.stdout)) {
    assert.match(r.stdout, /would run: claude plugin marketplace add/);
    assert.match(r.stdout, /would run: claude plugin install caveman@caveman/);
    assert.match(r.stdout, /would mkdir -p .*\/hooks/);
    assert.match(r.stdout, /would install .*caveman-activate\.js/);
    assert.match(r.stdout, /would merge SessionStart \+ UserPromptSubmit \+ statusline/);
  }
  // Nothing should have been written.
  assert.equal(fs.existsSync(path.join(cfg, 'settings.json')), false);
  assert.equal(fs.existsSync(path.join(cfg, 'hooks')), false);
});

test('dry-run --uninstall does not delete files', () => {
  const cfg = freshTmpDir();
  // Seed a fake installation
  fs.mkdirSync(path.join(cfg, 'hooks'), { recursive: true });
  const fake = path.join(cfg, 'hooks', 'caveman-activate.js');
  fs.writeFileSync(fake, '// fake');
  fs.writeFileSync(path.join(cfg, 'settings.json'),
    JSON.stringify({ hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'node ' + fake }] }] } }, null, 2));
  const before = fs.readFileSync(path.join(cfg, 'settings.json'), 'utf8');

  const r = spawnSync('node', [INSTALLER, '--uninstall', '--dry-run', '--non-interactive', '--config-dir', cfg],
    { encoding: 'utf8', env: { ...process.env, CLAUDE_CONFIG_DIR: cfg } });
  assert.equal(r.status, 0);

  // File still present, settings unchanged.
  assert.equal(fs.existsSync(fake), true);
  assert.equal(fs.readFileSync(path.join(cfg, 'settings.json'), 'utf8'), before);
});


package config

import (
	"bytes"
	"encoding/json"
	"errors"
	"testing"

	yaml "sigs.k8s.io/yaml/goyaml.v3"
)

func TestExpressionOrListMarshalJSON(t *testing.T) {
	for _, tt := range []struct {
		err    error
		input  *ExpressionOrList
		name   string
		output []byte
	}{
		{
			name: "single expression",
			input: &ExpressionOrList{
				Expression: "true",
			},
			output: []byte(`"true"`),
			err:    nil,
		},
		{
			name: "all",
			input: &ExpressionOrList{
				All: []string{"true", "true"},
			},
			output: []byte(`{"all":["true","true"]}`),
			err:    nil,
		},
		{
			name: "all one",
			input: &ExpressionOrList{
				All: []string{"true"},
			},
			output: []byte(`"true"`),
			err:    nil,
		},
		{
			name: "any",
			input: &ExpressionOrList{
				Any: []string{"true", "false"},
			},
			output: []byte(`{"any":["true","false"]}`),
			err:    nil,
		},
		{
			name: "any one",
			input: &ExpressionOrList{
				Any: []string{"true"},
			},
			output: []byte(`"true"`),
			err:    nil,
		},
	} {
		t.Run(tt.name, func(t *testing.T) {
			result, err := json.Marshal(tt.input)
			if !errors.Is(err, tt.err) {
				t.Errorf("wanted marshal error: %v but got: %v", tt.err, err)
			}

			if !bytes.Equal(result, tt.output) {
				t.Logf("wanted: %s", string(tt.output))
				t.Logf("got:    %s", string(result))
				t.Error("mismatched output")
			}
		})
	}
}

func TestExpressionOrListMarshalYAML(t *testing.T) {
	for _, tt := range []struct {
		err    error
		input  *ExpressionOrList
		name   string
		output []byte
	}{
		{
			name: "single expression",
			input: &ExpressionOrList{
				Expression: "true",
			},
			output: []byte(`"true"`),
			err:    nil,
		},
		{
			name: "all",
			input: &ExpressionOrList{
				All: []string{"true", "true"},
			},
			output: []byte(`all:
    - "true"
    - "true"`),
			err: nil,
		},
		{
			name: "all one",
			input: &ExpressionOrList{
				All: []string{"true"},
			},
			output: []byte(`"true"`),
			err:    nil,
		},
		{
			name: "any",
			input: &ExpressionOrList{
				Any: []string{"true", "false"},
			},
			output: []byte(`any:
    - "true"
    - "false"`),
			err: nil,
		},
		{
			name: "any one",
			input: &ExpressionOrList{
				Any: []string{"true"},
			},
			output: []byte(`"true"`),
			err:    nil,
		},
	} {
		t.Run(tt.name, func(t *testing.T) {
			result, err := yaml.Marshal(tt.input)
			if !errors.Is(err, tt.err) {
				t.Errorf("wanted marshal error: %v but got: %v", tt.err, err)
			}

			result = bytes.TrimSpace(result)

			if !bytes.Equal(result, tt.output) {
				t.Logf("wanted: %q", string(tt.output))
				t.Logf("got:    %q", string(result))
				t.Error("mismatched output")
			}
		})
	}
}

func TestExpressionOrListUnmarshalJSON(t *testing.T) {
	for _, tt := range []struct {
		err      error
		validErr error
		result   *ExpressionOrList
		name     string
		inp      string
	}{
		{
			name: "simple",
			inp:  `"\"User-Agent\" in headers"`,
			result: &ExpressionOrList{
				Expression: `"User-Agent" in headers`,
			},
		},
		{
			name: "object-and",
			inp: `{
			"all": ["\"User-Agent\" in headers"]
			}`,
			result: &ExpressionOrList{
				All: []string{
					`"User-Agent" in headers`,
				},
			},
		},
		{
			name: "object-or",
			inp: `{
			"any": ["\"User-Agent\" in headers"]
			}`,
			result: &ExpressionOrList{
				Any: []string{
					`"User-Agent" in headers`,
				},
			},
		},
		{
			name: "both-or-and",
			inp: `{
			"all": ["\"User-Agent\" in headers"],
			"any": ["\"User-Agent\" in headers"]
			}`,
			validErr: ErrExpressionCantHaveBoth,
		},
		{
			name: "expression-empty",
			inp: `{
			"any": []
			}`,
			validErr: ErrExpressionEmpty,
		},
	} {
		t.Run(tt.name, func(t *testing.T) {
			var eol ExpressionOrList

			if err := json.Unmarshal([]byte(tt.inp), &eol); !errors.Is(err, tt.err) {
				t.Errorf("wanted unmarshal error: %v but got: %v", tt.err, err)
			}

			if tt.result != nil && !eol.Equal(tt.result) {
				t.Logf("wanted: %#v", tt.result)
				t.Logf("got:    %#v", &eol)
				t.Fatal("parsed expression is not what was expected")
			}

			if err := eol.Valid(); !errors.Is(err, tt.validErr) {
				t.Errorf("wanted validation error: %v but got: %v", tt.err, err)
			}
		})
	}
}

func TestExpressionOrListString(t *testing.T) {
	for _, tt := range []struct {
		name string
		out  string
		in   ExpressionOrList
	}{
		{
			name: "single expression",
			in: ExpressionOrList{
				Expression: "true",
			},
			out: "true",
		},
		{
			name: "all",
			in: ExpressionOrList{
				All: []string{"true"},
			},
			out: "( true )",
		},
		{
			name: "all with &&",
			in: ExpressionOrList{
				All: []string{"true", "true"},
			},
			out: "( true ) && ( true )",
		},
		{
			name: "any",
			in: ExpressionOrList{
				All: []string{"true"},
			},
			out: "( true )",
		},
		{
			name: "any with ||",
			in: ExpressionOrList{
				Any: []string{"true", "true"},
			},
			out: "( true ) || ( true )",
		},
	} {
		t.Run(tt.name, func(t *testing.T) {
			result := tt.in.String()
			if result != tt.out {
				t.Errorf("wanted %q, got: %q", tt.out, result)
			}
		})
	}
}


import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
export const SITE = 'amazon';
export const DOMAIN = 'amazon.com';
export const HOME_URL = 'https://www.amazon.com/';
export const BESTSELLERS_URL = 'https://www.amazon.com/Best-Sellers/zgbs';
export const NEW_RELEASES_URL = 'https://www.amazon.com/gp/new-releases';
export const MOVERS_SHAKERS_URL = 'https://www.amazon.com/gp/movers-and-shakers';
export const SEARCH_URL_PREFIX = 'https://www.amazon.com/s?k=';
export const PRODUCT_URL_PREFIX = 'https://www.amazon.com/dp/';
export const DISCUSSION_URL_PREFIX = 'https://www.amazon.com/product-reviews/';
export const STRATEGY = 'cookie';
export const PRIMARY_PRICE_SELECTORS = [
    '#corePrice_feature_div .a-offscreen',
    '#corePriceDisplay_desktop_feature_div .a-offscreen',
    '#corePrice_desktop .a-offscreen',
    '#apex_desktop .a-offscreen',
    '#newAccordionRow_0 .a-offscreen',
    '#price_inside_buybox',
    '#priceblock_ourprice',
    '#priceblock_dealprice',
    '#tp_price_block_total_price_ww',
];
const ROBOT_TEXT_PATTERNS = [
    'Sorry, we just need to make sure you\'re not a robot',
    'Enter the characters you see below',
    'Type the characters you see in this image',
    'To discuss automated access to Amazon data please contact',
];
const AMAZON_RANKING_SPECS = {
    bestsellers: {
        commandName: 'bestsellers',
        rootUrl: BESTSELLERS_URL,
        pathPattern: /(?:^|\/)zgbs(?:\/|$)/i,
        invalidInputMessage: 'amazon bestsellers expects a best sellers URL or /zgbs path',
        invalidInputHint: 'Example: opencli amazon bestsellers https://www.amazon.com/Best-Sellers/zgbs',
    },
    new_releases: {
        commandName: 'new-releases',
        rootUrl: NEW_RELEASES_URL,
        pathPattern: /\/gp\/new-releases(?:\/|$)/i,
        invalidInputMessage: 'amazon new-releases expects a new releases URL or /gp/new-releases path',
        invalidInputHint: 'Example: opencli amazon new-releases https://www.amazon.com/gp/new-releases',
    },
    movers_shakers: {
        commandName: 'movers-shakers',
        rootUrl: MOVERS_SHAKERS_URL,
        pathPattern: /\/gp\/movers-and-shakers(?:\/|$)/i,
        invalidInputMessage: 'amazon movers-shakers expects a movers-and-shakers URL or /gp/movers-and-shakers path',
        invalidInputHint: 'Example: opencli amazon movers-shakers https://www.amazon.com/gp/movers-and-shakers',
    },
};
export function cleanText(value) {
    return typeof value === 'string'
        ? value.replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim()
        : '';
}
export function cleanMultilineText(value) {
    return typeof value === 'string'
        ? value
            .replace(/\u00a0/g, ' ')
            .split('\n')
            .map((line) => line.replace(/\s+/g, ' ').trim())
            .filter(Boolean)
            .join('\n')
        : '';
}
export function uniqueNonEmpty(values) {
    return [...new Set(values.map((value) => cleanText(value)).filter(Boolean))];
}
export function buildProvenance(sourceUrl) {
    return {
        source_url: sourceUrl,
        fetched_at: new Date().toISOString(),
        strategy: STRATEGY,
    };
}
export function buildSearchUrl(query) {
    const normalized = cleanText(query);
    if (!normalized) {
        throw new ArgumentError('amazon search query cannot be empty');
    }
    return `${SEARCH_URL_PREFIX}${encodeURIComponent(normalized)}`;
}
export function extractAsin(input) {
    const normalized = cleanText(input);
    if (!normalized)
        return null;
    if (/^[A-Z0-9]{10}$/i.test(normalized)) {
        return normalized.toUpperCase();
    }
    const match = normalized.match(/\/(?:dp|gp\/product|product-reviews)\/([A-Z0-9]{10})/i);
    return match ? match[1].toUpperCase() : null;
}
export function buildProductUrl(input) {
    const asin = extractAsin(input);
    if (!asin) {
        throw new ArgumentError('amazon product expects an ASIN or product URL', 'Example: opencli amazon product B0FJS72893');
    }
    return `${PRODUCT_URL_PREFIX}${asin}`;
}
export function buildDiscussionUrl(input) {
    const asin = extractAsin(input);
    if (!asin) {
        throw new ArgumentError('amazon discussion expects an ASIN or product URL', 'Example: opencli amazon discussion B0FJS72893');
    }
    return `${DISCUSSION_URL_PREFIX}${asin}`;
}
function getRankingSpec(listType) {
    return AMAZON_RANKING_SPECS[listType];
}
export function isSupportedRankingPath(listType, inputUrl) {
    try {
        const url = new URL(inputUrl);
        return getRankingSpec(listType).pathPattern.test(url.pathname);
    }
    catch {
        return false;
    }
}
export function resolveRankingUrl(listType, input) {
    const spec = getRankingSpec(listType);
    const normalized = cleanText(input);
    if (!normalized || normalized === 'root')
        return spec.rootUrl;
    let candidateUrl;
    if (normalized.startsWith('/')) {
        candidateUrl = new URL(normalized, HOME_URL).toString();
    }
    else if (/^https?:\/\//i.test(normalized)) {
        candidateUrl = canonicalizeAmazonUrl(normalized);
    }
    else if (normalized.includes('amazon.') && normalized.includes('/')) {
        candidateUrl = canonicalizeAmazonUrl(`https://${normalized.replace(/^\/+/, '')}`);
    }
    else {
        throw new ArgumentError(spec.invalidInputMessage, spec.invalidInputHint);
    }
    if (!isSupportedRankingPath(listType, candidateUrl)) {
        throw new ArgumentError(spec.invalidInputMessage, spec.invalidInputHint);
    }
    return normalizeRankingInputUrl(candidateUrl);
}
function normalizeRankingInputUrl(inputUrl) {
    try {
        const url = new URL(inputUrl);
        const normalizedPathSegments = url.pathname
            .split('/')
            .filter(Boolean)
            .filter((segment) => !/^ref=/i.test(segment));
        url.pathname = `/${normalizedPathSegments.join('/')}`;
        url.hash = '';
        // Ranking pages are frequently shared with tracking refs that can land on unstable variants.
        // Dropping ref keeps the canonical ranking path while preserving useful params (for example pg=2).
        url.searchParams.delete('ref');
        return url.toString();
    }
    catch {
        return inputUrl;
    }
}
export function isRankingPaginationUrl(listType, inputUrl) {
    const absolute = toAbsoluteAmazonUrl(inputUrl);
    if (!absolute || !isSupportedRankingPath(listType, absolute))
        return false;
    try {
        const url = new URL(absolute);
        const ref = cleanText(url.searchParams.get('ref')).toLowerCase();
        // pg= query param is the most reliable pagination indicator across all ranking lists
        return url.searchParams.has('pg')
            || /(?:^|_)pg(?:_|$)/.test(ref)
            // Amazon ranking pagination refs: zg_bs_pg_ (bestsellers), zg_bsnr_pg_ (new releases), zg_bsms_pg_ (movers & shakers)
            || /zg_bs(?:nr|ms)?_pg_/.test(ref);
    }
    catch {
        return false;
    }
}
export function extractCategoryNodeId(inputUrl) {
    const absolute = toAbsoluteAmazonUrl(inputUrl);
    if (!absolute)
        return null;
    try {
        const url = new URL(absolute);
        for (const key of ['node', 'nodeid', 'nodeId', 'browseNode']) {
            const value = cleanText(url.searchParams.get(key));
            if (/^\d{4,}$/.test(value))
                return value;
        }
        const rhValue = cleanText(url.searchParams.get('rh'));
        const rhMatch = decodeURIComponent(rhValue).match(/(?:^|,)\s*n:(\d{4,})(?:,|$)/i);
        if (rhMatch)
            return rhMatch[1];
        const pathMatches = [...url.pathname.matchAll(/\/(\d{4,})(?=\/|$)/g)];
        if (pathMatches.length > 0) {
            return pathMatches[pathMatches.length - 1][1];
        }
    }
    catch {
        return null;
    }
    return null;
}
export function resolveBestsellersUrl(input) {
    return resolveRankingUrl('bestsellers', input);
}
export function canonicalizeAmazonUrl(input) {
    try {
        const url = new URL(input);
        if (!url.hostname.endsWith(DOMAIN)) {
            throw new Error('not-amazon');
        }
        return url.toString();
    }
    catch {
        throw new ArgumentError('Invalid Amazon URL');
    }
}
export function toAbsoluteAmazonUrl(value) {
    const normalized = cleanText(value);
    if (!normalized)
        return null;
    try {
        return new URL(normalized, HOME_URL).toString();
    }
    catch {
        return null;
    }
}
export function normalizeProductUrl(value) {
    const normalized = cleanText(value);
    const asin = extractAsin(normalized);
    if (asin)
        return buildProductUrl(asin);
    return toAbsoluteAmazonUrl(normalized);
}
export function parsePriceText(text) {
    const normalized = cleanText(text);
    const match = normalized.match(/([$€£])\s*(\d+(?:,\d{3})*(?:\.\d+)?)/);
    if (!match) {
        return {
            price_text: normalized || null,
            price_value: null,
            currency: null,
        };
    }
    const currencyMap = {
        '$': 'USD',
        '€': 'EUR',
        '£': 'GBP',
    };
    return {
        price_text: `${match[1]}${match[2]}`,
        price_value: Number.parseFloat(match[2].replace(/,/g, '')),
        currency: currencyMap[match[1]] ?? null,
    };
}
export function parseRatingValue(text) {
    const normalized = cleanText(text);
    const match = normalized.match(/(\d+(?:\.\d+)?)\s*out of 5/i);
    return match ? Number.parseFloat(match[1]) : null;
}
export function parseReviewCount(text) {
    const normalized = cleanText(text);
    const compactMatch = normalized.match(/(\d+(?:\.\d+)?)\s*([kKmM])/);
    if (compactMatch) {
        const value = Number.parseFloat(compactMatch[1]);
        const multiplier = /m/i.test(compactMatch[2]) ? 1_000_000 : 1_000;
        return Number.isFinite(value) ? Math.round(value * multiplier) : null;
    }
    const match = normalized.match(/([\d,]+)/);
    return match ? Number.parseInt(match[1].replace(/,/g, ''), 10) : null;
}
export function extractReviewCountFromCardText(text) {
    const normalized = cleanMultilineText(text);
    const match = normalized.match(/out of 5 stars(?:, rating details)?\s*([\d,]+)/i);
    if (match)
        return match[1];
    const numericLine = normalized
        .split('\n')
        .map((line) => cleanText(line))
        .find((line) => /^[\d,]+$/.test(line));
    return numericLine ?? null;
}
export function isAmazonEntity(text) {
    const normalized = cleanText(text).toLowerCase();
    return normalized.includes('amazon');
}
export function firstMeaningfulLine(text) {
    return cleanMultilineText(text)
        .split('\n')
        .map((line) => cleanText(line))
        .find(Boolean)
        ?? '';
}
export function trimRatingPrefix(text) {
    const normalized = cleanText(text);
    if (!normalized)
        return null;
    return normalized.replace(/^\d+(?:\.\d+)?\s*out of 5 stars\s*/i, '').trim() || normalized;
}
export function isRobotState(state) {
    const title = cleanText(state.title);
    const bodyText = cleanMultilineText(state.body_text);
    return ROBOT_TEXT_PATTERNS.some((pattern) => title.includes(pattern) || bodyText.includes(pattern));
}
export function buildChallengeHint(action) {
    return [
        `Open a clean Amazon ${action} page in the shared Chrome profile and clear any robot check first.`,
        'If you are using CDP, set OPENCLI_CDP_TARGET=amazon.com and avoid parallel Amazon commands against the same browser target.',
    ].join(' ');
}
export async function readPageState(page) {
    const result = await page.evaluate(`
    (() => ({
      href: window.location.href,
      title: document.title || '',
      body_text: document.body ? document.body.innerText || '' : '',
    }))()
  `);
    return {
        href: cleanText(result.href),
        title: cleanText(result.title),
        body_text: cleanMultilineText(result.body_text),
    };
}
export async function gotoAndReadState(page, url, settleMs = 2500, action = 'page') {
    try {
        await page.goto(url, { settleMs });
        await page.wait(1.5);
        return await readPageState(page);
    }
    catch (error) {
        const message = error instanceof Error ? error.message : String(error);
        if (message.includes('Inspected target navigated or closed')
            || message.includes('Cannot find context with specified id')
            || message.includes('Target closed')) {
            throw new CommandExecutionError(`amazon ${action} navigation lost the current browser target`, `${buildChallengeHint(action)} If CDP is attached to a stale tab, open a fresh Amazon tab and retry.`);
        }
        throw error;
    }
}
export function assertUsableState(state, action) {
    if (!isRobotState(state))
        return;
    throw new CommandExecutionError(`amazon ${action} hit a robot check`, buildChallengeHint(action));
}
export const __test__ = {
    buildSearchUrl,
    extractAsin,
    buildProductUrl,
    buildDiscussionUrl,
    resolveBestsellersUrl,
    resolveRankingUrl,
    isSupportedRankingPath,
    isRankingPaginationUrl,
    extractCategoryNodeId,
    parsePriceText,
    parseRatingValue,
    parseReviewCount,
    extractReviewCountFromCardText,
    isAmazonEntity,
    trimRatingPrefix,
    isRobotState,
    PRIMARY_PRICE_SELECTORS,
};


//! OpenAI API client and Rig integration
//!
//! # Example
//! ```no_run
//! use rig_core::{client::CompletionClient, providers::openai};
//!
//! # fn run() -> Result<(), Box<dyn std::error::Error>> {
//! let client = openai::Client::new("YOUR_API_KEY")?;
//!
//! let model = client.completion_model(openai::GPT_5_2);
//! # Ok(())
//! # }
//! ```
pub mod client;
pub mod completion;
pub mod embedding;
pub mod model_listing;
pub mod responses_api;

#[cfg(feature = "audio")]
#[cfg_attr(docsrs, doc(cfg(feature = "audio")))]
pub mod audio_generation;

#[cfg(feature = "image")]
#[cfg_attr(docsrs, doc(cfg(feature = "image")))]
pub mod image_generation;
#[cfg(feature = "image")]
pub use image_generation::*;

pub mod transcription;

pub use client::*;
pub use completion::*;
pub use embedding::*;
pub use model_listing::*;

/// Recursively ensures all object schemas in a JSON schema respect OpenAI structured output restrictions.
/// Nested arrays, schema $defs, object properties and enums should be handled through this method
pub(crate) fn sanitize_schema(schema: &mut serde_json::Value) {
    use serde_json::Value;

    if let Value::Object(obj) = schema {
        // OpenAI does not allow sibling keywords next to $ref (e.g. "description").
        // Strip everything except $ref so the reference is the sole key.
        if obj.contains_key("$ref") {
            obj.retain(|k, _| k == "$ref");
            return;
        }

        let is_object_schema = obj.get("type") == Some(&Value::String("object".to_string()))
            || obj.contains_key("properties");

        // OpenAI requires "properties" on all object schemas, even empty ones.
        if is_object_schema && !obj.contains_key("properties") {
            obj.insert("properties".to_string(), Value::Object(Default::default()));
        }

        // This is required by OpenAI's Responses API when using strict mode.
        // Source: https://platform.openai.com/docs/guides/structured-outputs#additionalproperties-false-must-always-be-set-in-objects
        if is_object_schema && !obj.contains_key("additionalProperties") {
            obj.insert("additionalProperties".to_string(), Value::Bool(false));
        }

        // This is also required by OpenAI's Responses API
        // Source: https://platform.openai.com/docs/guides/structured-outputs#all-fields-must-be-required
        if let Some(Value::Object(properties)) = obj.get("properties") {
            let prop_keys = properties.keys().cloned().map(Value::String).collect();
            obj.insert("required".to_string(), Value::Array(prop_keys));
        }

        if let Some(defs) = obj.get_mut("$defs")
            && let Value::Object(defs_obj) = defs
        {
            for (_, def_schema) in defs_obj.iter_mut() {
                sanitize_schema(def_schema);
            }
        }

        if let Some(properties) = obj.get_mut("properties")
            && let Value::Object(props) = properties
        {
            for (_, prop_value) in props.iter_mut() {
                sanitize_schema(prop_value);
            }
        }

        if let Some(items) = obj.get_mut("items") {
            sanitize_schema(items);
        }

        // OpenAI doesn't support oneOf so we need to switch this to anyOf
        if let Some(one_of) = obj.remove("oneOf") {
            // If `anyOf` already exists, merge arrays. If not, insert new.
            match obj.get_mut("anyOf") {
                Some(Value::Array(existing)) => {
                    if let Value::Array(mut incoming) = one_of {
                        existing.append(&mut incoming);
                    }
                }
                _ => {
                    obj.insert("anyOf".to_string(), one_of);
                }
            }
        }

        // should handle Enums (anyOf/oneOf)
        for key in ["anyOf", "oneOf", "allOf"] {
            if let Some(variants) = obj.get_mut(key)
                && let Value::Array(variants_array) = variants
            {
                for variant in variants_array.iter_mut() {
                    sanitize_schema(variant);
                }
            }
        }
    }
}

#[cfg(feature = "audio")]
pub use audio_generation::{TTS_1, TTS_1_HD};

pub use streaming::*;
pub use transcription::*;

#[cfg(test)]
mod tests {
    use super::sanitize_schema;
    use serde_json::json;

    #[test]
    fn test_sanitize_strips_ref_sibling_keywords() {
        let mut schema = json!({
            "type": "object",
            "properties": {
                "location": {
                    "$ref": "#/$defs/Location",
                    "description": "The user's location"
                }
            },
            "$defs": {
                "Location": {
                    "type": "object",
                    "properties": {
                        "city": { "type": "string" },
                        "state": { "type": "string" }
                    }
                }
            }
        });

        sanitize_schema(&mut schema);

        // $ref node should only contain "$ref", no "description"
        let location = &schema["properties"]["location"];
        assert_eq!(location, &json!({ "$ref": "#/$defs/Location" }));

        // The referenced $def should still be fully sanitized
        let location_def = &schema["$defs"]["Location"];
        assert_eq!(location_def["additionalProperties"], json!(false));
        assert!(location_def["required"].as_array().is_some());
    }

    #[test]
    fn test_sanitize_adds_additional_properties_false() {
        let mut schema = json!({
            "type": "object",
            "properties": {
                "name": { "type": "string" }
            }
        });

        sanitize_schema(&mut schema);

        assert_eq!(schema["additionalProperties"], json!(false));
    }

    #[test]
    fn test_sanitize_marks_all_properties_required() {
        let mut schema = json!({
            "type": "object",
            "properties": {
                "a": { "type": "string" },
                "b": { "type": "number" }
            }
        });

        sanitize_schema(&mut schema);

        let required = schema["required"].as_array().unwrap();
        assert!(required.contains(&json!("a")));
        assert!(required.contains(&json!("b")));
        assert_eq!(required.len(), 2);
    }

    #[test]
    fn test_sanitize_converts_one_of_to_any_of() {
        let mut schema = json!({
            "oneOf": [
                { "type": "string" },
                { "type": "number" }
            ]
        });

        sanitize_schema(&mut schema);

        assert!(schema.get("oneOf").is_none());
        assert!(schema["anyOf"].as_array().is_some());
    }

    #[test]
    fn test_sanitize_recurses_into_nested_objects() {
        let mut schema = json!({
            "type": "object",
            "properties": {
                "inner": {
                    "type": "object",
                    "properties": {
                        "value": { "type": "string" }
                    }
                }
            }
        });

        sanitize_schema(&mut schema);

        assert_eq!(
            schema["properties"]["inner"]["additionalProperties"],
            json!(false)
        );
        let inner_required = schema["properties"]["inner"]["required"]
            .as_array()
            .unwrap();
        assert!(inner_required.contains(&json!("value")));
    }
}


import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ArgumentError, CliError, CommandExecutionError } from '@jackwener/opencli/errors';

const { mockApiGet, mockDownloadMedia, mockCheckYtdlp } = vi.hoisted(() => ({
  mockApiGet: vi.fn(),
  mockDownloadMedia: vi.fn(),
  mockCheckYtdlp: vi.fn(),
}));

vi.mock('./utils.js', async (importOriginal) => ({
  ...(await importOriginal()),
  apiGet: mockApiGet,
}));

vi.mock('@jackwener/opencli/download', () => ({
  checkYtdlp: mockCheckYtdlp,
  sanitizeFilename: (s) => s,
}));

vi.mock('@jackwener/opencli/download/media-download', () => ({
  downloadMedia: mockDownloadMedia,
}));

import { getRegistry } from '@jackwener/opencli/registry';
import './download.js';

/** view API 成功响应的最小骨架 */
function viewPayload(extra = {}) {
  return { code: 0, data: { bvid: 'BV1xx411c7mD', rights: {}, ...extra } };
}

describe('bilibili download paid-content pre-check', () => {
  const command = getRegistry().get('bilibili/download');
  const page = {
    goto: vi.fn().mockResolvedValue(undefined),
    wait: vi.fn().mockResolvedValue(undefined),
    evaluate: vi.fn().mockResolvedValue({ title: '标题', author: 'UP主' }),
    getCookies: vi.fn().mockResolvedValue([]),
  };

  beforeEach(() => {
    mockApiGet.mockReset();
    mockDownloadMedia.mockReset();
    mockCheckYtdlp.mockReset();
    mockCheckYtdlp.mockReturnValue(true);
    mockDownloadMedia.mockResolvedValue([{ status: 'success', size: '10MB' }]);
    page.goto.mockClear();
    page.evaluate.mockClear();
  });

  it('downloads normal (free) video without interference', async () => {
    mockApiGet.mockResolvedValueOnce(viewPayload());

    const rows = await command.func(page, { bvid: 'BV1xx411c7mD', output: './o', quality: 'best', force: false });

    expect(rows[0].status).toBe('success');
    expect(mockDownloadMedia).toHaveBeenCalledTimes(1);
  });

  it('throws PAID_CONTENT for member-only bangumi when account has no vip', async () => {
    mockApiGet
      .mockResolvedValueOnce(viewPayload({ rights: { pay: 1 } })) // view
      .mockResolvedValueOnce({ code: 0, data: { vipStatus: 0 } }); // nav

    await expect(
      command.func(page, { bvid: 'BV1xx411c7mD', output: './o', quality: 'best', force: false }),
    ).rejects.toSatisfy((err) => err instanceof CliError && err.code === 'PAID_CONTENT');
    expect(mockDownloadMedia).not.toHaveBeenCalled();
  });

  it('allows member-only content when account has active vip', async () => {
    mockApiGet
      .mockResolvedValueOnce(viewPayload({ rights: { pay: 1 } }))
      .mockResolvedValueOnce({ code: 0, data: { vipStatus: 1 } });

    const rows = await command.func(page, { bvid: 'BV1xx411c7mD', output: './o', quality: 'best', force: false });

    expect(rows[0].status).toBe('success');
    expect(mockDownloadMedia).toHaveBeenCalledTimes(1);
  });

  it('throws PAID_CONTENT for upower-exclusive video (no entitlement endpoint, conservative block)', async () => {
    mockApiGet.mockResolvedValueOnce(viewPayload({ is_upower_exclusive: true }));

    await expect(
      command.func(page, { bvid: 'BV1xx411c7mD', output: './o', quality: 'best', force: false }),
    ).rejects.toSatisfy((err) => err instanceof CliError && err.code === 'PAID_CONTENT');
    // upower 没有权益查询端点，不应再打 nav API
    expect(mockApiGet).toHaveBeenCalledTimes(1);
    expect(mockDownloadMedia).not.toHaveBeenCalled();
  });

  it('fails closed when successful view payload lacks paid-content metadata', async () => {
    mockApiGet.mockResolvedValueOnce({ code: 0, data: { bvid: 'BV1xx411c7mD' } });

    await expect(
      command.func(page, { bvid: 'BV1xx411c7mD', output: './o', quality: 'best', force: false }),
    ).rejects.toSatisfy(
      (err) => err instanceof CommandExecutionError && /paid-content metadata/.test(err.message),
    );
    expect(mockDownloadMedia).not.toHaveBeenCalled();
  });

  it('skips pre-check entirely with --force', async () => {
    const rows = await command.func(page, { bvid: 'BV1xx411c7mD', output: './o', quality: 'best', force: true });

    expect(rows[0].status).toBe('success');
    expect(mockApiGet).not.toHaveBeenCalled();
    expect(mockDownloadMedia).toHaveBeenCalledTimes(1);
  });

  it('does not block download when the pre-check API itself fails', async () => {
    mockApiGet.mockRejectedValueOnce(new Error('network down'));

    const rows = await command.func(page, { bvid: 'BV1xx411c7mD', output: './o', quality: 'best', force: false });

    expect(rows[0].status).toBe('success');
    expect(mockDownloadMedia).toHaveBeenCalledTimes(1);
  });

  it('targets the selected 分P part URL (?p=N) when --page is given', async () => {
    mockApiGet
      .mockResolvedValueOnce(viewPayload({
        pages: [
          { cid: 1001, page: 1, part: 'P1' },
          { cid: 1003, page: 3, part: 'P3 标题' },
        ],
      }))
      .mockResolvedValueOnce(viewPayload());

    await command.func(page, { bvid: 'BV1h6V16SEpg', output: './o', quality: 'best', force: false, page: '3' });

    // goto 与 yt-dlp 下载 URL 都应带 ?p=3
    expect(page.goto).toHaveBeenCalledWith('https://www.bilibili.com/video/BV1h6V16SEpg?p=3');
    const job = mockDownloadMedia.mock.calls[0][0][0];
    expect(job.url).toBe('https://www.bilibili.com/video/BV1h6V16SEpg?p=3');
    expect(job.filename).toContain('_p3_P3 标题');
  });

  it('rejects malformed --page before download side effects', async () => {
    await expect(
      command.func(page, { bvid: 'BV1h6V16SEpg', output: './o', quality: 'best', force: false, page: '1e2' }),
    ).rejects.toBeInstanceOf(ArgumentError);

    expect(page.goto).not.toHaveBeenCalled();
    expect(mockApiGet).not.toHaveBeenCalled();
    expect(mockDownloadMedia).not.toHaveBeenCalled();
  });

  it('fails before yt-dlp when selected --page is absent from view API pages', async () => {
    mockApiGet.mockResolvedValueOnce(viewPayload({
      pages: [{ cid: 1001, page: 1, part: 'P1' }],
    }));

    await expect(
      command.func(page, { bvid: 'BV1h6V16SEpg', output: './o', quality: 'best', force: false, page: '9' }),
    ).rejects.toBeInstanceOf(CommandExecutionError);

    expect(page.goto).not.toHaveBeenCalled();
    expect(mockDownloadMedia).not.toHaveBeenCalled();
  });

  it('downloads default P1 (no ?p=) when --page is omitted', async () => {
    mockApiGet.mockResolvedValueOnce(viewPayload());

    await command.func(page, { bvid: 'BV1xx411c7mD', output: './o', quality: 'best', force: false });

    expect(page.goto).toHaveBeenCalledWith('https://www.bilibili.com/video/BV1xx411c7mD');
    const job = mockDownloadMedia.mock.calls[0][0][0];
    expect(job.url).toBe('https://www.bilibili.com/video/BV1xx411c7mD');
    expect(job.filename).not.toContain('_p');
  });
});


import assert from "node:assert/strict";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";

import {
  filterSkillBodyForMode,
  parsePonytailCommand,
  readDefaultMode,
  readQuietStartup,
  resolveSessionMode,
  writeDefaultMode,
} from "../index.js";

test("parsePonytailCommand falls back to full when invoked bare and default is off", () => {
  assert.deepEqual(parsePonytailCommand("", "off"), { type: "set-mode", mode: "full" });
});

test("parsePonytailCommand parses modes, status, and default subcommand", () => {
  assert.deepEqual(parsePonytailCommand("ultra", "full"), { type: "set-mode", mode: "ultra" });
  assert.deepEqual(parsePonytailCommand("status", "full"), { type: "status" });
  assert.deepEqual(parsePonytailCommand("default lite", "full"), { type: "set-default", mode: "lite" });
});

test("parsePonytailCommand rejects review as a default (session-only mode, #377)", () => {
  assert.deepEqual(parsePonytailCommand("default review", "full"), { type: "invalid", reason: "invalid-default-mode" });
});

test("resolveSessionMode still honors review as a session mode (not a default)", () => {
  const entries = [{ type: "custom", customType: "ponytail-mode", data: { mode: "review" } }];
  assert.equal(resolveSessionMode(entries, "full"), "review");
});

test("resolveSessionMode prefers latest persisted session mode", () => {
  const entries = [
    { type: "custom", customType: "ponytail-mode", data: { mode: "lite" } },
    { type: "custom", customType: "ponytail-mode", data: { mode: "ultra" } },
  ];

  assert.equal(resolveSessionMode(entries, "full"), "ultra");
});

test("resolveSessionMode returns fallback when entries is not an array", () => {
  assert.equal(resolveSessionMode(null, "ultra"), "ultra");
  assert.equal(resolveSessionMode(undefined, "lite"), "lite");
  assert.equal(resolveSessionMode({}, "full"), "full");
  assert.equal(resolveSessionMode("not an array"), "full"); // DEFAULT_MODE fallback
});

test("readDefaultMode and writeDefaultMode use XDG config path", () => {
  const tempDir = mkdtempSync(join(tmpdir(), "ponytail-config-"));
  const previousXdg = process.env.XDG_CONFIG_HOME;
  const previousDefault = process.env.PONYTAIL_DEFAULT_MODE;
  const configPath = join(tempDir, "ponytail", "config.json");
  process.env.XDG_CONFIG_HOME = tempDir;
  delete process.env.PONYTAIL_DEFAULT_MODE;

  try {
    assert.equal(readDefaultMode(), "full");
    assert.equal(writeDefaultMode("ultra"), "ultra");
    assert.equal(readDefaultMode(), "ultra");
    assert.ok(existsSync(configPath));
    assert.deepEqual(JSON.parse(readFileSync(configPath, "utf8")), { defaultMode: "ultra" });
  } finally {
    if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME;
    else process.env.XDG_CONFIG_HOME = previousXdg;
    if (previousDefault === undefined) delete process.env.PONYTAIL_DEFAULT_MODE;
    else process.env.PONYTAIL_DEFAULT_MODE = previousDefault;
    rmSync(tempDir, { recursive: true, force: true });
  }
});

test("readQuietStartup resolves env var, config file, and default in that order", () => {
  const tempDir = mkdtempSync(join(tmpdir(), "ponytail-quiet-"));
  const previousXdg = process.env.XDG_CONFIG_HOME;
  const previousEnv = process.env.PONYTAIL_QUIET_STARTUP;
  const configDir = join(tempDir, "ponytail");
  const configPath = join(configDir, "config.json");
  process.env.XDG_CONFIG_HOME = tempDir;
  delete process.env.PONYTAIL_QUIET_STARTUP;

  try {
    // No env, no config -> default false (toast still shows)
    assert.equal(readQuietStartup(), false);

    // Config file true -> respected
    mkdirSync(configDir, { recursive: true });
    writeFileSync(configPath, JSON.stringify({ quietStartup: true }), "utf8");
    assert.equal(readQuietStartup(), true);

    // Env var overrides config
    process.env.PONYTAIL_QUIET_STARTUP = "false";
    assert.equal(readQuietStartup(), false);
    process.env.PONYTAIL_QUIET_STARTUP = "1";
    assert.equal(readQuietStartup(), true);
  } finally {
    if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME;
    else process.env.XDG_CONFIG_HOME = previousXdg;
    if (previousEnv === undefined) delete process.env.PONYTAIL_QUIET_STARTUP;
    else process.env.PONYTAIL_QUIET_STARTUP = previousEnv;
    rmSync(tempDir, { recursive: true, force: true });
  }
});

test("filterSkillBodyForMode keeps only requested intensity examples and rows", () => {
  // Examples are quoted in the real SKILL.md (`- lite: "..."`) — match that
  // shape here too; see the next test for why the quote is load-bearing.
  const body = `---\nname: ponytail\n---\n| **lite** | keep lite |\n| **full** | keep full |\n| **ultra** | keep ultra |\n- lite: "Lite example"\n- full: "Full example"\n- ultra: "Ultra example"\nOther line`;

  const filtered = filterSkillBodyForMode(body, "ultra");

  assert.ok(!filtered.includes("keep lite"));
  assert.ok(!filtered.includes("keep full"));
  assert.ok(filtered.includes("keep ultra"));
  assert.ok(!filtered.includes("Lite example"));
  assert.ok(filtered.includes("Ultra example"));
  assert.ok(filtered.includes("Other line"));
});

test("filterSkillBodyForMode does not drop a rule bullet whose label matches a mode name", () => {
  // A rule bullet like "- Full: ..." has the same "label: text" shape as a
  // worked example, but isn't one — it must survive in every mode. Only the
  // quoted, `- lite: "..."`-style bullets are real per-mode examples.
  const body = `- Full: do not confuse this rule label with the mode name.\n- Lite: same risk, this is a real rule bullet.\n- lite: "real worked example"\n- ultra: "real worked example"`;

  const filtered = filterSkillBodyForMode(body, "ultra");

  assert.ok(filtered.includes("Full: do not confuse"), "an unquoted rule bullet must not be treated as a mode example");
  assert.ok(filtered.includes("Lite: same risk"), "an unquoted rule bullet must not be treated as a mode example");
  assert.ok(!filtered.includes("- lite:"), "the real quoted lite example must still be filtered out in ultra mode");
  assert.ok(filtered.includes('ultra: "real worked example"'));
});

test("filterSkillBodyForMode keeps rule bullets that contain a colon", () => {
  // Regression: rule bullets outside the Intensity section (e.g. the
  // "No unrequested abstractions:" rule or the `ponytail:` comment convention)
  // contain a colon and must not be mistaken for mode-example lines.
  const skillPath = new URL("../../skills/ponytail/SKILL.md", import.meta.url);
  const body = readFileSync(skillPath, "utf8");

  const filtered = filterSkillBodyForMode(body, "full");

  assert.ok(filtered.includes("No unrequested abstractions"));
  assert.ok(filtered.includes("Mark deliberate simplifications that cut a real corner"));
  assert.ok(filtered.includes("`ponytail:` comment naming the ceiling and upgrade path"));
  // The Intensity examples are still filtered down to the active mode.
  assert.ok(filtered.includes('full: "`@lru_cache'));
  assert.ok(!filtered.includes('lite: "Done'));
  assert.ok(!filtered.includes('ultra: "No cache'));
});


from typing import BinaryIO, Any, Union
import base64
import mimetypes
from ._exiftool import exiftool_metadata
from .._base_converter import DocumentConverter, DocumentConverterResult
from .._stream_info import StreamInfo

ACCEPTED_MIME_TYPE_PREFIXES = [
    "image/jpeg",
    "image/png",
]

ACCEPTED_FILE_EXTENSIONS = [".jpg", ".jpeg", ".png"]

class ImageConverter(DocumentConverter):
    """
    Converts images to markdown via extraction of metadata (if `exiftool` is installed), and description via a multimodal LLM (if an llm_client is configured).
    """

    def accepts(
        self,
        file_stream: BinaryIO,
        stream_info: StreamInfo,
        **kwargs: Any,
    ) -> bool:
        mimetype = (stream_info.mimetype or "").lower()
        extension = (stream_info.extension or "").lower()

        if extension in ACCEPTED_FILE_EXTENSIONS:
            return True

        for prefix in ACCEPTED_MIME_TYPE_PREFIXES:
            if mimetype.startswith(prefix):
                return True

        return False

    def convert(
        self,
        file_stream: BinaryIO,
        stream_info: StreamInfo,
        **kwargs: Any,  # Options to pass to the converter
    ) -> DocumentConverterResult:
        md_content = ""

        # Add metadata
        metadata = exiftool_metadata(
            file_stream, exiftool_path=kwargs.get("exiftool_path")
        )

        if metadata:
            for f in [
                "ImageSize",
                "Title",
                "Caption",
                "Description",
                "Keywords",
                "Artist",
                "Author",
                "DateTimeOriginal",
                "CreateDate",
                "GPSPosition",
            ]:
                if f in metadata:
                    md_content += f"{f}: {metadata[f]}\n"

        # Try describing the image with GPT
        llm_client = kwargs.get("llm_client")
        llm_model = kwargs.get("llm_model")
        if llm_client is not None and llm_model is not None:
            llm_description = self._get_llm_description(
                file_stream,
                stream_info,
                client=llm_client,
                model=llm_model,
                prompt=kwargs.get("llm_prompt"),
            )

            if llm_description is not None:
                md_content += "\n# Description:\n" + llm_description.strip() + "\n"

        return DocumentConverterResult(
            markdown=md_content,
        )

    def _get_llm_description(
        self,
        file_stream: BinaryIO,
        stream_info: StreamInfo,
        *,
        client,
        model,
        prompt=None,
    ) -> Union[None, str]:
        if prompt is None or prompt.strip() == "":
            prompt = "Write a detailed caption for this image."

        # Get the content type
        content_type = stream_info.mimetype
        if not content_type:
            content_type, _ = mimetypes.guess_type(
                "_dummy" + (stream_info.extension or "")
            )
        if not content_type:
            content_type = "application/octet-stream"

        # Convert to base64
        cur_pos = file_stream.tell()
        try:
            base64_image = base64.b64encode(file_stream.read()).decode("utf-8")
        except Exception as e:
            return None
        finally:
            file_stream.seek(cur_pos)

        # Prepare the data-uri
        data_uri = f"data:{content_type};base64,{base64_image}"

        # Prepare the OpenAI API request
        messages = [
            {
                "role": "user",
                "content": [
                    {"type": "text", "text": prompt},
                    {
                        "type": "image_url",
                        "image_url": {
                            "url": data_uri,
                        },
                    },
                ],
            }
        ]

        # Call the OpenAI API
        response = client.chat.completions.create(model=model, messages=messages)
        return response.choices[0].message.content


import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"

import { cn } from "#/lib/utils.ts"
import { HugeiconsIcon } from "@hugeicons/react"
import { MinusSignIcon } from "@hugeicons/core-free-icons"

function InputOTP({
  className,
  containerClassName,
  ...props
}: React.ComponentProps<typeof OTPInput> & {
  containerClassName?: string
}) {
  return (
    <OTPInput
      data-slot="input-otp"
      containerClassName={cn(
        "cn-input-otp flex items-center has-disabled:opacity-50",
        containerClassName
      )}
      spellCheck={false}
      className={cn("disabled:cursor-not-allowed", className)}
      {...props}
    />
  )
}

function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
  return (

  )
}

function InputOTPSlot({
  index,
  className,
  ...props
}: React.ComponentProps<"div"> & {
  index: number
}) {
  const inputOTPContext = React.useContext(OTPInputContext)
  const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}

  return (
    <div
      data-slot="input-otp-slot"
      data-active={isActive}
      className={cn(
        "relative flex size-7 items-center justify-center border-y border-r border-input bg-input/20 text-xs/relaxed transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md aria-invalid:border-destructive data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-2 data-[active=true]:ring-ring/30 data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:ring-destructive/20 dark:bg-input/30 dark:data-[active=true]:aria-invalid:ring-destructive/40",
        className
      )}
      {...props}
    >
      {char}
      {hasFakeCaret && (

      )}

  )
}

function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
  return (

      <HugeiconsIcon icon={MinusSignIcon} strokeWidth={2} />

  )
}

export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }


#!/usr/bin/env bash
set -Eeuo pipefail

ETFS="boot/etfsboot.com"
FB="falling back to manual installation!"
EFISYS="efi/microsoft/boot/efisys_noprompt.bin"

backup () {

  local iso="$1"
  local count=1
  local name="unknown"
  local root="$STORAGE/backups"
  local file previous failed=""

  previous=$(readState "base") || return 1
  [ -n "$previous" ] && name="${previous%.*}"

  if ! makeDir "$root"; then
    error "Failed to create directory \"$root\" !"
    return 1
  fi

  local folder="$name"
  local dir="$root/$folder"

  while [ -d "$dir" ]; do
    (( count++ ))
    folder="${name}.${count}"
    dir="$root/$folder"
  done

  if ! makeDir "$dir"; then
    error "Failed to create directory \"$dir\" !"
    return 1
  fi

  if [ -f "$iso" ]; then
    if ! mv -f -- "$iso" "$dir/"; then
      error "Failed to move \"$iso\" to \"$dir\"."
      failed="Y"
    fi
  fi

  while IFS= read -r -d '' file; do
    if ! mv -n -- "$file" "$dir/"; then
      error "Failed to move \"$file\" to \"$dir\"."
      failed="Y"
    fi
  done < <(
    find "$STORAGE" -maxdepth 1 -type f \
      \( -iname 'data.*' -or -iname 'windows.*' -or -iname '*.rom' -or -iname '*.vars' \) \
      -not -iname '*.iso' -print0
  )

  local find_pid=$!

  if ! wait "$find_pid"; then
    error "Failed to enumerate files in \"$STORAGE\"."
    failed="Y"
  fi

  [ -z "$(ls -A "$dir")" ] && rm -rf "$dir"
  [ -z "$(ls -A "$root")" ] && rm -rf "$root"

  [ -n "$failed" ] && return 1

  return 0
}

findFile() {

  local dir file base
  local fname="$1"
  local boot="$STORAGE/windows.boot"

  dir=$(find / -maxdepth 1 -type d -iname "$fname" -print -quit)
  [ ! -d "$dir" ] && dir=$(find "$STORAGE" -maxdepth 1 -type d -iname "$fname" -print -quit)

  if [ -d "$dir" ]; then
    if ! hasData || [ ! -f "$boot" ]; then
      error "The bind $dir maps to a file that does not exist!" && return 1
    fi
  fi

  file=$(find / -maxdepth 1 -type f -iname "$fname" -print -quit)
  [ ! -s "$file" ] && file=$(find "$STORAGE" -maxdepth 1 -type f -iname "$fname" -print -quit)

  if [ ! -s "$file" ] && [[ "${VERSION,,}" != "http"* ]]; then
    base=$(basename "$VERSION")
    file="$STORAGE/$base"
  fi

  if [ ! -f "$file" ] || [ ! -s "$file" ]; then
    return 0
  fi

  local size
  size="$(stat -c%s "$file")"
  [ -z "$size" ] || [[ "$size" == "0" ]] && return 0

  ISO="$file"
  CUSTOM="$file"
  BOOT="$STORAGE/windows.$size.iso"

  return 0
}

detectCustom() {

  CUSTOM=""

  ! findFile "custom.iso" && return 1
  [ -n "$CUSTOM" ] && return 0

  ! findFile "boot.iso" && return 1
  [ -n "$CUSTOM" ] && return 0

  return 0
}

skipInstall() {

  local iso="$1"
  local method magic previous
  local boot="$STORAGE/windows.boot"

  previous=$(readState "base") || return 1

  if [ -n "$previous" ]; then
    if [[ "${STORAGE,,}/${previous,,}" != "${iso,,}" ]]; then

      if ! hasDisk; then

        if ! rm -f -- "$STORAGE/$previous"; then
          error "Failed to remove ISO file \"$STORAGE/$previous\" !"
          exit 50
        fi

        return 1

      fi

      if [[ "${iso,,}" == "${STORAGE,,}/windows."* ]]; then
        method="your custom .iso file was changed"
      else
        if [[ "${previous,,}" != "windows."* ]]; then
          method="the VERSION variable was changed"
        else
          method="your custom .iso file was removed"

          if [ -f "$boot" ] && hasData; then
            info "Detected that $method, will be ignored."
            return 0
          fi

        fi
      fi

      info "Detected that $method, a backup of your previous installation will be saved..."

      if ! backup "$STORAGE/$previous"; then
        warn "the backup was incomplete, continuing with installation..."
      fi

      return 1

    fi
  fi

  [ -f "$boot" ] && hasData && return 0

  [ ! -f "$iso" ] && return 1
  [ ! -s "$iso" ] && return 1

  # Check if the ISO was already processed by our script
  magic=$(dd if="$iso" bs=1 count=1 status=none | tr -d '\000')
  magic="$(printf '%s' "$magic" | od -A n -t x1 -v | tr -d ' \n')"
  local byte="16"
  enabled "$MANUAL" && byte="17"

  if [[ "$magic" != "$byte" ]]; then

    info "The ISO will be processed again because the configuration was changed..."
    return 1

  fi

  return 0
}

startInstall() {

  html "Starting $APP..."

  if [ -z "$CUSTOM" ]; then

    local file="${VERSION//\//}.iso"

    if [[ "${VERSION,,}" == "http"* ]]; then

      file=$(basename "${VERSION%%\?*}")
      printf -v file '%b' "${file//%/\\x}"
      file="${file//[!A-Za-z0-9._-]/_}"

    else

      local language
      language=$(getLanguage "$LANGUAGE" "culture")
      language="${language%%-*}"

      if [ -n "$language" ] && [[ "${language,,}" != "en" ]]; then
        file="${VERSION//\//}_${language,,}.iso"
      fi

    fi

    BOOT="$STORAGE/$file"

    REUSED_ISO=""
    [ -s "$BOOT" ] && REUSED_ISO="Y"

    # Use the suggested answer file for a new automatic download. When an
    # existing ISO is reused, leave DETECTED empty so its actual image can
    # be inspected instead.
    if [ -n "$DETECTED" ]; then
      DETECTED_ORG="Y"
    elif [ -z "$REUSED_ISO" ]; then
      DETECTED="$SUGGEST"
    fi

  fi

  TMP="$STORAGE/tmp"

  if ! rm -rf -- "$TMP"; then
    error "Failed to remove directory \"$TMP\" !"
    exit 50
  fi

  skipInstall "$BOOT" && return 1

  if hasDisk; then
    if ! backup ""; then
      warn "the backup was incomplete, continuing with installation..."
    fi
  fi

  if ! makeDir "$TMP"; then
    error "Failed to create directory \"$TMP\" !"
    exit 50
  fi

  if [ -z "$CUSTOM" ]; then

    ISO=$(basename "$BOOT")
    ISO="$TMP/$ISO"

    if [ -f "$BOOT" ] && [ -s "$BOOT" ]; then
      if ! mv -f -- "$BOOT" "$ISO"; then
        error "Failed to move ISO file from \"$BOOT\" to \"$ISO\" !"
        exit 50
      fi
    fi

  fi

  if ! rm -f -- "$BOOT"; then
    error "Failed to remove ISO file \"$BOOT\" !"
    exit 50
  fi

  if ! find "$STORAGE" -maxdepth 1 -type f -iname 'data.*' -not -iname '*.iso' -delete; then
    error "Failed to remove obsolete disk files from \"$STORAGE\" !"
    exit 50
  fi

  if ! find "$STORAGE" -maxdepth 1 -type f -iname 'windows.*' -not -iname '*.iso' -delete; then
    error "Failed to remove obsolete Windows files from \"$STORAGE\" !"
    exit 50
  fi

  if ! find "$STORAGE" -maxdepth 1 -type f \( -iname '*.rom' -or -iname '*.vars' \) -delete; then
    error "Failed to remove obsolete firmware files from \"$STORAGE\" !"
    exit 50
  fi

  return 0
}

finishInstall() {

  local iso="$1"
  local aborted="$2"
  local base

  if [ ! -s "$iso" ] || [ ! -f "$iso" ]; then
    error "Failed to find ISO file: $iso" && return 1
  fi

  if [[ "$iso" == "$STORAGE/"* ]]; then
    if ! setOwner "$iso"; then
      warn "failed to set the owner for \"$iso\" !"
    fi
  fi

  if [[ "$aborted" != [Yy1]* ]]; then
    # Mark ISO as prepared via magic byte
    local byte="16"
    enabled "$MANUAL" && byte="17"
    if ! printf '%b' "\x$byte" | dd of="$iso" bs=1 seek=0 count=1 conv=notrunc status=none; then
      warn "failed to set magic byte in ISO file: $iso"
    fi
  fi

  local file="$STORAGE/windows.ver"
  cp -f /etc/version "$file" || return 1

  if ! setOwner "$file"; then
    warn "Failed to set the owner for \"$file\" !"
  fi

  if [[ "$iso" == "$STORAGE/"* ]]; then
    if [[ "$aborted" != [Yy1]* ]] || [ -z "$CUSTOM" ]; then
      base=$(basename "$iso")
      writeState "base" "$base" || return 1
    fi
  fi

  if [[ "${PLATFORM,,}" == "x64" ]]; then
    if [[ "${BOOT_MODE,,}" == "windows_legacy" ]]; then
      writeState "mode" "$BOOT_MODE" || return 1
      if [[ "${MACHINE,,}" != "q35" ]]; then
        writeState "old" "$MACHINE" || return 1
      fi
    else
      # Enable secure boot + TPM on manual installs as Win11 requires
      if enabled "$MANUAL" || [[ "$aborted" == [Yy1]* ]]; then
        if [[ "${DETECTED,,}" == "win11"* ]]; then
          BOOT_MODE="windows_secure"
          writeState "mode" "$BOOT_MODE" || return 1
        fi
      fi
      # Enable secure boot on multi-socket systems to workaround freeze
      if [ -n "$SOCKETS" ] && [[ "$SOCKETS" != "1" ]]; then
        BOOT_MODE="windows_secure"
        writeState "mode" "$BOOT_MODE" || return 1
      fi
    fi
  fi

  if [ -n "${ARGS:-}" ]; then
    ARGUMENTS="$ARGS ${ARGUMENTS:-}"
    writeState "args" "$ARGS" || return 1
  fi

  if [ -n "${VGA:-}" ] && [[ "${VGA:-}" != "virtio"* ]]; then
    writeState "vga" "$VGA" || return 1
  fi

  if [ -n "${USB:-}" ] && [[ "${USB:-}" != "qemu-xhci"* ]]; then
    writeState "usb" "$USB" || return 1
  fi

  if [ -n "${DISK_TYPE:-}" ] && [[ "${DISK_TYPE:-}" != "scsi" ]]; then
    writeState "type" "$DISK_TYPE" || return 1
  fi

  if [ -n "${ADAPTER:-}" ] && [[ "${ADAPTER:-}" != "virtio-net-pci" ]]; then
    writeState "net" "$ADAPTER" || return 1
  fi

  if [ -n "${SOUND:-}" ] && [[ "${SOUND:-}" != "intel-hda" ]]; then
    writeState "sound" "$SOUND" || return 1
  fi

  if [ -n "${CPU_MODEL:-}" ] && [[ "${CPU_MODEL,,}" != "host" ]]; then
    writeState "cpu" "$CPU_MODEL" || return 1
  fi

  rm -rf "$TMP"
  return 0
}

abortInstall() {

  local dir="$1"
  local iso="$2"
  local efi

  [[ "${iso,,}" == *".esd" ]] && exit 60
  enabled "${UNPACK:-}" && exit 60

  efi=$(find "$dir" -maxdepth 1 -type d -iname efi -print -quit)

  if [ -z "$efi" ]; then
    [[ "${PLATFORM,,}" == "x64" ]] && BOOT_MODE="windows_legacy"
  fi

  if [ -n "$CUSTOM" ]; then
    BOOT="$iso"
    REMOVE="N"
  else
    if [[ "$iso" != "$BOOT" ]]; then
      if ! mv -f "$iso" "$BOOT"; then
        error "Failed to move ISO file: $iso" && return 1
      fi
    fi
  fi

  finishInstall "$BOOT" "Y" && return 0
  return 1
}

checkFreeSpace() {

  local dir="$1"
  local size="$2"
  local size_gb space space_gb

  size_gb=$(formatBytes "$size")
  space=$(df --output=avail -B 1 "$dir" | tail -n 1)
  space_gb=$(formatBytes "$space")

  if (( size > space )); then
    error "Not enough free space in $STORAGE, have $space_gb available but need at least $size_gb."
    return 1
  fi

  return 0
}

getEsdField() {

  local list="$1"
  local index="$2"

  sed -n "${index}p" <<< "$list" | tr -cd '0-9'

  return 0
}

extractESD() {

  local iso="$1"
  local dir="$2"
  local version="$3"
  local desc="$4"

  local info count totals links
  local bootTotal bootLinks
  local wimTotal wimLinks
  local installSize size
  local edition imgEdition

  local minSize=100000000
  local freeSpace=9606127360
  local bootPad=60000000
  local installPad=3000000

  local msg="Extracting $desc bootdisk"
  info "$msg..." && html "$msg..."

  if ! size=$(stat -c%s -- "$iso"); then
    error "Failed to determine size of ISO file \"$iso\" !"
    return 1
  fi

  if (( size < minSize )); then
    error "The downloaded ISO file is too small!"
    return 1
  fi

  if ! rm -rf -- "$dir"; then
    error "Failed to remove directory \"$dir\" !"
    return 1
  fi

  if ! makeDir "$dir"; then
    error "Failed to create directory \"$dir\" !"
    return 1
  fi

  checkFreeSpace "$dir" "$freeSpace" || return 1

  info=$(wimlib-imagex info "$iso") || {
    error "Cannot read ESD file information!"
    return 1
  }

  count=$(awk '/Image Count:/ {print $3}' <<< "$info")

  if [[ ! "$count" =~ ^[0-9]+$ ]]; then
    error "Cannot read the image count in ESD file!"
    return 1
  fi

  if (( count < 3 )); then
    error "Invalid ESD file: expected at least 3 images, found $count."
    return 1
  fi

  totals=$(grep "Total Bytes:" <<< "$info" || true)
  links=$(grep "Hard Link Bytes:" <<< "$info" || true)

  bootTotal=$(getEsdField "$totals" 1)
  bootLinks=$(getEsdField "$links" 1)

  if [[ ! "$bootTotal" =~ ^[0-9]+$ ]] || [[ ! "$bootLinks" =~ ^[0-9]+$ ]]; then
    error "Cannot read bootdisk size from ESD file!"
    return 1
  fi

  local bootSize=$(( bootTotal - bootLinks ))

  wimTotal=$(getEsdField "$totals" 3)
  wimLinks=$(getEsdField "$links" 3)

  if [[ ! "$wimTotal" =~ ^[0-9]+$ ]] || [[ ! "$wimLinks" =~ ^[0-9]+$ ]]; then
    error "Cannot read boot.wim size from ESD file!"
    return 1
  fi

  local wimSize=$(( wimTotal - wimLinks + bootPad ))

  /run/progress.sh "$dir" "$bootSize" "$msg ([P])..." &

  local index="1"
  wimlib-imagex apply "$iso" "$index" "$dir" --quiet 2>/dev/null || {
    local ret=$?
    fKill "progress.sh"
    error "Extracting $desc bootdisk failed ($ret)"
    return 1
  }

  fKill "progress.sh"

  local bootWim="$dir/sources/boot.wim"
  local installWim="$dir/sources/install.wim"

  msg="Extracting $desc environment"
  info "$msg..." && html "$msg..."

  index="2"
  /run/progress.sh "$bootWim" "$wimSize" "$msg ([P])..." &

  wimlib-imagex export "$iso" "$index" "$bootWim" --compress=none --quiet || {
    local ret=$?
    fKill "progress.sh"
    error "Adding WinPE failed ($ret)"
    return 1
  }

  fKill "progress.sh"

  msg="Extracting $desc setup"
  info "$msg..."

  index="3"
  /run/progress.sh "$bootWim" "$wimSize" "$msg ([P])..." &

  wimlib-imagex export "$iso" "$index" "$bootWim" --compress=none --boot --quiet || {
    local ret=$?
    fKill "progress.sh"
    error "Adding Windows Setup failed ($ret)"
    return 1
  }

  fKill "progress.sh"

  if [[ "${PLATFORM,,}" == "x64" ]]; then
    LABEL="CCCOMA_X64FRE_EN-US_DV9"
  else
    LABEL="CPBA_A64FRE_EN-US_DV9"
  fi

  msg="Extracting $desc image"
  info "$msg..." && html "$msg..."

  edition=$(getCatalog "$version" "name")

  if [ -z "$edition" ]; then
    error "Invalid VERSION specified, value \"$version\" is not recognized!"
    return 1
  fi

  for (( index=4; index<=count; index++ )); do

    imgEdition=$(wimlib-imagex info "$iso" "$index" | grep '^Description:' | sed 's/Description:[ \t]*//')
    [[ "${imgEdition,,}" != "${edition,,}" ]] && continue

    installSize=$(stat -c%s "$iso")
    installSize=$(( installSize + installPad ))

    /run/progress.sh "$installWim" "$installSize" "$msg ([P])..." &

    wimlib-imagex export "$iso" "$index" "$installWim" --compress=LZMS --chunk-size 128K --quiet || {
      local ret=$?
      fKill "progress.sh"
      error "Addition of $index to the $desc image failed ($ret)"
      return 1
    }

    fKill "progress.sh"
    return 0

  done

  fKill "progress.sh"
  error "Failed to find product '$edition' in install.wim!"
  return 1
}

extractImage() {

  local iso="$1"
  local dir="$2"
  local version="$3"
  local desc="local ISO"
  local file size

  if [ -z "$CUSTOM" ]; then
    desc="downloaded ISO"
    if [[ "$version" != "http"* ]]; then
      desc=$(printVariant "$version" "$desc")
    fi
  fi

  if [[ "${iso,,}" == *".esd" ]]; then
    extractESD "$iso" "$dir" "$version" "$desc" && return 0
    return 1
  fi

  local msg="Extracting $desc image"
  info "$msg..." && html "$msg..."

  if ! rm -rf -- "$dir"; then
    error "Failed to remove directory \"$dir\" !"
    return 1
  fi

  if ! makeDir "$dir"; then
    error "Failed to create directory \"$dir\" !"
    return 1
  fi

  size=$(stat -c%s "$iso")

  if (( size < 100000000 )); then
    error "Invalid ISO file: Size is smaller than 100 MB" && return 1
  fi

  checkFreeSpace "$dir" "$size" || return 1

  if ! rm -rf -- "$dir"; then
    error "Failed to remove directory \"$dir\" !"
    return 1
  fi

  /run/progress.sh "$dir" "$size" "$msg ([P])..." &

  if ! 7z x "$iso" -o"$dir" > /dev/null; then
    fKill "progress.sh"
    error "Failed to extract ISO file: $iso" && return 1
  fi

  fKill "progress.sh"

  if ! enabled "${UNPACK:-}"; then

    LABEL=$(isoinfo -d -i "$iso" | sed -n 's/Volume id: //p') || LABEL=""

  else

    file=$(find "$dir" -maxdepth 1 -type f -iname "*.iso" -print -quit)

    if [ -z "$file" ]; then
      error "Failed to find any .iso file in archive!" && return 1
    fi

    if ! 7z x "$file" -o"$dir" > /dev/null; then
      error "Failed to extract archive!" && return 1
    fi

    LABEL=$(isoinfo -d -i "$file" | sed -n 's/Volume id: //p') || LABEL=""
    rm -f "$file" || warn "Failed to remove temporary ISO file: $file"

  fi

  return 0
}

setMachine() {

  local id="$1"
  local iso="$2"
  local dir="$3"
  local desc="$4"
  local legacy=""

  if ! disabled "$REBUILD"; then

    case "${id,,}" in
      "win2k"* )   legacy="2k" ;;
      "winxp"* )   legacy="xp" ;;
      "win2003"* ) legacy="2k3" ;;
    esac

    if [ -n "$legacy" ]; then
      if ! legacyInstall "$iso" "$dir" "$desc" "$legacy"; then
        error "Failed to prepare $desc ISO!"
        return 1
      fi
    fi

  fi

  case "${id,,}" in
    "win9"* )
      USB="no"
      VGA="cirrus"
      DISK_TYPE="auto"
      MACHINE="pc-i440fx-2.4"
      BOOT_MODE="windows_legacy"
      [ -z "${ADAPTER:-}" ] && ADAPTER="pcnet" ;;
    "win2k"* )
      VGA="cirrus"
      MACHINE="pc"
      USB="pci-ohci"
      DISK_TYPE="auto"
      BOOT_MODE="windows_legacy"
      [ -z "${ADAPTER:-}" ] && ADAPTER="rtl8139" ;;
    "winxp"* )
      DISK_TYPE="blk"
      BOOT_MODE="windows_legacy"
      [ -z "${SOUND:-}" ] && SOUND="usb-audio"

      if [ -z "${CPU_MODEL:-}" ] || [[ "${CPU_MODEL:-}" == "host" ]]; then
        # Workaround for boot loop on AMD EPYC processors
        if [[ "${CPU,,}" == *"amd epyc"* ]]; then
          CPU_MODEL="qemu32"
        fi
      fi
      ;;
    "win2003"* )
      DISK_TYPE="blk"
      BOOT_MODE="windows_legacy"
      [ -z "${SOUND:-}" ] && SOUND="usb-audio"
      ;;
    "winvista"* | "win7"* | "win2008"* )
      BOOT_MODE="windows_legacy" ;;
  esac

  case "${id,,}" in
    "winxp"* | "win2003"* | "winvistax86"* | "win7x86"* | "win2008r2x86"* )
      if isQ35 "${MACHINE:-q35}"; then
        # Prevent bluescreen if 64 bit PCI hole size is >2G.
        ARGS="-global q35-pcihost.x-pci-hole64-fix=false"
      fi ;;
  esac

  return 0
}

prepareImage() {

  local iso="$1"
  local dir="$2"
  local desc missing

  desc=$(printVariant "$DETECTED" "$DETECTED")

  # Adjust QEMU machine configuration for legacy versions
  setMachine "$DETECTED" "$iso" "$dir" "$desc" || return 1

  disabled "$REBUILD" && return 0
  skipVersion "$DETECTED" && return 0

  if [[ "${BOOT_MODE,,}" == "windows_legacy" ]]; then

    extractBootImage "$iso" "$dir" "$desc" && return 0

    error "Failed to extract boot image from ISO image \"${iso}\"!"
    return 1
  fi

  [ -f "$dir/$ETFS" ] && [ -s "$dir/$ETFS" ] &&
    [ -f "$dir/$EFISYS" ] && [ -s "$dir/$EFISYS" ] && return 0

  missing=$(basename "$dir/$EFISYS")
  if [ ! -f "$dir/$ETFS" ] || [ ! -s "$dir/$ETFS" ]; then
    missing=$(basename "$dir/$ETFS")
  fi

  error "Failed to locate file \"${missing,,}\" in ISO image!"
  return 1
}

addFolder() {

  local src="$1"
  local folder="/oem" file
  local dest="$src/\$OEM\$/\$1/OEM"

  [ ! -d "$folder" ] && folder="/OEM"
  [ ! -d "$folder" ] && folder="$STORAGE/oem"
  [ ! -d "$folder" ] && folder="$STORAGE/OEM"
  [ ! -d "$folder" ] && folder=""

  [ -z "$folder" ] && [ -z "$COMMAND" ] && return 0

  local msg="Adding OEM files to image..."
  info "$msg" && html "$msg"

  mkdir -p "$dest" || return 1

  if [ -n "$folder" ]; then
    cp -Lr "$folder/." "$dest" || return 1
  fi

  file=$(find "$dest" -maxdepth 1 -type f -iname install.bat -print -quit) || return 1

  if [ -s "$file" ]; then
    normalizeBatch "$file" || return 1
  fi

  if [ -n "$COMMAND" ]; then

    [ -z "$file" ] && file="$dest/install.bat"

    if [ -s "$file" ]; then
      printf '\n' >> "$file" || return 1
    fi

    printf '%s\n' "$COMMAND" >> "$file" || return 1

  fi

  if [ -s "$file" ]; then

    if ! unix2dos -q "$file"; then
      error "Failed to convert $file to DOS format!"
      return 1
    fi

    checkBatch "$file"
  fi

  return 0
}

addDriver() {

  local id="$1"
  local path="$2"
  local target="$3"
  local driver="$4"
  local folder="" desc

  if [ -z "$id" ]; then
    warn "no Windows version specified for \"$driver\" driver!" && return 0
  fi

  case "${id,,}" in
    "win7x86"* ) folder="w7/x86" ;;
    "win7x64"* ) folder="w7/amd64" ;;
    "win81x64"* ) folder="w8.1/amd64" ;;
    "win10x64"* ) folder="w10/amd64" ;;
    "win11x64"* ) folder="w11/amd64" ;;
    "win2025"* ) folder="2k25/amd64" ;;
    "win2022"* ) folder="2k22/amd64" ;;
    "win2019"* ) folder="2k19/amd64" ;;
    "win2016"* ) folder="2k16/amd64" ;;
    "win2012"* ) folder="2k12R2/amd64" ;;
    "win2008"* ) folder="2k8R2/amd64" ;;
    "win10arm64"* ) folder="w10/ARM64" ;;
    "win11arm64"* ) folder="w11/ARM64" ;;
    "winvistax86"* ) folder="2k8/x86" ;;
    "winvistax64"* ) folder="2k8/amd64" ;;
  esac

  if [ -z "$folder" ]; then
    desc=$(printVersion "$id" "$id")
    if [[ "${id,,}" != *"x86"* ]]; then
      warn "no \"$driver\" driver available for \"$desc\" !" && return 0
    else
      warn "no \"$driver\" driver available for the 32-bit version of \"$desc\" !" && return 0
    fi
  fi

  [ ! -d "$path/$driver/$folder" ] && return 0

  case "${id,,}" in
    "winvista"* )
      [[ "${driver,,}" == "viorng" ]] && return 0
      ;;
  esac

  local dest="$path/$target/$driver"
  mkdir -p "$dest" || return 1
  cp -Lr "$path/$driver/$folder/." "$dest" || return 1

  return 0
}

addDrivers() {

  local src="$1"
  local tmp="$2"
  local file="$3"
  local index="$4"
  local version="$5"
  local drivers="$tmp/drivers"

  rm -rf "$drivers"
  mkdir -p "$drivers"

  local msg="Adding drivers to image..."
  info "$msg" && html "$msg"

  if [ -z "$version" ]; then
    version="win11x64"
    warn "Windows version unknown, falling back to Windows 11 drivers..."
  fi

  if ! bsdtar -xf /var/drivers.txz -C "$drivers"; then
    error "Failed to extract drivers from archive!" && return 1
  fi

  local target="\$WinPEDriver\$"
  local dest="$drivers/$target"
  mkdir -p "$dest" || return 1

  wimlib-imagex update "$file" "$index" --command "delete --force --recursive /$target" >/dev/null || true

  local driver
  local driver_list=( qxl viofs sriov smbus qxldod viorng viostor viomem NetKVM Balloon vioscsi pvpanic vioinput viogpudo vioserial qemupciserial )

  for driver in "${driver_list[@]}"; do
    addDriver "$version" "$drivers" "$target" "$driver" || return 1
  done

  local dst="$src/\$OEM\$/\$\$/Drivers"
  mkdir -p "$dst" || return 1
  cp -Lr "$dest/." "$dst" || return 1

  case "${version,,}" in
    "win11x64"* | "win2025"* )
      # Workaround Virtio GPU driver bug
      rm -rf "$dest/viogpudo"
      ;;
  esac

  if ! wimlib-imagex update "$file" "$index" --command "add $dest /$target" >/dev/null; then
    return 1
  fi

  rm -rf "$drivers"
  return 0
}

updateImage() {

  local dir="$1"
  local asset="$2"
  local language="$3"
  local tmp="/tmp/install"
  local xml="autounattend.xml"
  local bak="${xml//.xml/.org}"
  local dat="${xml//.xml/.dat}"
  local desc path src wim name info

  if disabled "$REBUILD"; then
    info "Skipping modifications to the installation image..."
    return 1
  fi

  skipVersion "${DETECTED,,}" && return 0

  if [ ! -s "$asset" ] || [ ! -f "$asset" ]; then
    asset=""
    if ! enabled "$MANUAL"; then
      MANUAL="Y"
      warn "no answer file provided, $FB."
    fi
  fi

  rm -rf "$tmp" || return 1
  mkdir -p "$tmp" || return 1

  src=$(find "$dir" -maxdepth 1 -type d -iname sources -print -quit) || return 1

  if [ ! -d "$src" ]; then
    error "failed to locate 'sources' folder in ISO image, $FB"
    return 1
  fi

  wim=$(find "$src" -maxdepth 1 -type f \( -iname boot.wim -or -iname boot.esd \) -print -quit) || return 1

  if [ ! -f "$wim" ]; then
    error "failed to locate 'boot.wim' or 'boot.esd' in ISO image, $FB"
    return 1
  fi

  local idx="1"

  if ! info=$(wimlib-imagex info -xml "$wim" | iconv -f UTF-16LE -t UTF-8); then
    warn "failed to read boot image information, $FB"
    MANUAL="Y"
    info=""
  fi

  if [[ "${info^^}" == *"<IMAGE INDEX=\"2\">"* ]]; then
    idx="2"
  fi

  if ! addDrivers "$src" "$tmp" "$wim" "$idx" "$DETECTED"; then
    error "Failed to add drivers to image!"
  fi

  if ! addFolder "$src"; then
    error "Failed to add OEM folder to image!"
  fi

  if wimlib-imagex extract "$wim" "$idx" "/$xml" "--dest-dir=$tmp" >/dev/null 2>&1; then
    if ! wimlib-imagex extract "$wim" "$idx" "/$dat" "--dest-dir=$tmp" >/dev/null 2>&1; then
      if ! wimlib-imagex extract "$wim" "$idx" "/$bak" "--dest-dir=$tmp" >/dev/null 2>&1; then
        if ! wimlib-imagex update "$wim" "$idx" --command "rename /$xml /$bak" > /dev/null; then
          warn "failed to backup original answer file ($xml)."
        fi
      fi
    fi
  fi

  if ! enabled "$MANUAL"; then

    name=$(basename "$asset") || return 1
    local answer="$tmp/$name"

    info "Adding $name for automatic installation..."

    if ! cp "$asset" "$answer"; then
      error "Failed to copy answer file to $answer."
      return 1
    fi

    removeGeneratedXML "$asset" || return 1

    if [ -n "${CUSTOM_XML:-}" ]; then

      if ! xmllint --nonet --noout "$answer"; then
        error "The custom answer file is not valid XML!"
        return 1
      fi

    else

      if ! updateXML "$answer" "$language"; then
        error "Failed to update answer file: $answer"
        return 1
      fi

    fi

    if ! wimlib-imagex update "$wim" "$idx" --command "add $answer /$xml" > /dev/null; then
      MANUAL="Y"
      warn "failed to add answer file ($name) to ISO image, $FB"
    else
      wimlib-imagex update "$wim" "$idx" --command "add $answer /$dat" > /dev/null || true
    fi

  fi

  if enabled "$MANUAL"; then

    removeGeneratedXML "$asset" || return 1

    wimlib-imagex update "$wim" "$idx" --command "delete --force /$xml" > /dev/null || true

    if wimlib-imagex extract "$wim" "$idx" "/$bak" "--dest-dir=$tmp" >/dev/null 2>&1; then
      if ! wimlib-imagex update "$wim" "$idx" --command "add $tmp/$bak /$xml" > /dev/null; then
        warn "failed to restore original answer file ($bak)."
      fi
    fi

  fi

  name="$xml"
  enabled "$MANUAL" && name="$bak"
  path=$(find "$dir" -maxdepth 1 -type f -iname "$name" -print -quit) || return 1

  if [ -f "$path" ]; then
    if ! enabled "$MANUAL"; then
      if ! mv -f "$path" "${path%.*}.org"; then
        error "Failed to rename answer file: $path"
        return 1
      fi
    else
      if ! mv -f "$path" "${path%.*}.xml"; then
        error "Failed to rename answer file: $path"
        return 1
      fi
    fi
  fi

  rm -rf "$tmp" || return 1
  return 0
}

removeImage() {

  local iso="$1"

  [ ! -f "$iso" ] && return 0
  [ -n "$CUSTOM" ] && return 0

  rm -f "$iso" 2> /dev/null || warn "failed to remove $iso !"

  return 0
}

bootWindows() {

  ARGS=$(readState "$STORAGE/windows.args") || return 1

  if [ -n "$ARGS" ]; then
    ARGUMENTS="$ARGS ${ARGUMENTS:-}"
  fi

  restoreState "VGA" "$STORAGE/windows.vga" || return 1
  restoreState "USB" "$STORAGE/windows.usb" || return 1
  restoreState "SOUND" "$STORAGE/windows.sound" || return 1
  restoreState "ADAPTER" "$STORAGE/windows.net" || return 1
  restoreState "CPU_MODEL" "$STORAGE/windows.cpu" || return 1
  restoreState "DISK_TYPE" "$STORAGE/windows.type" || return 1
  restoreState "BOOT_MODE" "$STORAGE/windows.mode" "Y" || return 1

  if [[ "${PLATFORM,,}" == "x64" ]]; then
    restoreState "MACHINE" "$STORAGE/windows.old" "Y" || return 1
  fi

  return 0
}

######################################

! parseVersion && exit 58
! parseLanguage && exit 56
! detectCustom && exit 59

if ! startInstall; then
  bootWindows && return 0
  exit 68
fi

if [ ! -s "$ISO" ] || [ ! -f "$ISO" ]; then
  if ! downloadImage "$ISO" "$VERSION" "$LANGUAGE"; then
    rm -f "$ISO" 2> /dev/null || true
    exit 61
  fi
fi

DIR="$TMP/unpack"

if ! extractImage "$ISO" "$DIR" "$VERSION"; then
  rm -f "$ISO" 2> /dev/null || true
  exit 62
fi

if ! detectImage "$DIR" "$VERSION"; then
  abortInstall "$DIR" "$ISO" && return 0
  exit 60
fi

if ! prepareImage "$ISO" "$DIR"; then
  abortInstall "$DIR" "$ISO" && return 0
  exit 66
fi

if ! updateImage "$DIR" "$XML" "$LANGUAGE"; then
  abortInstall "$DIR" "$ISO" && return 0
  exit 63
fi

if ! removeImage "$ISO"; then
  exit 64
fi

if ! buildImage "$DIR"; then
  exit 65
fi

if ! finishInstall "$BOOT" "N"; then
  exit 69
fi

return 0