import hashlib
import io
import logging
import pathlib
import re

from typing import (
	ClassVar,
	Optional,
	Any,
)

from .models import (
	package_desc_t,
	repo_index_t,
	compile_options_t,
	compile_entry_t,
	compile_result_t,
	mirror_config_t,
	resolution_strategy_t,
)

from .db import db_parser_t
from .compile import compile_t as compile_base_t

logger = logging.getLogger(__name__)


class repo_store_t:
	class constants_t:
		checksum_filename: ClassVar[str] = 'checksum.sha256'

	def __init__(
		self,
		index: repo_index_t,
		db_checksum: str = '',
	) -> None:
		self.index = index
		self.db_checksum = db_checksum

	@staticmethod
	def _file_checksum(path: pathlib.Path) -> str:
		h = hashlib.sha256()
		with io.open(path, 'rb') as f:
			while True:
				chunk = f.read(65536)
				if not chunk:
					break
				h.update(chunk)
		return h.hexdigest()

	@staticmethod
	def from_db(
		db_path: pathlib.Path,
		repo_name: Optional[str] = None,
		cache_dir: Optional[pathlib.Path] = None,
	) -> 'repo_store_t':
		if repo_name is None:
			repo_name = db_path.stem.split('.')[0]

		db_checksum = repo_store_t._file_checksum(db_path)

		if cache_dir is not None:
			solv_cache_path = cache_dir / ('%s.solv' % repo_name)
			checksum_path = cache_dir / ('%s.solv.sha256' % repo_name)
			index_cache_path = cache_dir / ('%s.index.solv' % repo_name)

			if solv_cache_path.exists() and checksum_path.exists():
				stored_checksum = checksum_path.read_text().strip()
				if stored_checksum == db_checksum:
					logger.info(
						dict(
							repo=repo_name,
							msg='using cached solv',
							path=str(solv_cache_path),
						)
					)

					index = db_parser_t.parse_db_path(db_path, repo_name=repo_name)

					return repo_store_t(
						index=index,
						db_checksum=db_checksum,
					)

		index = db_parser_t.parse_db_path(db_path, repo_name=repo_name)

		return repo_store_t(
			index=index,
			db_checksum=db_checksum,
		)

	def write_solv_cache(
		self,
		cache_dir: pathlib.Path,
		solv_repo: Any,
	) -> None:
		import solv

		cache_dir.mkdir(parents=True, exist_ok=True)
		solv_cache_path = cache_dir / ('%s.solv' % self.index.name)
		checksum_path = cache_dir / ('%s.solv.sha256' % self.index.name)

		f = solv.xfopen(str(solv_cache_path), 'w')
		solv_repo.write(f)
		f.close()

		checksum_path.write_text(self.db_checksum)

		logger.info(
			dict(
				repo=self.index.name,
				msg='wrote solv cache',
				path=str(solv_cache_path),
				size=solv_cache_path.stat().st_size,
			)
		)


class solv_pool_t:
	class constants_t:
		dep_re: ClassVar[re.Pattern[str]] = re.compile(r'^([a-zA-Z0-9@._+\-]+?)(?:(>=|<=|>|<|=)(.+))?$')

	def __init__(
		self,
		stores: Optional[list[repo_store_t]] = None,
		cache_dir: Optional[pathlib.Path] = None,
	) -> None:
		import solv

		self._solv = solv
		self._pool = solv.Pool()
		self._pool.setdisttype(solv.Pool.DISTTYPE_ARCH)
		self._pool.setarch('x86_64')
		self._rel_map = {
			'>=': solv.REL_GT | solv.REL_EQ,
			'<=': solv.REL_LT | solv.REL_EQ,
			'>': solv.REL_GT,
			'<': solv.REL_LT,
			'=': solv.REL_EQ,
		}
		self._stores: list[repo_store_t] = []

		if stores is not None:
			for store in stores:
				self.add_store(store, cache_dir=cache_dir)
			self.finalize()

	def _parse_dep(self, dep_str: str) -> Any:
		m = solv_pool_t.constants_t.dep_re.match(dep_str.strip())
		if not m:
			return self._pool.str2id(dep_str)

		name = m.group(1)
		op = m.group(2)
		ver = m.group(3)

		name_id = self._pool.str2id(name)

		if op and ver:
			ver_id = self._pool.str2id(ver)
			return self._pool.rel2id(name_id, ver_id, self._rel_map[op])

		return name_id

	def add_store(
		self,
		store: repo_store_t,
		cache_dir: Optional[pathlib.Path] = None,
	) -> None:
		solv = self._solv

		self._stores.append(store)

		loaded_from_cache = False

		if cache_dir is not None:
			solv_cache_path = cache_dir / ('%s.solv' % store.index.name)
			checksum_path = cache_dir / ('%s.solv.sha256' % store.index.name)

			if solv_cache_path.exists() and checksum_path.exists():
				stored_checksum = checksum_path.read_text().strip()
				if stored_checksum == store.db_checksum:
					repo = self._pool.add_repo(store.index.name)
					f = solv.xfopen(str(solv_cache_path))
					repo.add_solv(f)
					f.close()
					loaded_from_cache = True

					logger.info(
						dict(
							repo=store.index.name,
							msg='loaded solv from cache',
							solvables=repo.nsolvables,
						)
					)

		if not loaded_from_cache:
			repo = self._pool.add_repo(store.index.name)
			for pkg in store.index.packages.values():
				s = repo.add_solvable()
				s.name = pkg.name
				s.evr = pkg.version
				s.arch = 'noarch' if pkg.arch == 'any' else (pkg.arch or 'x86_64')

				for dep_str in pkg.depends:
					s.add_requires(self._parse_dep(dep_str))

				for prov_str in pkg.provides:
					s.add_provides(self._parse_dep(prov_str))

				s.add_provides(self._pool.rel2id(s.nameid, s.evrid, solv.REL_EQ))

				for conf_str in pkg.conflicts:
					s.add_conflicts(self._parse_dep(conf_str))

			repo.internalize()

			if cache_dir is not None:
				store.write_solv_cache(cache_dir, repo)

	def finalize(self) -> None:
		self._pool.createwhatprovides()

	class resolve_t:
		class res_t:
			def __init__(self) -> None:
				self.resolved: dict[str, Any] = {}
				self.problems: list[str] = []

	def expand_groups(
		self,
		packages: list[str],
	) -> list[str]:
		expanded: list[str] = []
		for pkg_name in packages:
			found_group = False
			for store in self._stores:
				if pkg_name in store.index.groups_index:
					expanded.extend(store.index.groups_index[pkg_name])
					found_group = True
					break
			if not found_group:
				expanded.append(pkg_name)
		return expanded

	@staticmethod
	def parse_reference(txt: str) -> dict[str, str]:
		pinned: dict[str, str] = {}
		for line in txt.splitlines():
			line = line.strip()
			if line == '' or line.startswith('#'):
				continue
			parts = line.split()
			pkg_spec = parts[0]
			if '==' in pkg_spec:
				name, version = pkg_spec.split('==', 1)
				pinned[name] = version
		return pinned

	def resolve(
		self,
		packages: list[str],
		expand_groups: bool = True,
		pinned: Optional[dict[str, str]] = None,
		upgrade_packages: Optional[list[str]] = None,
	) -> 'solv_pool_t.resolve_t.res_t':
		solv = self._solv

		if expand_groups:
			packages = self.expand_groups(packages)

		result = solv_pool_t.resolve_t.res_t()

		solver = self._pool.Solver()
		jobs: list[Any] = []

		upgrade_set: set[str] = set()
		if upgrade_packages is not None:
			if expand_groups:
				upgrade_packages = self.expand_groups(upgrade_packages)
			upgrade_set = set(upgrade_packages)

		for pkg_spec in packages:
			pkg_name = pkg_spec.split('>=')[0].split('<=')[0].split('>')[0].split('<')[0].split('=')[0]

			if pinned is not None and pkg_name in pinned and pkg_name not in upgrade_set:
				pinned_spec = '%s=%s' % (pkg_name, pinned[pkg_name])
				dep = self._parse_dep(pinned_spec)
				jobs.append(self._pool.Job(solv.Job.SOLVER_INSTALL | solv.Job.SOLVER_SOLVABLE_PROVIDES, dep))
			else:
				dep = self._parse_dep(pkg_spec)

				sel = self._pool.select(pkg_name, solv.Selection.SELECTION_NAME | solv.Selection.SELECTION_PROVIDES)
				if sel.isempty():
					result.problems.append('package not found: %s' % pkg_spec)
					continue

				if pkg_name != pkg_spec:
					jobs.append(self._pool.Job(solv.Job.SOLVER_INSTALL | solv.Job.SOLVER_SOLVABLE_PROVIDES, dep))
				else:
					jobs += sel.jobs(solv.Job.SOLVER_INSTALL)

		if len(result.problems) > 0:
			return result

		problems = solver.solve(jobs)

		if problems:
			for p in problems:
				result.problems.append(str(p))
			return result

		trans = solver.transaction()
		for s in trans.newsolvables():
			result.resolved[s.name] = s

		return result


class compile_solv_t:
	@staticmethod
	def compile(
		options: compile_options_t,
		stores: Optional[list[repo_store_t]] = None,
	) -> compile_result_t.res_t:
		mirror = compile_base_t.build_mirror_config(options)

		cache_dir: Optional[pathlib.Path] = None
		if options.cache_dir is not None:
			cache_dir = pathlib.Path(options.cache_dir)
			cache_dir.mkdir(parents=True, exist_ok=True)

		if stores is None:
			indices = compile_base_t.fetch_indices(
				mirror=mirror,
				cache_dir=cache_dir,
				no_cache=options.no_cache,
				offline=options.offline,
			)
			stores = [repo_store_t(index=idx) for idx in indices]

		pool = solv_pool_t(stores=stores, cache_dir=cache_dir)

		pinned: Optional[dict[str, str]] = None
		upgrade_packages: Optional[list[str]] = None

		if options.reference is not None:
			ref_txt = pathlib.Path(options.reference).read_text()
			pinned = solv_pool_t.parse_reference(ref_txt)

			if options.resolution_strategy is resolution_strategy_t.pin_referenced:
				upgrade_packages = options.packages
				packages = list(pinned.keys()) + [p for p in options.packages if p not in pinned]
			else:
				packages = options.packages
		else:
			packages = options.packages

		resolved = pool.resolve(
			packages,
			pinned=pinned if options.resolution_strategy is resolution_strategy_t.pin_referenced else None,
			upgrade_packages=upgrade_packages,
		)

		if len(resolved.problems) > 0:
			raise RuntimeError('resolution failed with %d problem(s):\n%s' % (len(resolved.problems), '\n'.join(resolved.problems)))

		result = compile_result_t.res_t()

		for pkg_name, solvable in resolved.resolved.items():
			repo_name = solvable.repo.name if solvable.repo else ''

			pkg_desc: Optional[package_desc_t] = None
			for store in stores:
				candidate = store.index.packages.get(pkg_name)
				if candidate is not None and candidate.version == solvable.evr:
					pkg_desc = candidate
					if store.index.name == repo_name:
						break

			filename = pkg_desc.filename if pkg_desc else ''
			sha256 = (pkg_desc.sha256sum if pkg_desc else '') if options.generate_hashes else ''

			url = ''
			if filename:
				repo_url = ''
				for repo_cfg in mirror.repos:
					if repo_cfg.name == repo_name:
						repo_url = repo_cfg.url
						break

				if repo_url:
					url = '%s/%s' % (repo_url, filename)
				else:
					url = 'https://archive.archlinux.org/packages/%s/%s/%s' % (
						pkg_name[0],
						pkg_name,
						filename,
					)

			entry = compile_entry_t(
				name=pkg_name,
				version=solvable.evr,
				filename=filename,
				repo=repo_name,
				url=url,
				sha256=sha256,
				depends=pkg_desc.depends if pkg_desc else [],
			)

			result.entries.append(entry)

		result.txt = result.to_txt()

		return result
