import {
  $atom,
  $hook,
  $inject,
  $state,
  Alepha,
  AlephaError,
  coerceObject,
  OPTIONS,
  PipelineHandler,
  type TSchema,
  z,
} from "alepha";
import { DateTimeProvider } from "alepha/datetime";
import { $logger } from "alepha/logger";
import { AlephaContext, ClientOnly } from "alepha/react";
import type { Head } from "alepha/react/head";
import { currentUserAtom } from "alepha/security";
import { createElement, type ReactNode, StrictMode } from "react";
import ErrorViewer from "../components/ErrorViewer.tsx";
import NestedView from "../components/NestedView.tsx";
import NotFoundPage from "../components/NotFound.tsx";
import { RouterLayerContext } from "../contexts/RouterLayerContext.ts";
import { Redirection } from "../errors/Redirection.ts";
import {
  $page,
  type ErrorHandler,
  type PagePrimitive,
  type PagePrimitiveOptions,
} from "../primitives/$page.ts";
import { RootComponentsProvider } from "./RootComponentsProvider.ts";
import { RouterLocaleProvider } from "./RouterLocaleProvider.ts";

// -------------------------------------------------------------------------------------------------------------------

export const reactPageOptions = $atom({
  name: "alepha.react.page.options",
  description: "Configuration options for the React page provider.",
  schema: z.object({
    /**
     * Enable React StrictMode wrapper.
     */
    strictMode: z.boolean().default(true),
    /**
     * RegExp pattern (as string) to detect file-like URLs (e.g. /hello.txt, /wp-login.php).
     * When a request hits the catch-all wildcard route and matches this pattern,
     * SSR is skipped and a plain 404 response is returned instead.
     *
     * Set to empty string to disable this behavior.
     *
     * @default "\\.[a-zA-Z0-9]{1,10}$"
     */
    staticFilePattern: z.string(),
  }),
  default: {
    strictMode: true,
    staticFilePattern: "\\.[a-zA-Z0-9]{1,10}$",
  },
});

// -------------------------------------------------------------------------------------------------------------------

/**
 * Handle page routes for React applications. (Browser and Server)
 */
export class ReactPageProvider {
  protected readonly dateTimeProvider = $inject(DateTimeProvider);
  protected readonly log = $logger();
  protected readonly options = $state(reactPageOptions);
  protected readonly alepha = $inject(Alepha);
  protected readonly rootComponentsProvider = $inject(RootComponentsProvider);
  protected readonly localeProvider = $inject(RouterLocaleProvider);
  protected readonly pages: PageRoute[] = [];
  protected nextIdCursor = 0;

  protected readonly configure = $hook({
    on: "configure",
    handler: () => {
      let hasNotFoundHandler = false;
      const pages = this.alepha.primitives($page);

      const hasParent = (it: PagePrimitive) => {
        if (it.options.parent) {
          return true;
        }

        for (const page of pages) {
          const children = page.options.children
            ? Array.isArray(page.options.children)
              ? page.options.children
              : page.options.children()
            : [];
          if (children.includes(it)) {
            return true;
          }
        }
      };

      for (const page of pages) {
        if (page.options.path === "/*") {
          hasNotFoundHandler = true;
        }

        // skip children, we only want root pages
        if (hasParent(page)) {
          continue;
        }

        this.add(this.map(pages, page));
      }

      if (!hasNotFoundHandler && pages.length > 0) {
        // add a default 404 page if not already defined
        this.add({
          path: "/*",
          name: "notFound",
          component: NotFoundPage,
          onServerResponse: ({ reply }) => {
            reply.status = 404;
          },
        });
      }
    },
  });

  // -------------------------------------------------------------------------------------------------------------------

  public getPages(): PageRoute[] {
    return this.pages;
  }

  public getConcretePages(): ConcretePageRoute[] {
    const pages: ConcretePageRoute[] = [];
    for (const page of this.pages) {
      if (page.children && page.children.length > 0) {
        continue;
      }

      // check if the page has dynamic params
      const fullPath = this.pathname(page.name);
      if (fullPath.includes(":") || fullPath.includes("*")) {
        if (typeof page.static === "object") {
          const entries = page.static.entries;
          if (entries && entries.length > 0) {
            for (const entry of entries) {
              const params = entry.params as Record<string, string>;
              const path = this.compile(page.path ?? "", params);
              if (!path.includes(":") && !path.includes("*")) {
                pages.push({
                  ...page,
                  name: params[Object.keys(params)[0]],
                  staticName: page.name,
                  path,
                  ...entry,
                });
              }
            }
          }
        }

        continue;
      }

      pages.push(page);
    }
    return pages;
  }

  public page(name: string): PageRoute {
    for (const page of this.pages) {
      if (page.name === name) {
        return page;
      }
    }

    throw new AlephaError(`Page '${name}' not found`);
  }

  /**
   * Find a route by name anywhere in the tree (including nested children).
   * Returns undefined if no page with that name exists.
   */
  protected findRoute(
    name: string,
    routes: PageRouteEntry[] = this.pages,
  ): PageRoute | undefined {
    for (const route of routes as PageRoute[]) {
      if (route.name === name) {
        return route;
      }
      if (route.children?.length) {
        const found = this.findRoute(name, route.children);
        if (found) {
          return found;
        }
      }
    }
    return undefined;
  }

  public pathname(
    name: string,
    options: {
      params?: Record<string, string>;
      query?: Record<string, string>;
    } = {},
  ) {
    const page = this.page(name);
    if (!page) {
      throw new AlephaError(`Page ${name} not found`);
    }

    let url = page.path ?? "";
    let parent = page.parent;
    while (parent) {
      url = `${parent.path ?? ""}/${url}`;
      parent = parent.parent;
    }

    url = this.compile(url, options.params ?? {});
    url = url.replace(/\/\/+/g, "/") || "/";

    // Apply the active locale prefix (e.g. `/about` → `/fr/about`) to the path
    // portion only, before any query string is appended. A no-op unless
    // `routing: "prefix"` is enabled on the i18n module.
    url = this.localeProvider.withPrefix(url);

    if (options.query) {
      const query = new URLSearchParams(options.query);
      if (query.toString()) {
        url += `?${query.toString()}`;
      }
    }

    return url;
  }

  public url(
    name: string,
    options: { params?: Record<string, string>; host?: string } = {},
  ): URL {
    return new URL(
      this.pathname(name, options),
      // use provided base or default to http://localhost
      options.host ?? `http://localhost`,
    );
  }

  public root(state: ReactRouterState): ReactNode {
    const root = createElement(
      AlephaContext.Provider,
      { value: this.alepha },
      createElement(NestedView, {}, state.layers[0]?.element),
      ...this.rootComponentsProvider.rootComponents,
    );

    if (this.options.strictMode) {
      return createElement(StrictMode, {}, root);
    }

    return root;
  }

  protected convertStringObjectToObject = (
    schema?: TSchema,
    value?: any,
  ): any => {
    if (z.schema.isObject(schema) && typeof value === "object") {
      for (const key in schema.properties) {
        // Peel optional/nullable/default wrappers so a field declared as
        // `z.object(...).optional()` is still recognised as an object whose
        // JSON-encoded query value needs parsing.
        const propSchema = z.schema.unwrap(schema.properties[key]);
        if (z.schema.isObject(propSchema) && typeof value[key] === "string") {
          try {
            value[key] = this.alepha.codec.decode(
              propSchema,
              decodeURIComponent(value[key]),
            );
          } catch (e) {
            // ignore
          }
        }
      }
    }
    return value;
  };

  /**
   * Create a new RouterState based on a given route and request.
   * This method resolves the layers for the route, applying any query and params schemas defined in the route.
   * It also handles errors and redirects.
   */
  public async createLayers(
    route: PageRoute,
    state: ReactRouterState,
    previous: PreviousLayerData[] = [],
  ): Promise<CreateLayersResult> {
    let context: Record<string, any> = {}; // all props
    const stack: Array<RouterStackItem> = [{ route }]; // stack of routes

    let parent = route.parent;
    while (parent) {
      stack.unshift({ route: parent });
      parent = parent.parent;
    }

    let forceRefresh = false;

    for (let i = 0; i < stack.length; i++) {
      const it = stack[i];
      const route = it.route;
      const config: Record<string, any> = {};

      try {
        this.convertStringObjectToObject(route.schema?.query, state.query);
        config.query = route.schema?.query
          ? this.alepha.codec.decode(
              route.schema.query,
              // Query params arrive as strings from the URL; coerce declared
              // scalar fields (number/integer/boolean) to their schema type
              // before strict validation, mirroring the HTTP server boundary.
              coerceObject(route.schema.query, state.query),
            )
          : {};
      } catch (e) {
        it.error = e instanceof Error ? e : new Error(String(e));
        break;
      }

      try {
        config.params = route.schema?.params
          ? this.alepha.codec.decode(
              route.schema.params,
              // URL path params arrive as strings; coerce declared scalar
              // fields (number/integer/boolean) to their schema type before
              // strict validation, mirroring `query` above and the HTTP server.
              coerceObject(route.schema.params, state.params),
            )
          : {};
      } catch (e) {
        it.error = e instanceof Error ? e : new Error(String(e));
        break;
      }

      // save config
      it.config = {
        ...config,
      };

      // check if previous layer is the same, reuse if possible
      if (previous?.[i] && !forceRefresh && previous[i].name === route.name) {
        const url = (str?: string) => (str ? str.replace(/\/\/+/g, "/") : "/");

        const prev = JSON.stringify({
          part: url(previous[i].part),
          params: previous[i].config?.params ?? {},
        });

        const curr = JSON.stringify({
          part: url(route.path),
          params: config.params ?? {},
        });

        if (prev === curr) {
          // part is the same, reuse previous layer
          it.props = previous[i].props;
          it.error = previous[i].error;
          it.cache = true;
          context = {
            ...context,
            ...it.props,
          };
          continue;
        }

        // part is different, force refresh of next layers
        forceRefresh = true;
      }

      // redirect shorthand
      if (route.redirect) {
        return { redirect: route.redirect };
      }

      // Run this layer's `use` middleware (e.g. $secure) around its loader.
      // Without this, client-side navigation would skip page guards entirely
      // and render protected pages for anyone.
      //
      // Browser-only: on the server, ReactServerProvider already wraps the
      // page handler with the collected middleware chain — running it here
      // too would double-execute it. `$cache` is excluded: like on the
      // server it is handled separately, not as a loader-wrapping middleware.
      const middleware = this.alepha.isBrowser()
        ? (route.use ?? []).filter((m) => m[OPTIONS]?.name !== "$cache")
        : [];

      // Nothing to run for this layer — render a basic view by default.
      if (!route.loader && middleware.length === 0) {
        continue;
      }

      try {
        const args = Object.create(state);
        Object.assign(args, config, context);

        // Terminal handler = the loader (or a no-op when the page has none).
        // `reached` stays false if a guard middleware short-circuits without
        // calling `next` — that is how $secure denies access in the browser.
        let reached = false;
        const terminal = async (a: any) => {
          reached = true;
          return (await route.loader?.(a)) ?? {};
        };

        const props = middleware.length
          ? ((await new PipelineHandler(terminal, middleware).run(args)) ?? {})
          : await terminal(args);

        if (!reached) {
          // A page guard (e.g. $secure) denied access. Distinguish the two
          // cases so the flow is right:
          //  - not authenticated (401) → redirect to the login page,
          //    resolved by the conventional `name: "login"`, carrying the
          //    blocked URL as `?redirect=` so login can return the user.
          //  - authenticated but not allowed (403) → a forbidden error;
          //    redirecting a logged-in user to login would just loop.
          const user = this.alepha.store.get(currentUserAtom);

          if (!user) {
            const login = this.findRoute("login");
            if (login?.match && !/[:*]/.test(login.match)) {
              const back = encodeURIComponent(
                state.url.pathname + state.url.search,
              );
              return { redirect: `${login.match}?redirect=${back}` };
            }
          }

          const denied = new AlephaError(
            user
              ? "You do not have permission to access this page."
              : "Authentication required.",
          );
          (denied as { status?: number }).status = user ? 403 : 401;
          throw denied;
        }

        // save props
        it.props = {
          ...props,
        };

        // add props to context
        context = {
          ...context,
          ...props,
        };
      } catch (e) {
        // check if we need to redirect
        if (e instanceof Redirection) {
          return {
            redirect: e.redirect,
          };
        }

        this.log.error("Page loader has failed", e);

        it.error = e instanceof Error ? e : new Error(String(e));
        break;
      }
    }

    let acc = "";
    for (let i = 0; i < stack.length; i++) {
      const it = stack[i];
      const props = it.props ?? {};

      const params = { ...it.config?.params };
      for (const key of Object.keys(params)) {
        params[key] = String(params[key]);
      }

      acc += "/";
      acc += it.route.path ? this.compile(it.route.path, params) : "";
      const path = acc.replace(/\/+/, "/");
      const localErrorHandler = this.getErrorHandler(it.route);
      if (localErrorHandler) {
        const onErrorParent = state.onError;
        state.onError = (error, context) => {
          const result = localErrorHandler(error, context);
          // if nothing happen, call the parent
          if (result === undefined) {
            return onErrorParent(error, context);
          }
          return result;
        };
      }

      // normal use case
      if (!it.error) {
        try {
          const element = await this.createElement(
            it.route,
            {
              // default props attached to page
              ...(it.route.props ? it.route.props() : {}),
              // resolved props
              ...props,
              // context props (from previous layers)
              ...context,
            },
            state.url,
          );

          state.layers.push({
            name: it.route.name,
            props,
            part: it.route.path,
            config: it.config,
            element: this.renderView(i + 1, path, element, it.route),
            index: i + 1,
            path,
            route: it.route,
            cache: it.cache,
          });
        } catch (e) {
          it.error = e instanceof Error ? e : new Error(String(e));
        }
      }

      // handler has thrown an error, render an error view
      if (it.error) {
        try {
          let element: ReactNode | Redirection | undefined =
            await state.onError(it.error, state);

          if (element === undefined) {
            throw it.error;
          }

          if (element instanceof Redirection) {
            return {
              redirect: element.redirect,
            };
          }

          if (element === null) {
            element = this.renderError(it.error);
          }

          state.layers.push({
            props,
            error: it.error,
            name: it.route.name,
            part: it.route.path,
            config: it.config,
            element: this.renderView(i + 1, path, element, it.route),
            index: i + 1,
            path,
            route: it.route,
            cache: it.cache,
          });
          break;
        } catch (e) {
          if (e instanceof Redirection) {
            return {
              redirect: e.redirect,
            };
          }
          throw e;
        }
      }
    }

    // If the matched leaf opts out of SSR (own value or inherited from
    // parents), wrap the root layer in ClientOnly so the server emits no
    // HTML for the route chain. Loaders have already run above.
    if (state.layers.length > 0 && !this.isSSR(route)) {
      const rootLayer = state.layers[0];
      rootLayer.element = createElement(ClientOnly, {}, rootLayer.element);
    }

    return { state };
  }

  protected getErrorHandler(route: PageRoute): ErrorHandler | undefined {
    if (route.errorHandler) return route.errorHandler;
    let parent = route.parent;
    while (parent) {
      if (parent.errorHandler) return parent.errorHandler;
      parent = parent.parent;
    }
  }

  protected async createElement(
    page: PageRoute,
    props: Record<string, any>,
    targetUrl?: URL,
  ): Promise<ReactNode> {
    if (page.lazy && page.component) {
      this.log.warn(
        `Page ${page.name} has both lazy and component options, lazy will be used`,
      );
    }

    if (page.lazy) {
      try {
        const component = await page.lazy();
        return createElement(component.default, props);
      } catch (error) {
        if (this.alepha.isBrowser() && this.isChunkLoadError(error)) {
          if (this.reloadAfterChunkError(targetUrl)) {
            return undefined;
          }
        }
        throw error;
      }
    }

    if (page.component) {
      return createElement(page.component, props);
    }

    return undefined;
  }

  /**
   * Detect chunk load errors caused by stale dynamic imports after a deployment.
   * When new assets are deployed with different hashes, old chunk URLs return 404.
   */
  protected isChunkLoadError(error: unknown): boolean {
    if (!(error instanceof Error)) return false;
    const msg = error.message;
    return (
      /Failed to fetch dynamically imported module/.test(msg) ||
      /error loading dynamically imported module/i.test(msg) ||
      /Unable to preload CSS/.test(msg) ||
      /Importing a module script failed/.test(msg)
    );
  }

  /**
   * Navigate to the target URL to fetch updated assets after a chunk load failure.
   * Uses sessionStorage to prevent infinite reload loops.
   * Returns true if navigation was initiated.
   */
  protected reloadAfterChunkError(url?: URL): boolean {
    const key = "alepha:chunk-reload";
    const lastReload = sessionStorage.getItem(key);
    const now = this.dateTimeProvider.nowMillis();

    if (lastReload && now - Number(lastReload) < 10_000) {
      this.log.error(
        "Chunk load failed after recent reload, not retrying to avoid loop",
      );
      return false;
    }

    this.log.warn("Chunk load failed after deployment, reloading page");
    sessionStorage.setItem(key, String(now));
    window.location.assign(
      url ? url.pathname + url.search : window.location.href,
    );
    return true;
  }

  public renderError(error: Error): ReactNode {
    return createElement(ErrorViewer, { error, alepha: this.alepha });
  }

  public renderEmptyView(): ReactNode {
    return createElement(NestedView, {});
  }

  public href(
    page: { options: { name?: string } },
    params: Record<string, any> = {},
  ): string {
    const found = this.pages.find((it) => it.name === page.options.name);
    if (!found) {
      throw new AlephaError(`Page ${page.options.name} not found`);
    }

    let url = found.path ?? "";
    let parent = found.parent;
    while (parent) {
      url = `${parent.path ?? ""}/${url}`;
      parent = parent.parent;
    }

    url = this.compile(url, params);

    return url.replace(/\/\/+/g, "/") || "/";
  }

  public compile(path: string, params: Record<string, string> = {}) {
    for (const [key, value] of Object.entries(params)) {
      path = path.replace(`:${key}`, value);
    }
    return path;
  }

  protected renderView(
    index: number,
    path: string,
    view: ReactNode | undefined,
    page: PageRoute,
  ): ReactNode {
    view ??= this.renderEmptyView();

    return createElement(
      RouterLayerContext.Provider,
      {
        value: {
          index,
          path,
          onError:
            this.getErrorHandler(page) ?? ((error) => this.renderError(error)),
        },
      },
      view,
    );
  }

  /**
   * Resolve the effective `ssr` value for a route by walking up the parent
   * chain. Returns the nearest explicit `ssr` value, defaulting to `true`.
   *
   * The decision is made at the leaf: a parent's `ssr` only acts as a default
   * for descendants that did not set their own value.
   */
  public isSSR(route: PageRoute): boolean {
    let current: PageRoute | undefined = route;
    while (current) {
      if (typeof current.ssr === "boolean") {
        return current.ssr;
      }
      current = current.parent;
    }
    return true;
  }

  protected map(
    pages: Array<PagePrimitive>,
    target: PagePrimitive,
  ): PageRouteEntry {
    const children = target.options.children
      ? Array.isArray(target.options.children)
        ? target.options.children
        : target.options.children()
      : [];

    const getChildrenFromParent = (it: PagePrimitive): PagePrimitive[] => {
      const children = [];
      for (const page of pages) {
        if (page.options.parent === it) {
          children.push(page);
        }
      }
      return children;
    };

    children.push(...getChildrenFromParent(target));

    return {
      ...target.options,
      name: target.name,
      parent: undefined,
      children: children.map((it) => this.map(pages, it)),
    } as PageRoute;
  }

  public add(entry: PageRouteEntry) {
    if (this.alepha.isReady()) {
      throw new AlephaError("Router is already initialized");
    }

    entry.name ??= this.nextId();
    const page = entry as PageRoute;

    page.match = this.createMatch(page);
    this.pages.push(page);

    if (page.children) {
      for (const child of page.children) {
        (child as PageRoute).parent = page;
        this.add(child);
      }
    }
  }

  protected createMatch(page: PageRoute): string {
    let url = page.path ?? "/";
    let target = page.parent;
    while (target) {
      url = `${target.path ?? ""}/${url}`;
      target = target.parent;
    }

    let path = url.replace(/\/\/+/g, "/");

    if (path.endsWith("/") && path !== "/") {
      // remove trailing slash
      path = path.slice(0, -1);
    }

    return path;
  }

  protected nextId(): string {
    this.nextIdCursor += 1;
    return `P${this.nextIdCursor}`;
  }
}

// ---------------------------------------------------------------------------------------------------------------------

export const isPageRoute = (it: any): it is PageRoute => {
  return (
    it &&
    typeof it === "object" &&
    typeof it.path === "string" &&
    typeof it.page === "object"
  );
};

export interface PageRouteEntry
  extends Omit<PagePrimitiveOptions, "children" | "parent"> {
  children?: PageRouteEntry[];
}

export interface ConcretePageRoute extends PageRoute {
  /**
   * When exported, static routes can be split into multiple pages with different params.
   * We replace 'name' by the new name for each static entry, and old 'name' becomes 'staticName'.
   */
  staticName?: string;

  params?: Record<string, string>;
}

export interface PageRoute extends PageRouteEntry {
  type: "page";
  name: string;
  parent?: PageRoute;
  match: string;

  /**
   * Optional meta information associated with the page route, can be used for any purpose (e.g. menu label, icon, etc.).
   */
  label?: string;
}

export interface Layer {
  config?: {
    query?: Record<string, any>;
    params?: Record<string, any>;
    // stack of resolved props
    context?: Record<string, any>;
  };

  name: string;
  props?: Record<string, any>;
  error?: Error;
  part?: string;
  element: ReactNode;
  index: number;
  path: string;
  route?: PageRoute;
  cache?: boolean;
}

export type PreviousLayerData = Omit<Layer, "element" | "index" | "path">;

export interface AnchorProps {
  href: string;
  onClick: (ev?: any) => any;
}

export interface ReactRouterState {
  /**
   * Stack of layers for the current page.
   */
  layers: Array<Layer>;

  /**
   * URL of the current page.
   */
  url: URL;

  /**
   * Error handler for the current page.
   */
  onError: ErrorHandler;

  /**
   * Params extracted from the URL for the current page.
   */
  params: Record<string, any>;

  /**
   * Query parameters extracted from the URL for the current page.
   */
  query: Record<string, string>;

  /**
   * Optional meta information associated with the current page.
   */
  meta: Record<string, any>;

  /**
   * Head configuration for the current page (title, meta tags, etc.).
   * Populated by HeadProvider during SSR.
   */
  head: Head;

  /**
   * Optional name of the current page route
   */
  name?: string;
}

export interface RouterStackItem {
  route: PageRoute;
  config?: Record<string, any>;
  props?: Record<string, any>;
  error?: Error;
  cache?: boolean;
}

export interface CreateLayersResult {
  redirect?: string;
  state?: ReactRouterState;
}
