import dataclasses
import io
import json
import importlib
import configparser
import glob
import os
import pathlib
import logging
import sys
import pydantic
import subprocess
import shutil
import abc
import argparse

from .os import shutil_which

from typing import (
	Optional,
	Literal,
	Any,
	MutableMapping,
	Mapping,
	IO,
)

from typing_extensions import (
	cast,
	Callable,
)

logger = logging.getLogger(__name__)


@dataclasses.dataclass
class Project:
	source_dir: pathlib.Path
	build_dir: pathlib.Path
	dest_dir: pathlib.Path
	meson_path: Optional[pathlib.Path] = None


@dataclasses.dataclass
class PyProject:
	@dataclasses.dataclass
	class Tool:
		@dataclasses.dataclass
		class Meson:
			@dataclasses.dataclass
			class Args:
				install: Optional[list[str]] = None
				setup: Optional[list[str]] = None

			args: Args

		@dataclasses.dataclass
		class MesonPython:
			@dataclasses.dataclass
			class Args:
				install: list[str]

			args: Args

		meson: Optional[Meson] = None

		meson_python: Optional[MesonPython] = pydantic.Field(
			alias='meson-python',
			default=None,
		)

	tool: Optional[Tool] = None


@dataclasses.dataclass
class Dependency:
	name: str
	mode: Literal['pyproject', 'meson', 'meson-python', 'm']
	source_path: pathlib.Path
	args: Optional[list[str]] = None


@dataclasses.dataclass
class DistSettings:
	wheel_dir: pathlib.Path
	python_path: pathlib.Path
	env_path: pathlib.Path


class CLI(abc.ABC):
	@property
	@abc.abstractmethod
	def dist_settings(self) -> DistSettings:
		raise NotImplementedError

	@property
	@abc.abstractmethod
	def projects(self) -> dict[str, Project]:
		raise NotImplementedError

	@property
	@abc.abstractmethod
	def dependencies(self) -> dict[str, Dependency]:
		raise NotImplementedError

	def make_env(
		self,
		env: Optional[dict[str, str]] = None,
		venv_path: bool = True,
		extra_paths: Optional[list[str]] = None,
	) -> dict[str, str]:
		res = dict(list(os.environ.items()))

		if env is not None:
			res.update(env)

		if venv_path or extra_paths:
			path_parts: list[str] = []

			if venv_path:
				path_parts.append(str(pathlib.Path(self.dist_settings.python_path).parent))

			if extra_paths:
				path_parts.extend(extra_paths)

			path_parts.append(os.environ.get('PATH', ''))

			res['PATH'] = os.pathsep.join(path_parts)

		return res

	def mypy(self, argv: list[str]) -> None:
		from . import mypy as _mypy

		_mypy.run(
			argv,
		)

	def ruff(
		self,
		project_name: str,
		argv: list[str],
	) -> None:
		project = self.projects[project_name]

		if len(argv) == 0:
			argv = [
				'check',
				'.',
			]

		cmd = [
			str(self.dist_settings.python_path),
			'-m',
			'ruff',
			'--config',
			str(project.source_dir / 'pyproject.toml'),
			*argv,
		]

		logger.info(
			dict(
				cmd=cmd,
			)
		)

		subprocess.check_call(cmd)

	def pyright(
		self,
		project_name: str,
		argv: list[str],
	) -> None:
		project = self.projects[project_name]

		if len(argv) == 0:
			argv = ['--threads', '3']

		cmd = [
			str(self.dist_settings.python_path),
			'-m',
			'pyright',
			'--pythonpath',
			str(self.dist_settings.python_path),
			'-p',
			str(project.source_dir / 'pyproject.toml'),
			*argv,
		]

		logger.info(cmd)

		subprocess.check_call(cmd)

	def pyrefly(
		self,
		project_name: str,
		argv: list[str],
	) -> None:
		project = self.projects[project_name]

		cmd = [
			str(self.dist_settings.python_path),
			'-m',
			'pyrefly',
			'check',
			*argv,
		]

		logger.info(cmd)

		subprocess.check_call(
			cmd,
			cwd=str(project.source_dir),
		)

	def pip_sync(
		self,
		project: str,
		features: list[str],
	) -> None:
		from . import cli_bootstrap

		pyproject = cli_bootstrap.pyproject_load(self.projects[project].source_dir / 'pyproject.toml')

		dependencies = sum([pyproject.dependencies[o] for o in features], cast(list[str], []))

		pip_find_links: list[pathlib.Path] = []

		if not pyproject.pip_find_links is None:
			pip_find_links.extend(pyproject.pip_find_links)

		logger.info(
			dict(
				dependencies=dependencies,
			)
		)

		if len(dependencies) > 0:
			subprocess.check_call(
				[
					self.dist_settings.python_path,
					'-m',
					'uv',
					'pip',
					'install',
					*sum(
						[
							[
								'-f',
								str(o),
							]
							for o in pip_find_links
						],
						cast(list[str], []),
					),
					# '-f', str(pathlib.Path(__file__).parent / 'deps' / 'dist'),
					'--offline',
					*dependencies,
				]
			)

	def deploy_fetch_dist(
		self,
		force: bool,
	) -> None:
		for k, d in self.dependencies.items():
			whl_glob = self.dist_settings.wheel_dir / ('*%s*.whl' % d.name.replace('.', '_'))
			if len(glob.glob(str(whl_glob))) == 0 or force:
				if d.source_path.exists():

					def whl_files_get() -> list[dict[str, Any]]:
						return [
							dict(
								path=o,
								stat=os.stat(o).st_mtime,
							)
							for o in glob.glob(str(whl_glob))
						]

					present_files = whl_files_get()

					if d.mode == 'm':
						if (d.source_path / 'm.py').exists():
							cmd = [
								sys.executable,
								str(d.source_path / 'm.py'),
								'deploy:wheel',
								'-o',
								str(self.dist_settings.wheel_dir),
							]

							if not d.args is None:
								cmd.extend(d.args)

							subprocess.check_call(
								cmd,
								cwd=d.source_path,
							)
					else:
						raise NotImplementedError

					updated_files = whl_files_get()

					def index_get(o: dict[str, Any]) -> tuple[Any, ...]:
						return (o['path'], o['stat'])

					present_files_index = {index_get(o): o for o in present_files}

					new_files: list[dict[str, Any]] = []

					for o in updated_files:
						entry_index = index_get(o)

						if not entry_index in present_files_index:
							new_files.append(o)

					if len(new_files) == 0:
						raise NotImplementedError

					latest_file = sorted(new_files, key=lambda x: x['stat'])[-1]

					subprocess.check_call(
						[
							self.dist_settings.python_path,
							'-m',
							'pip',
							'install',
							latest_file['path'],
						]
					)

	@property
	def pkg_config_path(
		self,
	) -> set[pathlib.Path]:
		return {
			pathlib.Path(o)
			for o in glob.glob(
				str(self.dist_settings.env_path / 'lib' / 'python*' / '**' / 'pkgconfig'),
				recursive=True,
			)
		}

	def deploy_wheel(
		self,
		project_name: str,
		argv: Optional[list[str]] = None,
		output_dir: Optional[pathlib.Path] = None,
		force: Optional[bool] = None,
		env: Optional[dict[str, str]] = None,
		mypy: bool = False,
		ruff: bool = False,
		pyright: bool = False,
		pyrefly: bool = False,
		tests: bool = False,
		force_whl_overwrite: bool = False,
	) -> None:
		project = self.projects[project_name]

		# subprocess.check_call([
		#     sys.argv[0],
		#     # sys.executable,
		#     '-p', options.project,
		#     Command.meson_setup.value,
		# ])

		if argv is None:
			argv = []

		from . import argparse as pr34_argparse

		deploy_parser = argparse.ArgumentParser()
		deploy_parser.add_argument(
			'--force-whl-overwrite',
			default=False,
			action='store_true',
			help='overwrite existing .whl files in output dir',
		)

		deploy_options, argv = pr34_argparse.parse_args(deploy_parser, argv)
		force_whl_overwrite = deploy_options.force_whl_overwrite

		# assert argv is None or len(argv) == 0

		if not project.meson_path is None:
			if force or not (project.build_dir / 'meson' / 'build.ninja').exists():
				self.meson_setup(
					project_name=project_name,
					force=force if force is not None else False,
				)

			if tests:
				self.meson_test(
					project_name=project_name,
				)

			self.meson_install(
				project_name=project_name,
				force=force,
			)

		if ruff:
			self.ruff(
				project_name=project_name,
				argv=[
					'format',
					'--check',
				],
			)

			self.ruff(
				project_name=project_name,
				argv=[],
			)

		if mypy:
			self.mypy([])

		if pyright:
			self.pyright(
				project_name=project_name,
				argv=[],
			)

		if pyrefly:
			self.pyrefly(
				project_name=project_name,
				argv=[],
			)

		if env is None:
			env = dict()

		extra_args: list[str] = []

		pyproject_build_dir = project.build_dir / 'pyproject'

		if force and pyproject_build_dir.exists():
			logger.info(
				dict(
					pyproject_build_dir=pyproject_build_dir,
					msg='remove build dir',
				)
			)
			shutil.rmtree(pyproject_build_dir)

		if len(self.third_party_roots(project_name)) > 0:
			extra_args.append('-Csetup-args=%s' % ('-Dthird_party_roots=%s' % json.dumps([str(o.absolute()) for o in self.third_party_roots(project_name)])))

		cmd = [
			sys.executable,
			'-m',
			'build',
			'-w',
			'-n',
			*extra_args,
		]

		if not project.meson_path is None:
			cmd.extend(
				[
					'-Csetup-args=-Dmodes=pyproject',
					'-Cbuild-dir=%s' % str(pyproject_build_dir),
					'-Csetup-args=-Dinstall_path=%s' % str(project.dest_dir),
				]
			)

		cmd.extend(
			[
				# '-Cbuild-dir=%s' % str(project.build_dir),
				str(project.source_dir),
				*argv,
			]
		)

		if not output_dir is None:
			cmd.extend(['-o', str(output_dir)])

			if not force_whl_overwrite:
				from . import cli_bootstrap

				pyproject = cli_bootstrap.pyproject_load(project.source_dir / 'pyproject.toml')
				whl_name_prefix = (pyproject.name or project_name).replace('.', '_').replace('-', '_')
				whl_name_prefix += '-' + pyproject.version if pyproject.version else ''

				conflicting = [o for o in glob.glob(str(pathlib.Path(output_dir) / '*.whl')) if pathlib.Path(o).name.startswith(whl_name_prefix + '-')]

				if len(conflicting) > 0:
					raise FileExistsError(
						'wheel(s) already exist in %s: %s. Use --force-whl-overwrite to overwrite.' % (output_dir, [pathlib.Path(o).name for o in conflicting])
					)

		logger.info(dict(env=env, cmd=cmd))

		subprocess.check_call(
			cmd,
			env=self.make_env(env=env),
		)

		if not project.meson_path is None:
			if tests:
				subprocess.check_call(
					[
						'ninja',
						'-C',
						str(project.build_dir / 'pyproject'),
						'test',
					]
				)

	def meson_install(
		self,
		project_name: str,
		force: Optional[bool] = None,
		argv: Optional[list[str]] = None,
	) -> None:
		from . import cli_bootstrap
		from .os import shutil_rmtree

		project = self.projects[project_name]

		if force is None:
			force = False

		if argv is None:
			argv = []

		if force and project.dest_dir.exists():
			shutil_rmtree(
				project.dest_dir,
				preserve_top_path=True,
			)

		pyproject = cli_bootstrap.pyproject_load(project.source_dir / 'pyproject.toml')

		pyproject_tool = pydantic.RootModel[PyProject.Tool].model_validate(pyproject.tool).root

		if pyproject_tool.meson and pyproject_tool.meson.args and pyproject_tool.meson.args.install:
			argv = pyproject_tool.meson.args.install + argv

		cmd = [
			str(self.dist_settings.python_path),
			'-m',
			'mesonbuild.mesonmain',
			# shutil_which(
			# 	'meson',
			# 	True,
			# ),
			'install',
			'-C',
			str(project.build_dir / 'meson'),
			'--destdir',
			str(project.dest_dir),
			*argv,
		]

		logger.info(
			dict(
				cmd=cmd,
			)
		)

		subprocess.check_call(
			cmd,
			env=self.make_env(),
		)

		for o in glob.glob(
			str(project.dest_dir / 'lib' / 'pkgconfig' / '*.pc'),
			recursive=True,
		):
			logger.info(
				dict(
					path=o,
					action='patch prefix',
				)
			)

			with io.open(o, 'r') as f:
				content = f.read()

			with io.open(o, 'w') as f:
				f.write(content.replace('prefix=/', 'prefix=${pcfiledir}/../../'))

	def ninja(
		self,
		project_name: str,
		argv: Optional[list[str]] = None,
		env: Optional[dict[str, str]] = None,
	) -> None:
		project = self.projects[project_name]

		if argv is None:
			argv = []

		if env is None:
			env = dict()

		logger.info(dict(env=env))

		subprocess.check_call(
			[
				shutil_which('ninja', True),
				'-C',
				str(project.build_dir / 'meson'),
				*argv,
			],
			env=self.make_env(env=env),
		)

	def meson_test(
		self,
		project_name: str,
		argv: Optional[list[str]] = None,
	) -> None:
		project = self.projects[project_name]

		if argv is None:
			argv = []

		subprocess.check_call(
			[
				str(self.dist_settings.python_path),
				'-m',
				'mesonbuild.mesonmain',
				# shutil_which(
				# 	'meson',
				# 	True,
				# ),
				'test',
				'-C',
				project.build_dir / 'meson',
				*argv,
			]
		)

	def meson_compile(
		self,
		project_name: str,
		argv: Optional[list[str]] = None,
	) -> None:
		project = self.projects[project_name]

		if argv is None:
			argv = []

		subprocess.check_call(
			[
				str(self.dist_settings.python_path),
				'-m',
				'mesonbuild.mesonmain',
				# shutil_which(
				# 	'meson',
				# 	True,
				# ),
				'compile',
				'-C',
				project.build_dir / 'meson',
				*argv,
			]
		)

	def third_party_roots(
		self,
		project_name: Optional[str] = None,
	) -> list[pathlib.Path]:
		from . import cli_bootstrap
		from .pip import pip_show

		res: list[pathlib.Path] = []

		if not project_name is None:
			pyproject = cli_bootstrap.pyproject_load(self.projects[project_name].source_dir / 'pyproject.toml')

			for third_party_root in pyproject.third_party_roots:
				if third_party_root.package:
					if not third_party_root.module_root:
						third_party_root.module_root = third_party_root.package.replace('.', os.path.sep)
					if not third_party_root.path:
						packages = pip_show([third_party_root.package])
						assert len(packages) == 1
						third_party_root.path = str(pathlib.Path(packages[0].location) / third_party_root.module_root / 'lib')
				else:
					assert not third_party_root.package and not third_party_root.module_root and third_party_root.path

				res.append(pathlib.Path(third_party_root.path))

			# res.append(self.projects[project_name].dest_dir / 'lib')

		return res

	class meson_toolchains_t:
		class res_t:
			@dataclasses.dataclass
			class toolchain_t:
				name: str
				path: Optional[pathlib.Path] = None

	@property
	def meson_toolchains(self) -> dict[str, meson_toolchains_t.res_t.toolchain_t]:
		t1 = pathlib.Path(importlib.import_module('online.fxreader.pr34').__path__[0])
		toolchains = glob.glob(str(t1 / 'meson' / 'toolchains' / '*'))

		res: dict[str, CLI.meson_toolchains_t.res_t.toolchain_t] = dict()

		for o in toolchains:
			entry = self.meson_toolchains_t.res_t.toolchain_t(
				name=pathlib.Path(o).stem,
				path=pathlib.Path(o),
			)

			config = configparser.ConfigParser()
			config.read(str(entry.path))

			res[entry.name] = entry

		return res

	def _cross_file(
		self,
		extra_args: list[str],
		pyproject_tool: PyProject.Tool,
	) -> list[str]:
		from . import argparse as pr34_argparse

		if pyproject_tool.meson and pyproject_tool.meson.args and pyproject_tool.meson.args.setup:
			extra_args = pyproject_tool.meson.args.setup + extra_args

			parser = argparse.ArgumentParser()
			parser.add_argument(
				'--cross-file',
				dest='cross_file',
				default=None,
				# type=pathlib.Path,
				type=pathlib.Path,
			)

			options, args = pr34_argparse.parse_args(parser, extra_args)

			if not options.cross_file is None:
				if not options.cross_file.exists() and (not options.cross_file.is_absolute() and options.cross_file.stem in self.meson_toolchains):
					options.cross_file = self.meson_toolchains[options.cross_file.stem].path

				extra_args = ['--cross-file', str(options.cross_file)] + args

		return extra_args

	def meson_setup(
		self,
		project_name: str,
		force: bool,
		argv: Optional[list[str]] = None,
		env: Optional[dict[str, str]] = None,
		# third_party_roots: Optional[list[pathlib.Path]] = None,
	) -> None:
		from . import cli_bootstrap
		from .os import shutil_rmtree

		project = self.projects[project_name]

		if argv is None:
			argv = []

		if env is None:
			env = dict()

		pyproject = cli_bootstrap.pyproject_load(project.source_dir / 'pyproject.toml')

		pyproject_tool = pydantic.RootModel[PyProject.Tool].model_validate(pyproject.tool).root

		logger.info(dict(env=env))

		if force:
			if (project.build_dir / 'meson').exists():
				logger.info(dict(action='removing build dir', path=project.build_dir / 'meson'))
				shutil.rmtree(project.build_dir / 'meson')

		extra_args: list[str] = []

		extra_args = self._cross_file(
			extra_args=extra_args,
			pyproject_tool=pyproject_tool,
		)

		if len(self.third_party_roots(project_name)) > 0:
			extra_args.append('-Dthird_party_roots=%s' % json.dumps([str(o.absolute()) for o in self.third_party_roots(project_name)]))

		cmd = [
			# shutil_which(
			# 	'meson',
			# 	True,
			# ),
			str(self.dist_settings.python_path),
			'-m',
			'mesonbuild.mesonmain',
			'setup',
			str(project.source_dir),
			str(project.build_dir / 'meson'),
			'-Dmodes=["meson"]',
			*extra_args,
			# '-Dpkgconfig.relocatable=true',
			'-Dprefix=/',
			*argv,
		]

		logger.info(dict(cmd=cmd))

		subprocess.check_call(
			cmd,
			env=self.make_env(env=env),
		)

	def venv_compile(
		self,
		project_name: str,
		# force: bool,
		argv: Optional[list[str]] = None,
	) -> None:
		from . import cli_bootstrap
		from . import argparse as pr34_argparse

		project = self.projects[project_name]

		bootstrap_settings = cli_bootstrap.BootstrapSettings.get()

		parser = argparse.ArgumentParser()
		parser.add_argument(
			'-f',
			dest='features',
			action='append',
			default=[],
			# type=pathlib.Path,
			type=str,
		)
		parser.add_argument(
			'-p',
			dest='python_version',
			default=bootstrap_settings.python_version,
			# type=pathlib.Path,
			type=str,
		)

		options, args = pr34_argparse.parse_args(
			parser,
			argv,
		)

		pyproject = cli_bootstrap.pyproject_load(project.source_dir / 'pyproject.toml')

		dependencies = sum([pyproject.dependencies[o] for o in options.features], cast(list[str], []))

		pip_find_links: list[pathlib.Path] = []

		if not pyproject.pip_find_links is None:
			pip_find_links.extend([o for o in pyproject.pip_find_links if o.exists()])

		requirements_name_get_res = cli_bootstrap.requirements_name_get(
			source_dir=project.source_dir,
			features=options.features,
			python_version=options.python_version,
			requirements=pyproject.requirements,
		)

		logger.info(
			dict(
				dependencies=dependencies,
				requirements_name_get_res=requirements_name_get_res,
			)
		)

		with io.open(
			requirements_name_get_res.not_compiled,
			'w',
		) as f:
			f.write(
				'\n'.join(dependencies),
			)
			f.flush()

		if len(dependencies) > 0:
			cmd = [
				str(self.dist_settings.python_path),
				'-m',
				'uv',
				'pip',
				'compile',
				*args,
				'--python-version',
				options.python_version,
				*sum(
					[
						[
							'-f',
							str(o),
						]
						for o in pip_find_links
					],
					cast(list[str], []),
				),
				'--generate-hashes',
				str(requirements_name_get_res.not_compiled),
				'-o',
				str(requirements_name_get_res.compiled),
			]

			logger.info(
				dict(
					cmd=cmd,
				)
			)
			subprocess.check_call(cmd)

	def module_switch(
		self,
		project_name: str,
		# force: bool,
		argv: Optional[list[str]] = None,
	) -> None:
		import tomlkit
		import tomlkit.container
		import tomlkit.items

		from . import cli_bootstrap
		from . import argparse as pr34_argparse
		from .toml import toml_add_overlay

		project = self.projects[project_name]

		parser = argparse.ArgumentParser()
		parser.add_argument(
			'-m',
			dest='module',
			# choices=[
			#     o.name
			#     for o in project.modules
			# ],
			required=True,
			# type=pathlib.Path,
			type=str,
		)

		parser.add_argument(
			'-f',
			dest='file',
			default=pathlib.Path('pyproject.common.toml'),
			# type=pathlib.Path,
			type=pathlib.Path,
		)

		options, args = pr34_argparse.parse_args(
			parser,
			argv,
		)

		if not options.file.is_absolute():
			options.file = project.source_dir / options.file

		pyproject = cli_bootstrap.pyproject_load(
			options.file,
		)

		assert options.module in [o.name for o in pyproject.modules]

		modules: dict[str, cli_bootstrap.PyProject.Module] = {o.name: o for o in pyproject.modules}

		module = modules[options.module]

		with io.open(options.file, 'rb') as f:
			pyproject2 = tomlkit.load(f)

		with io.open(
			project.source_dir / 'pyproject.toml',
			'w',
		) as f:
			p = pyproject2['project']
			assert isinstance(p, tomlkit.items.Table)
			p['name'] = module.name

			if not pyproject2['tool']:
				pyproject2['tool'] = []

			if not 'tool' in pyproject2:
				pyproject2['tool'] = dict()

			pyproject_tool = pyproject2['tool']
			# assert isinstance(pyproject_tool, tomlkit.items.Array)
			assert isinstance(pyproject_tool, MutableMapping)

			toml_add_overlay(
				pyproject_tool,
				module.tool,
			)

			# for k, v in module.tool.items():
			# 	assert not k in pyproject_tool
			# 	pyproject_tool[k] = v

			if len(module.scripts) > 0:
				if 'scripts' not in p:
					p['scripts'] = dict()

				scripts = p['scripts']
				assert isinstance(scripts, MutableMapping)

				for k, v in module.scripts.items():
					scripts[k] = v

			if len(module.project) > 0:
				for k, v in module.project.items():
					p[k] = v

			del p
			del pyproject_tool

			cast(
				Callable[[Mapping[Any, Any], IO[str] | IO[bytes]], None],
				getattr(tomlkit, 'dump'),
			)(pyproject2, f)

		del pyproject2
		del module

		# raise NotImplementedError
