import { WRN_RUNTIME_TARGETS } from "./spec.ts";

/**
 * Recursive-descent parser for `.wrn`, producing a small AST.
 *
 * Grammar (subset of the vision, but real):
 *
 *   page <Name> {
 *     types { <TypeScript declarations> }
 *     props { <ident>: <type> [= <expr>] } // no default means required
 *     state <ident>: <type> = <expr>       // type annotation is optional
 *     view { <html> }                   // plain HTML (see parseHtmlView)
 *     seo { title = "Home" description = "..." }
 *     ssr { api <name> <METHOD> <path> { <render js> } functions { <raw js> } }
 *     client { api <name> <METHOD> <path> { <render js> } functions { <raw js> } }
 *     style { <raw css> }               // zero or more, inlined with the page
 *     functions { <raw js> }            // zero or more, shared helpers
 *     api <METHOD> <path> { <raw js> }  // zero or more
 *     realtime <name> { on <evt>(<args>) { <raw js> } * }  // zero or more
 *   }
 *
 * The `view` block is written as ordinary HTML — nothing new to learn. Text may
 * contain `{expr}` interpolation, attributes may be hyphenated (`data-*`), and
 * `@event="..."` declares a client event binding. See `parseHtmlView`.
 */

import { Lexer, LexError, type Token } from "./tokenizer.ts";
import { validateTypedInitializer } from "./types.ts";
import {
  parseComputedDeclarations,
  parseOutputs,
  parsePersist,
  parseRuntimeFunctions,
  parseStateDeclarations,
  parseStoreLifecycle,
  parseStructuredImports,
  type OutputDecl,
  type PersistDecl,
  type RuntimeFunctionDecl,
  type StateRuntime,
  type StoreKind,
  type StoreLifecycleDecl,
  type StructuredImportDecl,
} from "./v060.ts";

export interface StateDecl {
  name: string;
  /** Runtime visibility. Legacy declarations are shared. */
  runtime: StateRuntime;
  /** Explicit TypeScript-style type annotation, when supplied. */
  valueType?: string;
  /** Raw JS initializer expression, e.g. `0` or `'x'`. */
  expr: string;
}

export interface ComputedDecl {
  name: string;
  valueType?: string;
  expr: string;
}

export interface EffectBlock {
  body: string;
}

export interface LoadBlock {
  mode: "server" | "client";
  name?: string;
  dependsOn?: string[];
  deferred?: boolean;
  body: string;
}

export interface ActionBlock {
  name: string;
  args: string[];
  schema?: string;
  body: string;
}

export interface Attr {
  name: string;
  value: string;
  /** True for `@event` bindings (vs. plain HTML attributes). */
  event: boolean;
  /** True for a valueless boolean attribute, e.g. `<button disabled>`. */
  boolean?: boolean;
}

/**
 * HTML void elements: they have no children and no closing tag.
 * @see https://html.spec.whatwg.org/multipage/syntax.html#void-elements
 */
export const VOID_ELEMENTS = new Set([
  "area",
  "base",
  "br",
  "col",
  "embed",
  "hr",
  "img",
  "input",
  "link",
  "meta",
  "param",
  "source",
  "track",
  "wbr",
]);

export type ViewNode =
  | { type: "text"; value: string }
  | { type: "element"; tag: string; attrs: Attr[]; children: ViewNode[] }
  /**
   * A server-side loop: `{#each <list> as <item>[, <index>] [key <expr>]} …body… {:empty} …empty… {/each}`.
   * `list` is a JS expression (evaluated on the server, may reference an `ssr` data
   * binding). The `body` is rendered once per item with `{item.field}` interpolation;
   * `empty` renders when the list is empty. See codegen `compileEach`.
   */
  | {
      type: "each";
      list: string;
      item: string;
      index?: string;
      key?: string;
      body: ViewNode[];
      empty: ViewNode[];
    }
  /**
   * A server-side conditional: `{#if <expr>} … {:else if <expr>} … {:else} … {/if}`.
   * Rendered branches are chosen on the server. Each branch's `cond` is a JS expression
   * (`null` for the final `{:else}`); the first truthy branch renders. See `compileIfExpr`.
   */
  | { type: "if"; branches: { cond: string | null; body: ViewNode[] }[] };

export interface ApiBlock {
  method: string;
  path: string;
  body: string;
}

export type SeoBlock = Record<string, string>;

export type DataMode = "ssr" | "client";

export interface DataApiBlock {
  mode: DataMode;
  name: string;
  method: string;
  path: string;
  body: string;
}

export interface ModeFunctionsBlock {
  mode: DataMode;
  body: string;
}

export type LifecycleHookName = "mount" | "update" | "unmount";

export interface LifecycleBlock {
  mount?: string;
  update?: string;
  unmount?: string;
}

export interface WatchBlock {
  state: string;
  body: string;
}

export interface RealtimeHandler {
  event: string;
  args: string[];
  body: string;
}

export interface RealtimeBlock {
  name: string;
  handlers: RealtimeHandler[];
}

export interface PropDecl {
  name: string;
  /** Explicit TypeScript-style type annotation, when supplied. */
  valueType?: string;
  /** Props without a default are required. */
  required: boolean;
  /** Raw JS default expression, e.g. `0` or `'Count'`. Its type drives coercion. */
  default: string;
}

export interface EventDecl {
  /** Legacy public event declaration retained for compatibility. */
  name: string;
}

export interface PageAst {
  type: "page";
  /** Static ES module imports declared before the WRN root declaration. */
  imports: string[];
  structuredImports: StructuredImportDecl[];
  /**
   * `page` is a route, `component` is a reusable fragment,
   * and `layout` is a reusable page wrapper.
   */
  kind: "page" | "component" | "layout" | "global-store" | "page-store";
  storeKind?: StoreKind;
  name: string;
  /** Name of the page layout (`app/layouts/<layout>.wrn`), if the page sets one. */
  layout?: string;
  layoutIsSymbol?: boolean;
  /** Execution boundary metadata. Defaults to universal. */
  runtime?: "server" | "client" | "universal" | "edge" | "worker" | "service-worker";
  /** Explicit rendering policy; `hybrid` is the default SSR + optional hydration behavior. */
  renderMode?: "static" | "server" | "hybrid" | "client" | "partial-static";
  /** Client hydration strategy. Defaults to load when interactivity is present. */
  hydrate?: string;
  /** Declarative framework cache policy. */
  cache?: Record<string, string>;
  /** Declared component props (empty for pages). */
  props: PropDecl[];
  /** Legacy public events exposed by a reusable component. */
  events: EventDecl[];
  /** Canonical typed callable outputs. */
  outputs: OutputDecl[];
  /** Raw declarations from `types { ... }`, emitted as TypeScript. */
  types: string[];
  states: StateDecl[];
  computed: ComputedDecl[];
  effects: EffectBlock[];
  loads: LoadBlock[];
  actions: ActionBlock[];
  security: Record<string, string>;
  navigation: Record<string, string>;
  seo: SeoBlock;
  view: ViewNode[];
  styles: string[];
  functions: string[];
  runtimeFunctions: RuntimeFunctionDecl[];
  dataApis: DataApiBlock[];
  modeFunctions: ModeFunctionsBlock[];
  lifecycle: LifecycleBlock;
  storeLifecycle: StoreLifecycleDecl;
  persist?: PersistDecl;
  watches: WatchBlock[];
  apis: ApiBlock[];
  realtimes: RealtimeBlock[];
}

export class ParseError extends Error {
  readonly code: string;
  readonly offset?: number;

  constructor(message: string, code = "WRN-PARSE-001") {
    super(message);
    this.name = "ParseError";
    this.code = code;
    const match = /offset\s+(\d+)/i.exec(message);
    this.offset = match ? Number(match[1]) : undefined;
  }
}

function parseSeoBlock(body: string): SeoBlock {
  const out: SeoBlock = {};
  const pair =
    /([A-Za-z][A-Za-z0-9_-]*)\s*=\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|([^\n;]+))/g;
  for (const match of body.matchAll(pair)) {
    const key = match[1]!;
    const rawValue = match[2] ?? match[3] ?? match[4] ?? "";
    out[key] = unescapeSeoValue(rawValue.trim());
  }
  return out;
}

function unescapeSeoValue(value: string): string {
  return value.replace(/\\(["'\\nrt])/g, (_match, ch: string) => {
    if (ch === "n") return "\n";
    if (ch === "r") return "\r";
    if (ch === "t") return "\t";
    return ch;
  });
}

export function parse(source: string): PageAst {
  const lx = new Lexer(source);

  const imports: string[] = [];
  const importPattern = /import\s+(?:type\s+)?(?:[\s\S]*?\s+from\s+)?["'][^"'\r\n]+["']\s*;?/y;
  while (true) {
    while (/\s/u.test(source[lx.pos] ?? "")) lx.pos++;
    if (source.startsWith("//", lx.pos)) {
      while (lx.pos < source.length && source[lx.pos] !== "\n") lx.pos++;
      continue;
    }
    importPattern.lastIndex = lx.pos;
    const statement = importPattern.exec(source);
    if (!statement) break;
    imports.push(statement[0].trim());
    lx.pos = importPattern.lastIndex;
  }

  const expect = (type: Token["type"]): Token => {
    const t = lx.next();
    if (t.type !== type) {
      throw new ParseError(`Expected ${type} but got '${t.value || t.type}' at offset ${t.pos}`);
    }
    return t;
  };
  const expectKeyword = (kw: string): void => {
    const t = lx.next();
    if (t.type !== "ident" || t.value !== kw) {
      throw new ParseError(`Expected '${kw}' but got '${t.value || t.type}' at offset ${t.pos}`);
    }
  };

  try {
    // A file may contain a page, component, layout, global store, or page store.
    const opener = lx.next();
    if (
      opener.type !== "ident" ||
      !["page", "component", "layout", "global"].includes(opener.value)
    ) {
      throw new ParseError(
        `Expected 'page', 'component', 'layout', 'global store', or 'page store' but got '${
          opener.value || opener.type
        }' at offset ${opener.pos}`,
      );
    }

    let kind: PageAst["kind"];
    let storeKind: StoreKind | undefined;
    let name: string;
    if (opener.value === "global") {
      expectKeyword("store");
      kind = "global-store";
      storeKind = "global";
      name = expect("ident").value;
    } else if (
      opener.value === "page" &&
      lx.peek().type === "ident" &&
      lx.peek().value === "store"
    ) {
      lx.next();
      kind = "page-store";
      storeKind = "page";
      name = expect("ident").value;
    } else {
      kind = opener.value as "page" | "component" | "layout";
      name = expect("ident").value;
    }
    expect("lbrace");

    let layout: string | undefined;
    let layoutIsSymbol = false;
    let runtime: PageAst["runtime"];
    let renderMode: PageAst["renderMode"];
    let hydrate: string | undefined;
    const props: PropDecl[] = [];
    const events: EventDecl[] = [];
    const outputs: OutputDecl[] = [];
    const types: string[] = [];
    const states: StateDecl[] = [];
    const computed: ComputedDecl[] = [];
    const effects: EffectBlock[] = [];
    const loads: LoadBlock[] = [];
    const actions: ActionBlock[] = [];
    const security: Record<string, string> = {};
    const cache: Record<string, string> = {};
    const navigation: Record<string, string> = {};
    const seo: SeoBlock = {};
    const view: ViewNode[] = [];
    const styles: string[] = [];
    const functions: string[] = [];
    const runtimeFunctions: RuntimeFunctionDecl[] = [];
    const dataApis: DataApiBlock[] = [];
    const modeFunctions: ModeFunctionsBlock[] = [];
    const lifecycle: LifecycleBlock = {};
    let storeLifecycle: StoreLifecycleDecl = {};
    let persist: PersistDecl | undefined;
    const watches: WatchBlock[] = [];
    const apis: ApiBlock[] = [];
    const realtimes: RealtimeBlock[] = [];

    while (lx.peek().type !== "rbrace") {
      const kw = lx.peek();
      if (kw.type === "eof") throw new ParseError(`Unexpected end of input inside ${kind}`);
      if (kw.type !== "ident") {
        throw new ParseError(`Expected a ${kind} member keyword at offset ${kw.pos}`);
      }
      switch (kw.value) {
        case "layout": {
          // layout = "public" — selects app/layouts/<name>.wrn for this page.
          lx.next();
          expect("eq");
          const layoutToken = lx.next();
          if (layoutToken.type !== "string" && layoutToken.type !== "ident") {
            throw new ParseError(
              `Expected a layout string or imported symbol at offset ${layoutToken.pos}`,
            );
          }
          layout = layoutToken.value;
          layoutIsSymbol = layoutToken.type === "ident";
          break;
        }
        case "runtime": {
          lx.next();
          expect("eq");
          const value = expect("string").value;
          if (!WRN_RUNTIME_TARGETS.includes(value as (typeof WRN_RUNTIME_TARGETS)[number])) {
            throw new ParseError(
              `Unknown runtime target '${value}' at offset ${kw.pos}`,
              "WRN-RUNTIME-TARGET",
            );
          }
          runtime = value as PageAst["runtime"];
          break;
        }
        case "render": {
          lx.next();
          expect("eq");
          const value = expect("string").value;
          if (!["static", "server", "hybrid", "client", "partial-static"].includes(value))
            throw new ParseError(
              `Unknown render mode '${value}' at offset ${kw.pos}`,
              "WRN-RENDER-MODE",
            );
          renderMode = value as PageAst["renderMode"];
          break;
        }
        case "hydrate": {
          lx.next();
          expect("eq");
          const value = expect("string").value;
          hydrate = value === "never" ? "none" : value;
          break;
        }
        case "props": {
          // props { name: Type = <default>; @event name = function }
          lx.next();
          expect("lbrace");
          while (true) {
            if (lx.startsWithBlockComment()) {
              throw new ParseError(
                "Block comments are not allowed inside props {}; use // line comments instead",
                "WRN-PROPS-BLOCK-COMMENT",
              );
            }
            if (lx.peek().type === "rbrace") break;
            const t = lx.peek();
            if (t.type === "eof") throw new ParseError("Unexpected end of input inside props");
            if (t.type === "at") {
              lx.next();
              const declarationKind = expect("ident");
              if (declarationKind.value !== "event") {
                throw new ParseError(
                  `Expected '@event' but got '@${declarationKind.value}' at offset ${declarationKind.pos}`,
                );
              }
              const eventName = expect("ident").value;
              expect("eq");
              const marker = lx.readPropInitializer();
              if (marker !== "function") {
                throw new ParseError(
                  `Event '${eventName}' must be declared as '@event ${eventName} = function'`,
                );
              }
              events.push({ name: eventName });
              continue;
            }
            if (t.type !== "ident") {
              throw new ParseError(`Expected a prop name at offset ${t.pos}`);
            }
            const pName = expect("ident").value;
            const optional = lx.peek().type === "question" ? (lx.next(), true) : false;
            let valueType: string | undefined;
            let hasDefault = false;
            if (lx.peek().type === "colon") {
              lx.next();
              const annotation = lx.readTypeAnnotation();
              valueType = annotation.type;
              hasDefault = annotation.hasDefault;
            } else {
              expect("eq");
              hasDefault = true;
            }
            const defaultValue = hasDefault ? lx.readPropInitializer() : "undefined";
            props.push({
              name: pName,
              valueType,
              required: !hasDefault && !optional,
              default: defaultValue,
            });
          }
          expect("rbrace");
          break;
        }
        case "state": {
          lx.next();
          if (lx.peek().type === "lbrace") {
            const grouped = parseStateDeclarations(lx.readBalancedBraces(), "shared");
            states.push(...grouped);
            break;
          }
          const sName = expect("ident").value;
          let valueType: string | undefined;
          if (lx.peek().type === "colon") {
            lx.next();
            const annotation = lx.readTypeAnnotation();
            valueType = annotation.type;
            if (!annotation.hasDefault)
              throw new ParseError(`State '${sName}' requires an initializer`);
          } else {
            expect("eq");
          }
          states.push({
            name: sName,
            valueType,
            expr: lx.readPropInitializer(),
            runtime: "shared",
          });
          break;
        }
        case "computed": {
          lx.next();
          if (lx.peek().type === "lbrace") {
            computed.push(...parseComputedDeclarations(lx.readBalancedBraces()));
          } else {
            const cName = expect("ident").value;
            let valueType: string | undefined;
            if (lx.peek().type === "colon") {
              lx.next();
              const annotation = lx.readTypeAnnotation();
              valueType = annotation.type;
              if (!annotation.hasDefault)
                throw new ParseError(`Computed '${cName}' requires an expression`);
            } else expect("eq");
            computed.push({ name: cName, valueType, expr: lx.readPropInitializer() });
          }
          break;
        }
        case "outputs": {
          lx.next();
          try {
            outputs.push(...parseOutputs(lx.readBalancedBraces()));
          } catch (error) {
            throw new ParseError(
              error instanceof Error ? error.message : String(error),
              "WRN-OUTPUT-DECLARATION",
            );
          }
          break;
        }
        case "effect": {
          lx.next();
          effects.push({ body: lx.readBalancedBraces() });
          break;
        }
        case "types": {
          lx.next();
          types.push(lx.readBalancedBraces());
          break;
        }
        case "view": {
          lx.next();
          expect("lbrace");
          // The view body is plain HTML. Parse it straight off the source
          // (the token lexer isn't used for markup), then resume after the
          // block's closing `}`.
          const { nodes, endPos } = parseHtmlView(lx.src, lx.pos);
          view.push(...nodes);
          lx.pos = endPos;
          expect("rbrace");
          break;
        }
        case "seo": {
          lx.next();
          Object.assign(seo, parseSeoBlock(lx.readBalancedBraces()));
          break;
        }
        case "security": {
          lx.next();
          Object.assign(security, parseSeoBlock(lx.readBalancedBraces()));
          break;
        }
        case "navigation": {
          lx.next();
          Object.assign(navigation, parseSeoBlock(lx.readBalancedBraces()));
          break;
        }
        case "cache": {
          lx.next();
          Object.assign(cache, parseSeoBlock(lx.readBalancedBraces()));
          break;
        }
        case "load": {
          lx.next();
          const first = expect("ident");
          const mode = first.value === "client" ? "client" : "server";
          const name =
            first.value === "server" || first.value === "client"
              ? lx.peek().type === "ident"
                ? expect("ident").value
                : undefined
              : first.value;
          const dependsOn: string[] = [];
          let deferred = false;
          while (lx.peek().type === "ident") {
            if (lx.peek().value === "defer") {
              lx.next();
              deferred = true;
              continue;
            }
            if (lx.peek().value !== "after") break;
            lx.next();
            dependsOn.push(expect("ident").value);
            while (lx.peek().type === "comma") {
              lx.next();
              dependsOn.push(expect("ident").value);
            }
          }
          loads.push({
            mode,
            name,
            ...(dependsOn.length ? { dependsOn } : {}),
            ...(deferred ? { deferred: true } : {}),
            body: lx.readBalancedBraces(),
          });
          break;
        }
        case "action": {
          lx.next();
          const actionName = expect("ident").value;
          const args: string[] = [];
          if (lx.peek().type === "lparen") {
            lx.next();
            while (lx.peek().type !== "rparen") {
              args.push(expect("ident").value);
              if (lx.peek().type === "comma") lx.next();
            }
            expect("rparen");
          }
          let schema: string | undefined;
          if (lx.peek().type === "ident" && lx.peek().value === "using") {
            lx.next();
            schema = expect("ident").value;
          }
          actions.push({ name: actionName, args, schema, body: lx.readBalancedBraces() });
          break;
        }
        case "api": {
          lx.next();
          const method = expect("ident").value.toUpperCase();
          const path = lx.readPath();
          const body = lx.readBalancedBraces();
          apis.push({ method, path, body });
          break;
        }
        case "ssr":
        case "client":
        case "server": {
          const rawMode = kw.value;
          const mode: DataMode = rawMode === "client" ? "client" : "ssr";
          lx.next();
          if (
            (rawMode === "client" || rawMode === "server") &&
            lx.peek().type === "ident" &&
            lx.peek().value === "state"
          ) {
            lx.next();
            if (lx.peek().type !== "lbrace")
              throw new ParseError(`Expected a grouped ${rawMode} state block`);
            states.push(
              ...parseStateDeclarations(lx.readBalancedBraces(), rawMode as StateRuntime),
            );
            break;
          }
          if (mode === "client" && lx.peek().type === "eq") {
            lx.next();
            hydrate = expect("string").value;
            break;
          }
          expect("lbrace");
          while (lx.peek().type !== "rbrace") {
            const member = lx.peek();
            if (member.type === "eof") {
              throw new ParseError(`Unexpected end of input inside ${mode} block`);
            }
            if (member.type !== "ident") {
              throw new ParseError(`Expected a ${mode} member keyword at offset ${member.pos}`);
            }
            switch (member.value) {
              case "api": {
                lx.next();
                const name = expect("ident").value;
                const method = expect("ident").value.toUpperCase();
                const path = lx.readPath();
                const body = lx.readBalancedBraces();
                dataApis.push({ mode, name, method, path, body });
                break;
              }
              case "functions": {
                lx.next();
                modeFunctions.push({ mode, body: lx.readBalancedBraces() });
                break;
              }
              default:
                throw new ParseError(
                  `Unknown ${mode} member '${member.value}' at offset ${member.pos}`,
                );
            }
          }
          expect("rbrace");
          break;
        }
        case "shared": {
          lx.next();
          const member = expect("ident");
          if (member.value !== "state")
            throw new ParseError(`Expected 'state' after shared at offset ${member.pos}`);
          states.push(...parseStateDeclarations(lx.readBalancedBraces(), "shared"));
          break;
        }
        case "realtime": {
          lx.next();
          const rName = expect("ident").value;
          expect("lbrace");
          const handlers: RealtimeHandler[] = [];
          while (lx.peek().type !== "rbrace") {
            expectKeyword("on");
            const event = expect("ident").value;
            expect("lparen");
            const args: string[] = [];
            while (lx.peek().type !== "rparen") {
              args.push(expect("ident").value);
              if (lx.peek().type === "comma") lx.next();
            }
            expect("rparen");
            handlers.push({ event, args, body: lx.readBalancedBraces() });
          }
          expect("rbrace");
          realtimes.push({ name: rName, handlers });
          break;
        }
        case "style": {
          lx.next();
          styles.push(lx.readBalancedBraces());
          break;
        }
        case "lifecycle": {
          lx.next();
          const body = lx.readBalancedBraces();
          if (kind === "global-store" || kind === "page-store") {
            storeLifecycle = parseStoreLifecycle(body);
            const allowedStoreHooks = new Set(["serverInit", "clientInit", "hydrate", "dispose"]);
            const hookLexer = new Lexer(body);
            while (hookLexer.peek().type !== "eof") {
              const token = hookLexer.next();
              if (token.type !== "ident") {
                throw new ParseError(`Expected a lifecycle hook at offset ${token.pos}`);
              }
              if (!allowedStoreHooks.has(token.value)) {
                throw new ParseError(`Unknown store lifecycle hook '${token.value}'`);
              }
              hookLexer.readBalancedBraces();
            }
          } else {
            const allowedComponentHooks = new Set([
              "mount",
              "update",
              "unmount",
              "clientInit",
              "dispose",
            ]);
            const hookLexer = new Lexer(body);
            while (hookLexer.peek().type !== "eof") {
              const token = hookLexer.next();
              if (token.type !== "ident") {
                throw new ParseError(`Expected a lifecycle hook at offset ${token.pos}`);
              }
              if (!allowedComponentHooks.has(token.value)) {
                throw new ParseError(`Unknown lifecycle hook '${token.value}'`);
              }
              const hookBody = hookLexer.readBalancedBraces();
              const hook =
                token.value === "clientInit"
                  ? "mount"
                  : token.value === "dispose"
                    ? "unmount"
                    : (token.value as LifecycleHookName);
              if (lifecycle[hook] !== undefined) {
                throw new ParseError(`Duplicate lifecycle hook '${hook}'`);
              }
              lifecycle[hook] = hookBody;
            }
          }
          break;
        }
        case "watch": {
          lx.next();

          const stateName = expect("ident").value;
          const body = lx.readBalancedBraces();

          watches.push({
            state: stateName,
            body,
          });

          break;
        }
        case "functions": {
          lx.next();
          const body = lx.readBalancedBraces();
          functions.push(body);
          try {
            runtimeFunctions.push(...parseRuntimeFunctions(body));
          } catch (error) {
            throw new ParseError(
              error instanceof Error ? error.message : String(error),
              "WRN-FUNCTION-DECLARATION",
            );
          }
          break;
        }
        case "persist": {
          lx.next();
          persist = parsePersist(lx.readBalancedBraces());
          break;
        }
        default:
          throw new ParseError(`Unknown page member '${kw.value}' at offset ${kw.pos}`);
      }
    }
    expect("rbrace");

    const declaredStates = new Set(states.map((state) => state.name));

    if (declaredStates.has("page")) {
      throw new ParseError(
        "State name 'page' collides with the WRN 'page' keyword; choose another state name",
        "WRN-STATE-RESERVED-NAME",
      );
    }

    for (const watcher of watches) {
      if (!declaredStates.has(watcher.state)) {
        throw new ParseError(`Cannot watch undeclared state '${watcher.state}'`);
      }
    }

    for (const prop of props) {
      const problem = validateTypedInitializer(`Prop '${prop.name}'`, prop.valueType, prop.default);
      if (problem) throw new ParseError(problem);
    }
    for (const state of states) {
      const problem = validateTypedInitializer(
        `State '${state.name}'`,
        state.valueType,
        state.expr,
      );
      if (problem) throw new ParseError(problem);
    }

    const symbols = new Set<string>();
    for (const declaration of [...props, ...states, ...computed]) {
      if (symbols.has(declaration.name)) {
        throw new ParseError(`Duplicate symbol '${declaration.name}'`, "WRN-SYMBOL-DUPLICATE");
      }
      symbols.add(declaration.name);
    }

    const outputNames = new Set<string>();
    for (const output of outputs) {
      if (outputNames.has(output.name))
        throw new ParseError(`Duplicate output '${output.name}'`, "WRN-OUTPUT-DUPLICATE");
      outputNames.add(output.name);
    }
    const functionKeys = new Set<string>();
    for (const fn of runtimeFunctions) {
      const key = `${fn.runtime}:${fn.name}`;
      if (functionKeys.has(key))
        throw new ParseError(
          `Duplicate ${fn.runtime} function '${fn.name}'`,
          "WRN-FUNCTION-DUPLICATE",
        );
      functionKeys.add(key);
    }
    const namedLoads = new Map(loads.filter((load) => load.name).map((load) => [load.name!, load]));
    for (const load of namedLoads.values()) {
      for (const dependency of load.dependsOn ?? []) {
        const dependencyLoad = namedLoads.get(dependency);
        if (!dependencyLoad)
          throw new ParseError(
            `Load '${load.name}' depends on unknown load '${dependency}'`,
            "WRN-LOAD-DEPENDENCY",
          );
        if (
          load.mode === "server" &&
          !load.deferred &&
          (dependencyLoad.mode !== "server" || dependencyLoad.deferred)
        )
          throw new ParseError(
            `Server load '${load.name}' cannot depend on deferred/client load '${dependency}'`,
            "WRN-LOAD-PHASE",
          );
      }
    }
    const visiting = new Set<string>();
    const visited = new Set<string>();
    const visitLoad = (name: string): void => {
      if (visiting.has(name))
        throw new ParseError(`Load dependency cycle includes '${name}'`, "WRN-LOAD-CYCLE");
      if (visited.has(name)) return;
      visiting.add(name);
      for (const dependency of namedLoads.get(name)?.dependsOn ?? []) visitLoad(dependency);
      visiting.delete(name);
      visited.add(name);
    };
    for (const name of namedLoads.keys()) visitLoad(name);
    return {
      type: "page",
      imports,
      structuredImports: parseStructuredImports(imports),
      kind,
      storeKind,
      name,
      layout,
      layoutIsSymbol,
      runtime,
      renderMode,
      hydrate,
      cache,
      props,
      events,
      outputs,
      types,
      states,
      computed,
      effects,
      loads,
      actions,
      security,
      navigation,
      seo,
      view,
      styles,
      functions,
      runtimeFunctions,
      dataApis,
      modeFunctions,
      lifecycle,
      storeLifecycle,
      persist,
      watches,
      apis,
      realtimes,
    };
  } catch (err) {
    if (err instanceof LexError) throw new ParseError(err.message);
    throw err;
  }
}

/**
 * Parse the body of a `view { ... }` block as plain HTML.
 *
 * `src` is the whole `.wrn` source; `pos` points just past the view block's
 * opening `{`. Returns the parsed nodes plus the index of the block's closing
 * `}` (left for the caller to consume). It is intentionally lenient — you write
 * markup the way you already know:
 *
 *   - `<tag attr="v" @event="expr">children</tag>` — elements with attributes
 *   - `<tag/>` and HTML void elements (`<br>`, `<img>`, …) — no closing tag
 *   - text may contain `{expr}` interpolation, kept verbatim for the runtime
 *   - `@event="..."` becomes a client event binding; hyphenated names are fine
 *   - `<!-- comments -->` are dropped
 *
 * `{` and `}` in text are reserved for interpolation; a lone `<` that isn't a
 * tag is treated as literal text.
 */
export function parseHtmlView(src: string, pos: number): { nodes: ViewNode[]; endPos: number } {
  let i = pos;

  const isNameStart = (c: string): boolean => /[A-Za-z_]/.test(c);

  const isTagNamePart = (c: string): boolean => /[A-Za-z0-9_$:.-]/.test(c);

  const isWs = (c: string): boolean => c === " " || c === "\t" || c === "\n" || c === "\r";

  const fail = (msg: string): never => {
    throw new ParseError(`${msg} at offset ${i}`);
  };
  const skipWs = (): void => {
    while (i < src.length && isWs(src[i]!)) i++;
  };

  /** Read a `{...}` interpolation (brace-balanced and quote-aware), braces included. */
  const readInterpolation = (): string => {
    const start = i;
    let depth = 0;
    let quote: string | null = null;
    for (; i < src.length; i++) {
      const char = src[i]!;

      if (quote) {
        if (char === "\\" && i + 1 < src.length) {
          i++;
          continue;
        }
        if (char === quote) quote = null;
        continue;
      }

      if (char === '"' || char === "'" || char === "`") {
        quote = char;
        continue;
      }

      if (char === "{") depth++;
      else if (char === "}" && --depth === 0) {
        i++;
        return src.slice(start, i);
      }
    }
    return fail("Unterminated `{` interpolation in view");
  };

  const readQuoted = (): string => {
    const quote = src[i];
    if (quote !== '"' && quote !== "'") return fail("Expected a quoted attribute value");
    i++;
    const start = i;
    while (i < src.length && src[i] !== quote) i++;
    if (i >= src.length) return fail("Unterminated attribute value");
    const value = src.slice(start, i);
    i++; // closing quote
    return value;
  };

  const readTagName = (): string => {
    if (i >= src.length || !isNameStart(src[i]!)) {
      return fail("Expected a tag name");
    }

    const start = i++;

    while (i < src.length && isTagNamePart(src[i]!)) {
      i++;
    }

    return src.slice(start, i);
  };

  const readAttributeName = (): string => {
    if (i >= src.length) {
      return fail("Expected an attribute name");
    }

    const start = i;

    while (i < src.length) {
      const char = src[i];
      const next = src[i + 1];

      if (
        char === "=" ||
        char === ">" ||
        char === '"' ||
        char === "'" ||
        char === " " ||
        char === "\t" ||
        char === "\n" ||
        char === "\r" ||
        (char === "/" && next === ">")
      ) {
        break;
      }

      i++;
    }

    if (i === start) {
      return fail("Expected an attribute name");
    }

    return src.slice(start, i);
  };

  const parseTag = (): ViewNode => {
    i++; // consume '<'
    const tag = readTagName();
    const attrs: Attr[] = [];

    for (;;) {
      skipWs();
      const c = src[i];
      if (c === undefined) return fail(`Unterminated <${tag}> tag`);
      if (c === ">") {
        i++;
        break;
      }
      if (c === "/" && src[i + 1] === ">") {
        i += 2;
        return { type: "element", tag, attrs, children: [] };
      }
      if (c === "@") {
        i++;
        const name = readAttributeName();
        skipWs();
        if (src[i] !== "=") return fail(`Expected '=' after @${name}`);
        i++;
        skipWs();
        attrs.push({ name, value: readQuoted(), event: true });
        continue;
      }
      const name = readAttributeName();
      skipWs();
      if (src[i] === "=") {
        i++;
        skipWs();
        const value =
          src[i] === '"' || src[i] === "'"
            ? readQuoted()
            : src[i] === "{"
              ? readInterpolation()
              : fail(`Expected a quoted value or {...} expression after '${name}='`);
        attrs.push({ name, value, event: false });
      } else {
        attrs.push({ name, value: "", event: false, boolean: true });
      }
    }

    if (VOID_ELEMENTS.has(tag.toLowerCase())) {
      return { type: "element", tag, attrs, children: [] };
    }

    const children = parseNodeList("element");
    // parseNodeList stops at the parent's closing tag `</`.
    if (src[i] !== "<" || src[i + 1] !== "/") return fail(`Expected </${tag}>`);
    i += 2;
    skipWs();
    const close = readTagName();
    if (close !== tag) return fail(`Mismatched </${close}>, expected </${tag}>`);
    skipWs();
    if (src[i] !== ">") return fail(`Expected '>' to close </${tag}>`);
    i++;
    return { type: "element", tag, attrs, children };
  };

  const EACH_HEADER =
    /^\{#each\s+([\s\S]+?)\s+as\s+([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*))?(?:\s+key\s+([\s\S]+?))?\s*\}$/;

  /** Parse `{#each <list> as <item>[, <index>] [key <expr>]} …body… {:empty} …empty… {/each}`. */
  function parseEach(): ViewNode {
    const header = readInterpolation(); // reads the full `{#each …}`
    const m = EACH_HEADER.exec(header);
    if (!m) return fail(`Invalid {#each …} header: ${header}`);
    const list = m[1]!.trim();
    const item = m[2]!;
    const index = m[3];
    const key = m[4]?.trim();
    const body = parseNodeList("each"); // stops at {:empty} or {/each}
    let empty: ViewNode[] = [];
    if (src.startsWith("{:empty}", i)) {
      i += "{:empty}".length;
      empty = parseNodeList("each"); // stops at {/each}
    }
    if (!src.startsWith("{/each}", i)) return fail("Expected `{/each}` to close `{#each}`");
    i += "{/each}".length;
    return { type: "each", list, item, index, key, body, empty };
  }

  /** Parse `{#if <expr>} … {:else if <expr>} … {:else} … {/if}`. */
  function parseIf(): ViewNode {
    const header = readInterpolation(); // reads the full `{#if …}`
    const m = /^\{#if\s+([\s\S]+?)\s*\}$/.exec(header);
    if (!m) return fail(`Invalid {#if …} header: ${header}`);
    const branches: { cond: string | null; body: ViewNode[] }[] = [
      { cond: m[1]!.trim(), body: parseNodeList("if") },
    ];
    for (;;) {
      if (src.startsWith("{:else if", i)) {
        const h = readInterpolation();
        const mm = /^\{:else if\s+([\s\S]+?)\s*\}$/.exec(h);
        if (!mm) return fail(`Invalid {:else if …}: ${h}`);
        branches.push({ cond: mm[1]!.trim(), body: parseNodeList("if") });
        continue;
      }
      if (src.startsWith("{:else}", i)) {
        i += "{:else}".length;
        branches.push({ cond: null, body: parseNodeList("if") });
        continue;
      }
      break;
    }
    if (!src.startsWith("{/if}", i)) return fail("Expected `{/if}` to close `{#if}`");
    i += "{/if}".length;
    return { type: "if", branches };
  }

  /**
   * Parse a run of nodes. `mode` sets the terminator:
   *   - "root":    stops at the view block's closing `}`
   *   - "element": stops at the parent element's closing tag (`</`)
   *   - "each":    stops (without consuming) at `{:empty}` or `{/each}`
   *   - "if":      stops (without consuming) at `{:else …}` or `{/if}`
   * `{#each …}` and `{#if …}` start nested blocks in any mode.
   */
  function parseNodeList(mode: "root" | "element" | "each" | "if"): ViewNode[] {
    const nodes: ViewNode[] = [];
    let text = "";
    const flush = (): void => {
      if (text.length > 0) {
        nodes.push({ type: "text", value: text });
        text = "";
      }
    };

    for (;;) {
      if (i >= src.length) {
        return mode === "root"
          ? fail("Unexpected end of view (missing `}`)")
          : fail("Unclosed block");
      }
      const c = src[i]!;

      if (c === "<") {
        const next = src[i + 1];
        if (next === "/") {
          flush();
          break; // parent's closing tag
        }
        if (src.startsWith("<!--", i)) {
          const end = src.indexOf("-->", i + 4);
          i = end === -1 ? src.length : end + 3;
          continue;
        }
        if (next !== undefined && (isNameStart(next) || next === "!")) {
          flush();
          nodes.push(parseTag());
          continue;
        }
        // A lone `<` that doesn't start a tag: treat as literal text.
        text += c;
        i++;
        continue;
      }

      if (c === "{") {
        if (src.startsWith("{#each", i)) {
          flush();
          nodes.push(parseEach());
          continue;
        }
        if (src.startsWith("{#if", i)) {
          flush();
          nodes.push(parseIf());
          continue;
        }
        if (mode === "each" && (src.startsWith("{:empty}", i) || src.startsWith("{/each}", i))) {
          flush();
          break; // loop-section terminator; left for parseEach
        }
        if (mode === "if" && (src.startsWith("{:else", i) || src.startsWith("{/if}", i))) {
          flush();
          break; // conditional-section terminator; left for parseIf
        }
        text += readInterpolation();
        continue;
      }

      if (c === "}" && mode === "root") {
        flush();
        break; // view terminator; leave `}` for the caller
      }

      text += c;
      i++;
    }

    return nodes;
  }

  const nodes = parseNodeList("root");
  return { nodes, endPos: i };
}
